Rebrand product as Manyangles and add Authentik

This commit is contained in:
2026-09-08 09:17:25 -04:00
parent 27e2f196eb
commit f5702caaea
37 changed files with 630 additions and 237 deletions
+3 -5
View File
@@ -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,
+26 -1
View File
@@ -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,
+2 -6
View File
@@ -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,
+46 -1
View File
@@ -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,
},
],
}),
]
: [],
});
+26 -1
View File
@@ -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);
+1 -1
View File
@@ -68,7 +68,7 @@ await seedCredentialUser({
password: "admin",
});
await seedCredentialUser({
name: "Maya Host",
name: "Demo Host",
email: "host@example.com",
password: "host",
});
+10
View File
@@ -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}$/);
}
});
});
+7 -2
View File
@@ -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("");
}