Rebrand product as Manyangles and add Authentik
This commit is contained in:
@@ -26,7 +26,8 @@ import { getPlatformRole } from "@/server/roles";
|
||||
import { resolveGroupQuota } from "@/server/entitlements";
|
||||
import { countGroupOwners } from "@/server/membership";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
import { hashToken, newInviteCode, newToken } from "@/server/tokens";
|
||||
import { hashToken, newToken } from "@/server/tokens";
|
||||
import { createInviteCode } from "@/server/invites";
|
||||
import { sendStaffInviteEmail } from "@album/email";
|
||||
import { publicAppOrigin } from "@/server/public-app-url";
|
||||
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
||||
@@ -265,10 +266,7 @@ export const groupRouter = createTRPCRouter({
|
||||
platformRole,
|
||||
);
|
||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||
const code = newInviteCode();
|
||||
await getDb().insert(invites).values({
|
||||
kind: "code",
|
||||
tokenHash: hashToken(code),
|
||||
const code = await createInviteCode({
|
||||
reusable: input.reusable ?? false,
|
||||
maxUses: input.maxUses ?? 1,
|
||||
groupId: input.groupId,
|
||||
|
||||
@@ -3,9 +3,22 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getDeploymentSettings } from "@/server/settings";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
|
||||
export const invitesRouter = createTRPCRouter({
|
||||
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ input }) => {
|
||||
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ ctx, input }) => {
|
||||
const limit = await consumeRateLimit({
|
||||
namespace: "invite-preview",
|
||||
identifier: ctx.clientIdentifier,
|
||||
limit: 12,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
});
|
||||
if (!limit.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many invite attempts. Try again shortly.",
|
||||
});
|
||||
}
|
||||
const invite = await findInviteByToken(input.token);
|
||||
if (!invite) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
@@ -23,6 +36,18 @@ export const invitesRouter = createTRPCRouter({
|
||||
redeem: protectedProcedure
|
||||
.input(redeemInviteInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const limit = await consumeRateLimit({
|
||||
namespace: "invite-redeem",
|
||||
identifier: `${ctx.session.user.id}:${ctx.clientIdentifier}`,
|
||||
limit: 12,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
});
|
||||
if (!limit.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Too many invite attempts. Try again shortly.",
|
||||
});
|
||||
}
|
||||
return redeemInviteForUser({
|
||||
token: input.token,
|
||||
userId: ctx.session.user.id,
|
||||
|
||||
@@ -26,8 +26,7 @@ import { hasPlatformPermission } from "@/server/roles";
|
||||
import { getDeploymentSettings } from "@/server/settings";
|
||||
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
import { hashToken, newInviteCode } from "@/server/tokens";
|
||||
import { invites } from "@album/database";
|
||||
import { createInviteCode } from "@/server/invites";
|
||||
|
||||
export const platformRouter = createTRPCRouter({
|
||||
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
|
||||
@@ -200,10 +199,7 @@ export const platformRouter = createTRPCRouter({
|
||||
createCode: platformPermissionProcedure(PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)
|
||||
.input(createInviteCodeInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const code = newInviteCode();
|
||||
await getDb().insert(invites).values({
|
||||
kind: "code",
|
||||
tokenHash: hashToken(code),
|
||||
const code = await createInviteCode({
|
||||
reusable: input.reusable ?? true,
|
||||
maxUses: input.maxUses ?? 100,
|
||||
groupId: input.groupId ?? null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb } from "@album/database";
|
||||
import {
|
||||
sendAccountVerificationEmail,
|
||||
@@ -11,8 +12,30 @@ const baseURL =
|
||||
process.env.NEXT_PUBLIC_APP_URL ??
|
||||
"http://localhost:3000";
|
||||
|
||||
const authentikIssuer = process.env.AUTHENTIK_ISSUER?.trim();
|
||||
const authentikClientId = process.env.AUTHENTIK_CLIENT_ID?.trim();
|
||||
const authentikClientSecret = process.env.AUTHENTIK_CLIENT_SECRET?.trim();
|
||||
const authentikValues = [
|
||||
authentikIssuer,
|
||||
authentikClientId,
|
||||
authentikClientSecret,
|
||||
];
|
||||
|
||||
if (authentikValues.some(Boolean) && !authentikValues.every(Boolean)) {
|
||||
throw new Error(
|
||||
"Authentik requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, and AUTHENTIK_CLIENT_SECRET",
|
||||
);
|
||||
}
|
||||
|
||||
export const authentikEnabled = authentikValues.every(Boolean);
|
||||
|
||||
function authentikDiscoveryUrl(issuer: string) {
|
||||
const base = issuer.endsWith("/") ? issuer : `${issuer}/`;
|
||||
return new URL(".well-known/openid-configuration", base).toString();
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Vellum",
|
||||
appName: "Manyangles",
|
||||
baseURL,
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
advanced: {
|
||||
@@ -21,6 +44,12 @@ export const auth = betterAuth({
|
||||
database: drizzleAdapter(getDb(), {
|
||||
provider: "pg",
|
||||
}),
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: authentikEnabled ? ["authentik"] : [],
|
||||
},
|
||||
},
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
@@ -59,4 +88,20 @@ export const auth = betterAuth({
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
plugins: authentikEnabled
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
discoveryUrl: authentikDiscoveryUrl(authentikIssuer!),
|
||||
clientId: authentikClientId!,
|
||||
clientSecret: authentikClientSecret!,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,32 @@ import {
|
||||
ensureEventMembership,
|
||||
ensureGroupMembership,
|
||||
} from "@/server/membership";
|
||||
import { hashToken } from "@/server/tokens";
|
||||
import { hashToken, newInviteCode } from "@/server/tokens";
|
||||
|
||||
type InviteCodeValues = Omit<
|
||||
typeof invites.$inferInsert,
|
||||
"kind" | "tokenHash"
|
||||
>;
|
||||
|
||||
export async function createInviteCode(
|
||||
values: InviteCodeValues,
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const code = newInviteCode();
|
||||
const [created] = await db
|
||||
.insert(invites)
|
||||
.values({ ...values, kind: "code", tokenHash: hashToken(code) })
|
||||
.onConflictDoNothing({ target: invites.tokenHash })
|
||||
.returning({ id: invites.id });
|
||||
if (created) return code;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not allocate an invite code",
|
||||
});
|
||||
}
|
||||
|
||||
export async function findInviteByToken(token: string, db: Database = getDb()) {
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
@@ -68,7 +68,7 @@ await seedCredentialUser({
|
||||
password: "admin",
|
||||
});
|
||||
await seedCredentialUser({
|
||||
name: "Maya Host",
|
||||
name: "Demo Host",
|
||||
email: "host@example.com",
|
||||
password: "host",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { newInviteCode } from "./tokens";
|
||||
|
||||
describe("invite codes", () => {
|
||||
test("uses four uppercase alphanumeric characters", () => {
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
expect(newInviteCode()).toMatch(/^[A-Z0-9]{4}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||
|
||||
const INVITE_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token.trim().toLowerCase()).digest("hex");
|
||||
@@ -9,5 +11,8 @@ export function newToken(bytes = 24) {
|
||||
}
|
||||
|
||||
export function newInviteCode() {
|
||||
return `VELLUM-${randomBytes(5).toString("hex").toUpperCase()}`;
|
||||
return Array.from(
|
||||
{ length: 4 },
|
||||
() => INVITE_CODE_ALPHABET[randomInt(INVITE_CODE_ALPHABET.length)],
|
||||
).join("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user