diff --git a/apps/admin/src/app/styles/admin.css b/apps/admin/src/app/styles/admin.css index 3d7bb38f..199991ad 100644 --- a/apps/admin/src/app/styles/admin.css +++ b/apps/admin/src/app/styles/admin.css @@ -110,14 +110,12 @@ font-size: 1rem; letter-spacing: -0.02em; } + /* No tile. The logo is a multi-tone leaf that brings its own colour — the + accent square existed to make a flat white glyph legible, and the glyph + has gone. */ .rail-brand .mark { display: grid; place-items: center; - width: 24px; - height: 24px; - border-radius: 7px; - background: var(--accent); - color: var(--accent-ink); flex: none; } .rail-brand small { @@ -415,8 +413,15 @@ .row-broadcast { grid-template-columns: 80px minmax(0, 1fr) 70px 28px; } + /* The name column is `1fr`, so these tracks plus the gaps are the row's real + minimum: put this list in a container narrower than that and the name gets + 0px and every column lands on top of the one before it. It belongs in + `.content-narrow`, not `.content-form`. */ .row-member { - grid-template-columns: minmax(0, 1fr) 104px 140px 92px minmax(0, 320px); + grid-template-columns: minmax(0, 1fr) 132px 140px 92px minmax(0, 240px); + } + .row-role { + grid-template-columns: minmax(0, 190px) minmax(0, 1fr) minmax(0, 210px) 96px; } /* An identifier in a list — an address, a slug, a package name. */ @@ -554,6 +559,14 @@ .panel-body { padding: 14px; } + /* A list inside a panel: the panel head is already a rule above it, and the + panel's own border closes it off below. */ + .panel > .rows { + border-top: none; + } + .panel > .rows > .row:last-child { + border-bottom: none; + } /* ---------- forms ---------- */ @@ -808,7 +821,10 @@ font-weight: 600; letter-spacing: -0.015em; } - .dialog-card p { + /* The direct child only. This is the confirm dialog's body copy, and a + descendant selector would also claim every paragraph in the form dialogs + built on the same card — at a higher specificity than their own classes. */ + .dialog-card > p { margin-top: 7px; color: var(--text-dim); font-size: 0.88rem; @@ -862,12 +878,8 @@ .sheet-card .mark { display: grid; place-items: center; - width: 38px; - height: 38px; + width: fit-content; margin: 0 auto 18px; - border-radius: 11px; - background: var(--accent); - color: var(--accent-ink); } .sheet-card h1 { font-family: var(--font-display); @@ -985,12 +997,6 @@ align-items: center; gap: 10px; } - .member-scopes .field-label { - color: var(--text-dim); - font-size: 0.82rem; - font-weight: 500; - } - /* A checkbox with its word beside it, small enough to live inside a row. */ .tick { display: inline-flex; @@ -1026,6 +1032,229 @@ grid-template-columns: minmax(0, 1fr) 140px auto; } + /* ---------- roles and permissions ---------- */ + + .member-role { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + } + /* "+2 −1": this person's grant is not their role's. Small, but the one thing + on the row a reader has to look twice at. */ + .member-exceptions { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 0.7rem; + white-space: nowrap; + } + + .role-grants { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + .role-why { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .role-lock { + color: var(--text-faint); + } + + /* One permission: a checkbox, the verb, and a line saying what it hands over. */ + .perm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); + gap: 10px; + } + /* A `
` rather than a fieldset: a legend is laid out by + rules of its own that a flex or grid fieldset has never handled the same + way twice across browsers, and the heading here has to sit in a row with + the all/none control. */ + .perm-group { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 11px 11px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--surface-2); + transition: border-color 0.15s; + } + .perm-group[data-active="true"] { + border-color: color-mix(in oklab, var(--accent) 34%, var(--line)); + } + .perm-legend { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + width: 100%; + margin-bottom: 6px; + font-size: 0.78rem; + font-weight: 600; + } + .perm-all { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 400; + } + .perm-all:hover:not(:disabled) { + color: var(--accent); + } + + .perm { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + padding: 5px 6px; + border-radius: var(--r-sm); + cursor: pointer; + } + .perm:hover { + background: var(--chip); + } + .perm input { + margin-top: 3px; + accent-color: var(--accent); + } + .perm-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + } + .perm-verb { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-dim); + font-size: 0.8rem; + } + .perm[data-on="true"] .perm-verb { + color: var(--text); + font-weight: 500; + } + /* Only the differences are labelled. A tick that matches the role is the + default state and needs no word for it. */ + .perm-flag { + padding: 0 5px; + border-radius: var(--r-sm); + background: color-mix(in oklab, var(--ok) 18%, transparent); + color: var(--ok); + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.02em; + } + .perm-flag-off { + background: color-mix(in oklab, var(--danger) 16%, transparent); + color: var(--danger); + } + .perm-hint { + color: var(--text-faint); + font-size: 0.72rem; + line-height: 1.45; + } + + /* ---------- form dialogs ---------- */ + + /* Wider than the confirm dialog, and scrolls: a permission matrix is taller + than a short viewport, and the actions have to stay reachable. */ + .dialog-card-wide { + display: flex; + flex-direction: column; + width: min(760px, calc(100vw - 32px)); + max-height: min(88vh, 860px); + padding: 0; + } + .dialog-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 18px 13px; + border-bottom: 1px solid var(--line-soft); + } + .dialog-sub { + margin-top: 3px; + color: var(--text-faint); + font-size: 0.78rem; + line-height: 1.45; + } + .dialog-close { + margin-left: auto; + color: var(--text-faint); + line-height: 1; + } + .dialog-close:hover { + color: var(--text); + } + .dialog-body { + padding: 16px 18px; + overflow-y: auto; + } + .dialog-card-wide .dialog-actions { + align-items: center; + margin-top: 0; + padding: 12px 18px; + border-top: 1px solid var(--line-soft); + background: var(--surface-2); + border-radius: 0 0 var(--r-md) var(--r-md); + } + /* What the draft currently amounts to, beside the button that will save it. */ + .dialog-status { + margin-right: auto; + color: var(--text-faint); + font-size: 0.76rem; + } + + .dialog-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0 16px; + } + .dialog-section { + margin: 4px 0 12px; + } + .dialog-section h3 { + margin-bottom: 4px; + font-size: 0.85rem; + font-weight: 600; + } + .dialog-section .side-note { + line-height: 1.5; + } + + /* A label outside a `
diff --git a/apps/admin/src/views/members/members-page.tsx b/apps/admin/src/views/members/members-page.tsx index 0de2d171..434f26f8 100644 --- a/apps/admin/src/views/members/members-page.tsx +++ b/apps/admin/src/views/members/members-page.tsx @@ -1,10 +1,12 @@ import { MAIL_WORKSPACE_LABELS, MAIL_WORKSPACES } from "@byteveda/db/constants"; import { PageHeader } from "@/components"; -import { can, roleLabel } from "@/lib/auth/roles"; +import { can, hasOverrides, roleLabel } from "@/lib/auth/roles"; import { requirePermission } from "@/lib/auth/session"; import { ago } from "@/lib/format"; -import { listMembers, type Member } from "@/lib/members/queries"; +import { listCustomRoles, listMembers, type Member } from "@/lib/members/queries"; import { InviteForm, MemberControls } from "./member-controls"; +import { RolesPanel } from "./roles-panel"; +import type { RoleOption } from "./types"; /** * Who has access, and what to. @@ -12,14 +14,30 @@ import { InviteForm, MemberControls } from "./member-controls"; * Only a super admin can change anything here — `members.manage` is in no * role's permission set — but `members.read` lets an admin see the list, which * is the difference between "you cannot do this" and "you cannot see who can". + * + * A list, not a form, so it takes the full width. It used to be `content-form` + * at 720px, which is narrower than the row's own columns add up to: the name + * column collapsed to nothing and the header printed "Member" and "Role" on + * top of each other. The invite panel is the form, and it constrains itself. */ export async function MembersPage() { const { access, user } = await requirePermission("members.read"); - const members = await listMembers(); + const [members, customRoles] = await Promise.all([listMembers(), listCustomRoles()]); const manage = can(access, "members.manage"); const pending = members.filter((member) => member.user.lastLoginAt === null).length; + // The rows travel to the browser, so only the fields a dropdown and a matrix + // read — not the timestamps, and not who wrote the role. + const roles: RoleOption[] = customRoles.map((role) => ({ + id: role.id, + key: role.key, + label: role.label, + description: role.description, + permissions: [...role.permissions], + members: role.members, + })); + return ( <> 0 ? `, ${pending} not signed in yet` : ""}`} /> -
+
{!manage && (
@@ -50,13 +68,16 @@ export async function MembersPage() { ))}
- {manage && } + + + {manage && }
); @@ -64,10 +85,12 @@ export async function MembersPage() { function MemberRow({ member, + roles, manage, isSelf, }: { member: Member; + roles: RoleOption[]; manage: boolean; isSelf: boolean; }) { @@ -84,8 +107,20 @@ function MemberRow({ - - {roleLabel(access)} + + + {roleLabel(access)} + + {/* Somebody whose grant no longer matches their role is the one row a + reader has to look twice at. Saying so is cheaper than making them + open the dialog to find out. */} + {hasOverrides(access) && ( + + {access.extra.length > 0 && `+${access.extra.length}`} + {access.extra.length > 0 && access.denied.length > 0 && " "} + {access.denied.length > 0 && `−${access.denied.length}`} + + )} @@ -105,11 +140,17 @@ function MemberRow({ {manage ? ( `, but it answers one question with + * a yes or a no. These are forms: they hold a draft, they are cancelled rather + * than declined, and closing one has to throw the draft away. Rather than + * bending the confirm dialog into a form host, this is the same platform + * element with the same manners — focus trapped, Escape closes, the page + * behind it inert, backdrop click dismisses — wrapped around arbitrary + * children. + * + * Mounted only while open, so each opening starts from a fresh draft instead + * of whatever the last one was left on. + */ +export function Modal({ + title, + sub, + onClose, + children, + footer, +}: { + title: string; + /** Who or what is being edited. Sits under the title, not in it. */ + sub?: string; + onClose: () => void; + children: React.ReactNode; + footer: React.ReactNode; +}) { + const dialog = useRef(null); + const titleId = useId(); + + // `showModal()` rather than the `open` attribute: only the method makes the + // page behind inert and gives us the backdrop and the focus trap. + useEffect(() => { + const element = dialog.current; + if (!element?.open) element?.showModal(); + }, []); + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: the keyboard route out is Escape, handled by onCancel; the click handler only adds pointer dismissal on the backdrop + { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (event.target === dialog.current) onClose(); + }} + > +
+
+
+

{title}

+ {sub &&

{sub}

} +
+ +
+ +
{children}
+ +
{footer}
+
+
+ ); +} diff --git a/apps/admin/src/views/members/permission-picker.tsx b/apps/admin/src/views/members/permission-picker.tsx new file mode 100644 index 00000000..4ad6d6df --- /dev/null +++ b/apps/admin/src/views/members/permission-picker.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { + ADMIN_PERMISSION_LABELS, + ADMIN_RESOURCE_LABELS, + ADMIN_RESOURCES, + type AdminResource, + resourceOf, +} from "@byteveda/db/constants"; +import { PERMISSIONS, type Permission, RESERVED_PERMISSIONS } from "@/lib/auth/roles"; + +/** + * Which permissions are ticked, grouped by the thing they are about. + * + * Grouped rather than one flat list of fifteen checkboxes, because the question + * a super admin is actually answering is "what do they do with the inbox" — + * and the answer is three ticks that belong together. The resource is the + * heading; the verbs sit under it with a sentence each saying what ticking one + * hands over. + * + * Used twice with the same markup and a different meaning for `base`: editing + * a role, where there is nothing to inherit from and a tick is simply a grant; + * and editing one person, where `base` is what their role already gives and + * every difference is labelled as an exception. Drawing both from one component + * is what keeps "inherited" meaning the same thing on both screens — and the + * same thing the server means when it computes the diff. + */ +export function PermissionPicker({ + value, + base, + onChange, + disabled = false, +}: { + value: readonly Permission[]; + /** + * What the chosen role grants, when there is one. Undefined while editing a + * role itself — a role inherits from nothing. + */ + base?: readonly Permission[]; + onChange: (next: Permission[]) => void; + disabled?: boolean; +}) { + const ticked = new Set(value); + + // Reserved grants are not offered at all. Showing a permanently disabled + // `members.manage` would invite the question of how to enable it, and the + // answer is a commit to lib/auth/roles.ts, not a checkbox. + const grantable = PERMISSIONS.filter((permission) => !RESERVED_PERMISSIONS.includes(permission)); + + function toggle(permission: Permission) { + onChange( + grantable.filter((candidate) => + candidate === permission ? !ticked.has(candidate) : ticked.has(candidate), + ), + ); + } + + /** The header control: everything in this resource, or none of it. */ + function setResource(resource: AdminResource, on: boolean) { + const inGroup = new Set(grantable.filter((permission) => resourceOf(permission) === resource)); + onChange( + grantable.filter((permission) => (inGroup.has(permission) ? on : ticked.has(permission))), + ); + } + + return ( +
+ {ADMIN_RESOURCES.map((resource) => { + const group = grantable.filter((permission) => resourceOf(permission) === resource); + if (group.length === 0) return null; + + const on = group.filter((permission) => ticked.has(permission)).length; + + return ( + // biome-ignore lint/a11y/useSemanticElements: the semantic element is
, whose is laid out by rules of its own that no browser applies consistently inside a flex or grid fieldset — and this heading has to sit in a row with the all/none button. `role="group"` plus a label carries the same semantics with none of that. +
0} + role="group" + aria-label={ADMIN_RESOURCE_LABELS[resource]} + > +

+ {ADMIN_RESOURCE_LABELS[resource]} + +

+ + {group.map((permission) => { + const label = ADMIN_PERMISSION_LABELS[permission]; + const checked = ticked.has(permission); + const inherited = base?.includes(permission); + + return ( + + ); + })} +
+ ); + })} +
+ ); +} diff --git a/apps/admin/src/views/members/role-dialog.tsx b/apps/admin/src/views/members/role-dialog.tsx new file mode 100644 index 00000000..bb6a4a89 --- /dev/null +++ b/apps/admin/src/views/members/role-dialog.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { type Permission, permissionsFor } from "@/lib/auth/roles"; +import { createRole, type RoleDraft, updateRole } from "@/lib/members/actions"; +import { Modal } from "./modal"; +import { PermissionPicker } from "./permission-picker"; +import type { RoleOption } from "./types"; + +/** + * Writing a role, or editing one. + * + * The same form either way: the difference between creating and editing is + * which action it calls and one extra sentence about who is already on it. + * Splitting them into two components would mean two permission matrices to + * keep in step, for one line of copy. + * + * Starting from a built-in role rather than from nothing is offered because + * that is what these are for — "support, plus publishing" is the shape people + * actually ask for, and typing fifteen ticks to express it invites a mistake + * in the ones they did not mean to change. + */ +export function RoleDialog({ + // Named `existing` rather than `role`, which is what it is: a JSX attribute + // called `role` is an ARIA role as far as a linter is concerned, whatever + // component it is on. + existing, + onClose, + onSaved, +}: { + /** The role being edited, or null to write a new one. */ + existing: RoleOption | null; + onClose: () => void; + onSaved: (message: string, ok: boolean) => void; +}) { + const [label, setLabel] = useState(existing?.label ?? ""); + const [description, setDescription] = useState(existing?.description ?? ""); + const [permissions, setPermissions] = useState( + existing ? [...existing.permissions] : [], + ); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function save() { + setError(null); + const draft: RoleDraft = { label, description, permissions }; + + startTransition(async () => { + const result = existing ? await updateRole(existing.id, draft) : await createRole(draft); + if (!result.ok) { + setError(result.message); + return; + } + onSaved(result.message, true); + onClose(); + }); + } + + return ( + + + {permissions.length} permission{permissions.length === 1 ? "" : "s"} + {existing && existing.members > 0 && ` · affects ${existing.members} now`} + + + + + } + > +
+
+ + setLabel(event.target.value)} + /> + + {existing + ? `Renaming is safe — ${existing.key} stays the handle this role is known by.` + : "What the job is called. It appears in every role dropdown."} + +
+ +
+ + setDescription(event.target.value)} + /> + Optional. Shown when somebody picks this role. +
+
+ + {!existing && ( +
+

Start from

+
+ {(["admin", "editor", "support", "viewer"] as const).map((builtin) => ( + + ))} + +
+
+ )} + +
+

Permissions

+

+ Everyone on this role gets exactly these, unless their own row says otherwise. Managing + members is not on the list — that stays with the super admins named in the source. +

+
+ + + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/admin/src/views/members/roles-panel.tsx b/apps/admin/src/views/members/roles-panel.tsx new file mode 100644 index 00000000..f3b5b230 --- /dev/null +++ b/apps/admin/src/views/members/roles-panel.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { + ADMIN_PERMISSION_LABELS, + ADMIN_RESOURCE_LABELS, + ADMIN_ROLE_LABELS, + ADMIN_ROLES, + type AdminResource, + resourceOf, +} from "@byteveda/db/constants"; +import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; +import { useState, useTransition } from "react"; +import { useConfirm } from "@/components"; +import { type Permission, permissionsFor } from "@/lib/auth/roles"; +import { deleteRole } from "@/lib/members/actions"; +import { RoleDialog } from "./role-dialog"; +import type { RoleOption } from "./types"; + +/** + * Every role the console has, whether the source ships it or a super admin + * wrote it. + * + * Shown together on purpose. The built-in four are the answer most of the time + * and the starting point for the rest, and a screen that listed only the + * custom ones would make them look like the whole system — a super admin would + * write "Editor" again rather than discover it already exists. + * + * The built-in rows are locked, and say why. Editing them is a commit to + * `lib/auth/roles.ts`, which is a reviewable, revertable, attributable act, + * and that is the property worth keeping for the roles most people are on. + */ +export function RolesPanel({ roles, manage }: { roles: RoleOption[]; manage: boolean }) { + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); + const [pending, startTransition] = useTransition(); + const confirm = useConfirm(); + + async function remove(role: RoleOption) { + const go = await confirm({ + title: `Delete “${role.label}”?`, + body: + role.members > 0 + ? `${role.members} ${role.members === 1 ? "person is" : "people are"} on this role. They keep their account and fall back to their built-in role, which may grant them less — or more.` + : "Nobody is on this role. Nothing else changes.", + confirmLabel: "Delete role", + destructive: true, + }); + if (!go) return; + + startTransition(async () => { + const result = await deleteRole(role.id); + setMessage({ text: result.message, ok: result.ok }); + }); + } + + return ( +
+
+

Roles

+ + {ADMIN_ROLES.length} built in + {roles.length > 0 && `, ${roles.length} custom`} + + {manage && ( + + )} +
+ +
+ {ADMIN_ROLES.map((role) => ( + + ))} + + {roles.map((role) => ( + + + + + ) + } + /> + ))} +
+ + {message && ( +
+

+ {message.text} +

+
+ )} + + {creating && ( + setCreating(false)} + onSaved={(text, ok) => setMessage({ text, ok })} + /> + )} + {editing && ( + setEditing(null)} + onSaved={(text, ok) => setMessage({ text, ok })} + /> + )} +
+ ); +} + +function RoleRow({ + label, + handle, + description, + permissions, + locked = false, + actions, +}: { + label: string; + handle: string; + description: string; + permissions: readonly Permission[]; + locked?: boolean; + actions?: React.ReactNode; +}) { + return ( +
+ + {label} + {handle} + + + + {permissions.length === 0 ? ( + nothing + ) : ( + summarise(permissions).map((line) => ( + + {line} + + )) + )} + + + {description} + + + {locked ? : actions} + +
+ ); +} + +/** + * "Posts read·write, Inbox read" — one chip per resource rather than fifteen. + * + * A row that listed every permission would be a wall of identical words, and + * the question it has to answer at a glance is which parts of the console this + * role touches at all. + */ +function summarise(permissions: readonly Permission[]): string[] { + const byResource = new Map(); + + for (const permission of permissions) { + const resource = resourceOf(permission); + const verbs = byResource.get(resource) ?? []; + verbs.push(ADMIN_PERMISSION_LABELS[permission].verb.toLowerCase()); + byResource.set(resource, verbs); + } + + return [...byResource].map( + ([resource, verbs]) => `${ADMIN_RESOURCE_LABELS[resource]} ${verbs.join("·")}`, + ); +} diff --git a/apps/admin/src/views/members/types.ts b/apps/admin/src/views/members/types.ts new file mode 100644 index 00000000..f602878f --- /dev/null +++ b/apps/admin/src/views/members/types.ts @@ -0,0 +1,19 @@ +import type { Permission } from "@/lib/auth/roles"; + +/** + * A custom role as the browser sees it. + * + * The row minus its timestamps and its author — the parts a dropdown and a + * permission matrix actually read. Declared here rather than derived from the + * table type so that a client component never has a reason to import + * `@byteveda/db`, which would drag `pg` into the bundle. + */ +export type RoleOption = { + id: string; + key: string; + label: string; + description: string | null; + permissions: Permission[]; + /** How many people are on it. What makes deleting one a decision. */ + members: number; +}; diff --git a/apps/admin/src/views/subscription/confirm-page.tsx b/apps/admin/src/views/subscription/confirm-page.tsx index e94d55cd..6e8780a2 100644 --- a/apps/admin/src/views/subscription/confirm-page.tsx +++ b/apps/admin/src/views/subscription/confirm-page.tsx @@ -30,7 +30,7 @@ export async function ConfirmPage({ searchParams }: Props) {
- + {!state.found ? ( diff --git a/apps/admin/src/views/subscription/unsubscribe-page.tsx b/apps/admin/src/views/subscription/unsubscribe-page.tsx index f0a34838..8db42fc8 100644 --- a/apps/admin/src/views/subscription/unsubscribe-page.tsx +++ b/apps/admin/src/views/subscription/unsubscribe-page.tsx @@ -29,7 +29,7 @@ export async function UnsubscribePage({ searchParams }: Props) {
- + {!state.found ? ( diff --git a/apps/admin/vitest.config.mts b/apps/admin/vitest.config.mts index 6920ed80..8d773aa7 100644 --- a/apps/admin/vitest.config.mts +++ b/apps/admin/vitest.config.mts @@ -1,7 +1,44 @@ +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin } from "vitest/config"; + +/** + * Makes a static image import look the way `next build` makes it look. + * + * Next turns `import mark from "./mark.png"` into `{ src, width, height }`, + * and components use the dimensions — the brand mark derives its width from + * the artwork's ratio. Vite hands back a URL string instead, so those reads + * are `undefined`, the ratio is `NaN`, and `next/image` throws inside any test + * that renders a page with the logo on it. + * + * The size is read from the PNG header rather than faked, so a test is looking + * at the same aspect ratio the browser will. + */ +function nextStaticImages(): Plugin { + return { + name: "next-static-images", + enforce: "pre", + load(id) { + const path = id.split("?")[0]; + if (!/\.(png|jpe?g|gif|webp|avif)$/i.test(path)) return null; + + // PNG: 8-byte signature, then the IHDR chunk with width and height as + // big-endian 32-bit integers at byte 16 and byte 20. + let width = 1; + let height = 1; + if (path.toLowerCase().endsWith(".png")) { + const header = readFileSync(path).subarray(0, 24); + width = header.readUInt32BE(16); + height = header.readUInt32BE(20); + } + + return `export default ${JSON.stringify({ src: path, width, height })};`; + }, + }; +} export default defineConfig({ + plugins: [nextStaticImages()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), diff --git a/packages/db/migrations/0009_custom_roles.sql b/packages/db/migrations/0009_custom_roles.sql new file mode 100644 index 00000000..2c46f5fe --- /dev/null +++ b/packages/db/migrations/0009_custom_roles.sql @@ -0,0 +1,17 @@ +CREATE TYPE "public"."admin_permission" AS ENUM('posts.read', 'posts.write', 'posts.publish', 'stats.read', 'stats.write', 'subscribers.read', 'subscribers.write', 'broadcasts.send', 'mail.read', 'mail.send', 'mail.manage', 'settings.read', 'settings.write', 'members.read', 'members.manage');--> statement-breakpoint +CREATE TABLE "admin_custom_roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "label" text NOT NULL, + "description" text, + "permissions" "admin_permission"[] DEFAULT '{}' NOT NULL, + "created_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "admin_custom_roles_key_unique" UNIQUE("key") +); +--> statement-breakpoint +ALTER TABLE "admin_users" ADD COLUMN "custom_role_id" uuid;--> statement-breakpoint +ALTER TABLE "admin_users" ADD COLUMN "extra_permissions" "admin_permission"[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "admin_users" ADD COLUMN "denied_permissions" "admin_permission"[] DEFAULT '{}' NOT NULL;--> statement-breakpoint +ALTER TABLE "admin_users" ADD CONSTRAINT "admin_users_custom_role_id_admin_custom_roles_id_fk" FOREIGN KEY ("custom_role_id") REFERENCES "public"."admin_custom_roles"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/meta/0009_snapshot.json b/packages/db/migrations/meta/0009_snapshot.json new file mode 100644 index 00000000..cba6c577 --- /dev/null +++ b/packages/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,1941 @@ +{ + "id": "cfc26b2d-2116-451c-bf1e-a65e4ff9b790", + "prevId": "0e4ec473-5918-4703-817b-d2e17c499485", + "version": "7", + "dialect": "postgresql", + "tables": { + "academy.sample_requests": { + "name": "sample_requests", + "schema": "academy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "sample_kind", + "typeSchema": "academy", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "list_value": { + "name": "list_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sample_requests_created_idx": { + "name": "sample_requests_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sample_requests_email_key": { + "name": "sample_requests_email_key", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_custom_roles": { + "name": "admin_custom_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "admin_permission[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_custom_roles_key_unique": { + "name": "admin_custom_roles_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_users": { + "name": "admin_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "admin_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "custom_role_id": { + "name": "custom_role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "extra_permissions": { + "name": "extra_permissions", + "type": "admin_permission[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "denied_permissions": { + "name": "denied_permissions", + "type": "admin_permission[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "admin_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "mail_workspaces": { + "name": "mail_workspaces", + "type": "mail_workspace[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "admin_users_custom_role_id_admin_custom_roles_id_fk": { + "name": "admin_users_custom_role_id_admin_custom_roles_id_fk", + "tableFrom": "admin_users", + "tableTo": "admin_custom_roles", + "columnsFrom": ["custom_role_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_users_github_id_unique": { + "name": "admin_users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_admin_users_id_fk": { + "name": "sessions_user_id_admin_users_id_fk", + "tableFrom": "sessions", + "tableTo": "admin_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcasts": { + "name": "broadcasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "broadcasts_status_idx": { + "name": "broadcasts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "broadcasts_post_idx": { + "name": "broadcasts_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "broadcasts_post_id_posts_id_fk": { + "name": "broadcasts_post_id_posts_id_fk", + "tableFrom": "broadcasts", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "broadcasts_status_check": { + "name": "broadcasts_status_check", + "value": "\"status\" in ('draft', 'sending', 'sent', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'application/octet-stream'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_attachments_scope_idx": { + "name": "email_attachments_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_attachments_message_idx": { + "name": "email_attachments_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_attachments_message_id_outbound_messages_id_fk": { + "name": "email_attachments_message_id_outbound_messages_id_fk", + "tableFrom": "email_attachments", + "tableTo": "outbound_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_uploaded_by_admin_users_id_fk": { + "name": "email_attachments_uploaded_by_admin_users_id_fk", + "tableFrom": "email_attachments", + "tableTo": "admin_users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_threads": { + "name": "email_threads", + "schema": "", + "columns": { + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "correspondent_email": { + "name": "correspondent_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correspondent_name": { + "name": "correspondent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mailbox": { + "name": "mailbox", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "workspace": { + "name": "workspace", + "type": "mail_workspace", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'byteveda'" + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_inbound_at": { + "name": "last_inbound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_outbound_at": { + "name": "last_outbound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_threads_active_idx": { + "name": "email_threads_active_idx", + "columns": [ + { + "expression": "workspace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"archived_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_threads_archived_idx": { + "name": "email_threads_archived_idx", + "columns": [ + { + "expression": "workspace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"archived_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inbound_messages": { + "name": "inbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbound_messages_resend_idx": { + "name": "inbound_messages_resend_idx", + "columns": [ + { + "expression": "resend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbound_messages_thread_idx": { + "name": "inbound_messages_thread_idx", + "columns": [ + { + "expression": "thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbound_messages_received_idx": { + "name": "inbound_messages_received_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbound_messages_thread_key_email_threads_thread_key_fk": { + "name": "inbound_messages_thread_key_email_threads_thread_key_fk", + "tableFrom": "inbound_messages", + "tableTo": "email_threads", + "columnsFrom": ["thread_key"], + "columnsTo": ["thread_key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_messages": { + "name": "outbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "broadcast_id": { + "name": "broadcast_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbound_messages_sent_idx": { + "name": "outbound_messages_sent_idx", + "columns": [ + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_broadcast_idx": { + "name": "outbound_messages_broadcast_idx", + "columns": [ + { + "expression": "broadcast_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_thread_idx": { + "name": "outbound_messages_thread_idx", + "columns": [ + { + "expression": "thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"thread_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_messages_thread_key_email_threads_thread_key_fk": { + "name": "outbound_messages_thread_key_email_threads_thread_key_fk", + "tableFrom": "outbound_messages", + "tableTo": "email_threads", + "columnsFrom": ["thread_key"], + "columnsTo": ["thread_key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_broadcast_id_broadcasts_id_fk": { + "name": "outbound_messages_broadcast_id_broadcasts_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "broadcasts", + "columnsFrom": ["broadcast_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "outbound_messages_kind_check": { + "name": "outbound_messages_kind_check", + "value": "\"kind\" in ('transactional', 'confirmation', 'broadcast', 'announcement', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.subscribers": { + "name": "subscribers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscribers_email_idx": { + "name": "subscribers_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscribers_token_idx": { + "name": "subscribers_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscribers_status_idx": { + "name": "subscribers_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "subscribers_status_check": { + "name": "subscribers_status_check", + "value": "\"status\" in ('pending', 'active', 'unsubscribed', 'bounced')" + }, + "subscribers_email_lowercase_check": { + "name": "subscribers_email_lowercase_check", + "value": "\"email\" = lower(\"email\")" + }, + "subscribers_email_shape_check": { + "name": "subscribers_email_shape_check", + "value": "\"email\" ~ '^[^@[:space:]]+@[^@[:space:]]+$'" + } + }, + "isRLSEnabled": false + }, + "public.post_revisions": { + "name": "post_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body_mdx": { + "name": "body_mdx", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'richtext'" + }, + "editor_json": { + "name": "editor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_revisions_post_id_created_idx": { + "name": "post_revisions_post_id_created_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_revisions_post_id_posts_id_fk": { + "name": "post_revisions_post_id_posts_id_fk", + "tableFrom": "post_revisions", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_revisions_author_id_admin_users_id_fk": { + "name": "post_revisions_author_id_admin_users_id_fk", + "tableFrom": "post_revisions", + "tableTo": "admin_users", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site": { + "name": "site", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flexiq'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body_mdx": { + "name": "body_mdx", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'richtext'" + }, + "editor_json": { + "name": "editor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "seo": { + "name": "seo", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"keywords\":[]}'::jsonb" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ByteVeda'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_site_slug_idx": { + "name": "posts_site_slug_idx", + "columns": [ + { + "expression": "site", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_site_status_published_idx": { + "name": "posts_site_status_published_idx", + "columns": [ + { + "expression": "site", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "posts_site_check": { + "name": "posts_site_check", + "value": "\"site\" in ('flexiq')" + }, + "posts_status_check": { + "name": "posts_status_check", + "value": "\"status\" in ('draft', 'published', 'archived')" + }, + "posts_body_format_check": { + "name": "posts_body_format_check", + "value": "\"body_format\" in ('richtext', 'mdx')" + }, + "posts_slug_check": { + "name": "posts_slug_check", + "value": "\"slug\" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'" + }, + "posts_published_at_check": { + "name": "posts_published_at_check", + "value": "(\"status\" = 'published') = (\"published_at\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_runs": { + "name": "collection_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ok": { + "name": "ok", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "days_written": { + "name": "days_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "collection_runs_started_idx": { + "name": "collection_runs_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_runs_package_id_project_packages_id_fk": { + "name": "collection_runs_package_id_project_packages_id_fk", + "tableFrom": "collection_runs", + "tableTo": "project_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.download_snapshots": { + "name": "download_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "downloads": { + "name": "downloads", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "download_snapshots_package_day_idx": { + "name": "download_snapshots_package_day_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "download_snapshots_day_idx": { + "name": "download_snapshots_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "download_snapshots_package_id_project_packages_id_fk": { + "name": "download_snapshots_package_id_project_packages_id_fk", + "tableFrom": "download_snapshots", + "tableTo": "project_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "download_snapshots_downloads_check": { + "name": "download_snapshots_downloads_check", + "value": "\"downloads\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.package_totals": { + "name": "package_totals", + "schema": "", + "columns": { + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "total": { + "name": "total", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "package_totals_package_id_project_packages_id_fk": { + "name": "package_totals_package_id_project_packages_id_fk", + "tableFrom": "package_totals", + "tableTo": "project_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_packages": { + "name": "project_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_slug": { + "name": "project_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ecosystem": { + "name": "ecosystem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_packages_ecosystem_name_idx": { + "name": "project_packages_ecosystem_name_idx", + "columns": [ + { + "expression": "ecosystem", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_packages_project_idx": { + "name": "project_packages_project_idx", + "columns": [ + { + "expression": "project_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_packages_ecosystem_check": { + "name": "project_packages_ecosystem_check", + "value": "\"ecosystem\" in ('pypi', 'npm', 'crates', 'maven')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "academy.sample_kind": { + "name": "sample_kind", + "schema": "academy", + "values": ["chapter", "custom"] + }, + "public.admin_permission": { + "name": "admin_permission", + "schema": "public", + "values": [ + "posts.read", + "posts.write", + "posts.publish", + "stats.read", + "stats.write", + "subscribers.read", + "subscribers.write", + "broadcasts.send", + "mail.read", + "mail.send", + "mail.manage", + "settings.read", + "settings.write", + "members.read", + "members.manage" + ] + }, + "public.admin_role": { + "name": "admin_role", + "schema": "public", + "values": ["admin", "editor", "support", "viewer"] + }, + "public.admin_status": { + "name": "admin_status", + "schema": "public", + "values": ["active", "suspended"] + }, + "public.mail_workspace": { + "name": "mail_workspace", + "schema": "public", + "values": ["byteveda", "academy"] + } + }, + "schemas": { + "academy": "academy" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 8766533d..bbdf3f44 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790050390750, "tag": "0008_rbac_and_attachments", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1790053866766, + "tag": "0009_custom_roles", + "breakpoints": true } ] } diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts index bbeeb888..1d3d32fe 100644 --- a/packages/db/src/constants.ts +++ b/packages/db/src/constants.ts @@ -56,13 +56,121 @@ export type SampleKind = (typeof SAMPLE_KINDS)[number]; * `apps/admin/src/lib/auth/roles.ts` — because a permission is a statement * about the code, and the code is where it can be kept true. * - * "Super admin" is deliberately absent. It is not a row anyone can be given; - * it is a hardcoded list of GitHub IDs, and no write to this database can - * widen it. + * A super admin may also define roles of their own, which are rows in + * `admin_custom_roles` carrying an explicit permission list. Those cannot be + * in this enum — it is a column's type, and a new value would mean a migration + * per role — so a member is either on one of these four or on a custom one, + * and `custom_role_id` is what says which. + * + * "Super admin" is deliberately absent from both. It is not a row anyone can be + * given; it is a hardcoded list of GitHub IDs, and no write to this database + * can widen it. */ export const ADMIN_ROLES = ["admin", "editor", "support", "viewer"] as const; export type AdminRole = (typeof ADMIN_ROLES)[number]; +/** + * Everything the console can be asked to allow, named `resource.verb`. + * + * Here rather than in the admin app because the set is now a Postgres enum: + * a custom role stores its permissions as a column, and the column's type + * should be the closed set rather than `text[]` that any typo fits into. The + * admin app re-exports this as `PERMISSIONS` and remains the only place that + * decides which of them each built-in role carries. + * + * Groups of two or three — read, write, and the irreversible one — because + * "may edit a post" and "may put it in front of the public" are genuinely + * different grants, and so are "may read the mail" and "may answer it as us". + */ +export const ADMIN_PERMISSIONS = [ + "posts.read", + "posts.write", + "posts.publish", + "stats.read", + "stats.write", + "subscribers.read", + "subscribers.write", + "broadcasts.send", + "mail.read", + "mail.send", + "mail.manage", + "settings.read", + "settings.write", + "members.read", + "members.manage", +] as const; + +export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number]; + +/** + * The thing a permission is about, derived from the name rather than declared. + * + * Deriving it means a new permission cannot be added to a resource that does + * not exist, and cannot be forgotten by the screen that groups them. + */ +export type AdminResource = AdminPermission extends `${infer Resource}.${string}` + ? Resource + : never; + +/** The order resources are shown in, which is roughly the order of the rail. */ +export const ADMIN_RESOURCES = [ + "posts", + "stats", + "subscribers", + "broadcasts", + "mail", + "settings", + "members", +] as const satisfies readonly AdminResource[]; + +export function resourceOf(permission: AdminPermission): AdminResource { + return permission.slice(0, permission.indexOf(".")) as AdminResource; +} + +export const ADMIN_RESOURCE_LABELS: Record = { + posts: "Posts", + stats: "Downloads", + subscribers: "Subscribers", + broadcasts: "Broadcasts", + mail: "Inbox", + settings: "Settings", + members: "Members", +}; + +/** + * What ticking one actually hands over, in the second person. + * + * Written out rather than generated from the verb: "manage" means archiving a + * conversation in one place and editing every grant in the console in another, + * and a screen that says "Manage" twice has told the person nothing. + */ +export const ADMIN_PERMISSION_LABELS: Record = { + "posts.read": { verb: "Read", hint: "See every post, draft ones included." }, + "posts.write": { verb: "Write", hint: "Create and edit posts, and upload their images." }, + "posts.publish": { verb: "Publish", hint: "Put a post in front of the public, or take it down." }, + "stats.read": { verb: "Read", hint: "See download figures and the registries behind them." }, + "stats.write": { verb: "Write", hint: "Add or remove a tracked package, and force a refresh." }, + "subscribers.read": { verb: "Read", hint: "See the list and each address's state." }, + "subscribers.write": { verb: "Write", hint: "Add, edit and unsubscribe addresses." }, + "broadcasts.send": { + verb: "Send", + hint: "Mail the whole list. This one cannot be taken back.", + }, + "mail.read": { verb: "Read", hint: "Open conversations in the granted inboxes." }, + "mail.send": { verb: "Send", hint: "Reply as ByteVeda, with attachments." }, + "mail.manage": { verb: "Manage", hint: "Archive, reopen and assign conversations." }, + "settings.read": { verb: "Read", hint: "See the console's configuration." }, + "settings.write": { + verb: "Write", + hint: "Change it, including anything integrations depend on.", + }, + "members.read": { verb: "Read", hint: "See who has access and what it amounts to." }, + "members.manage": { + verb: "Manage", + hint: "Grant and withdraw access. Reserved for super admins.", + }, +}; + /** Suspended keeps the record and the history; only signing in stops. */ export const ADMIN_STATUSES = ["active", "suspended"] as const; export type AdminStatus = (typeof ADMIN_STATUSES)[number]; diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 09fe5e76..f7a2123a 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -1,22 +1,64 @@ import { relations } from "drizzle-orm"; import { bigint, index, inet, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { + ADMIN_PERMISSIONS, ADMIN_ROLES, ADMIN_STATUSES, + type AdminPermission, type AdminRole, type AdminStatus, MAIL_WORKSPACES, type MailWorkspace, } from "../constants"; -export type { AdminRole, AdminStatus }; -export { ADMIN_ROLES, ADMIN_STATUSES }; +export type { AdminPermission, AdminRole, AdminStatus }; +export { ADMIN_PERMISSIONS, ADMIN_ROLES, ADMIN_STATUSES }; /** Real Postgres enums, so the closed set is the column's type. */ export const adminRole = pgEnum("admin_role", ADMIN_ROLES); export const adminStatus = pgEnum("admin_status", ADMIN_STATUSES); +export const adminPermission = pgEnum("admin_permission", ADMIN_PERMISSIONS); export const mailWorkspace = pgEnum("mail_workspace", MAIL_WORKSPACES); +/** + * A role a super admin wrote, rather than one the source ships. + * + * The four built-in roles are jobs — editor, support — and they are in the + * code because they are claims about what the code allows. This table is for + * the shape that does not have a name yet: "everything support can do, plus + * publishing", asked for once, on a Tuesday. Inventing a role in the source + * for it would mean a deploy; inventing one here means a row. + * + * What it can hold is still bounded. `permissions` is an array of the + * `admin_permission` enum, so nothing outside the catalogue in `constants.ts` + * can be stored at all — a custom role can only ever recombine grants the + * application already knows how to enforce. It cannot mint a new power, and it + * cannot carry `members.manage` either: that check is a super admin's, and + * `sanitisePermissions` in the admin app strips it on the way in. + * + * Scope is deliberately not here. Which inboxes somebody may read stays on + * their own row — a role says *what*, a scope says *where*, and folding them + * together would need a role per combination, which is the thing this exists + * to avoid. + */ +export const adminCustomRoles = pgTable("admin_custom_roles", { + id: uuid("id").primaryKey().defaultRandom(), + /** Slug derived from the label. Stable across renames, and what a log reads. */ + key: text("key").notNull().unique(), + label: text("label").notNull(), + /** What this role is for, in the words of whoever made it. */ + description: text("description"), + permissions: adminPermission("permissions") + .array() + .notNull() + .$type() + .default([]), + /** Who made it. A plain id, like `invited_by` — deleting them keeps the role. */ + createdBy: uuid("created_by"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + /** * Everyone who may use the admin app, and what they may do in it. * @@ -46,6 +88,40 @@ export const adminUsers = pgTable("admin_users", { * about what the code allows, and only the code can keep it honest. */ role: adminRole("role").notNull().default("viewer"), + /** + * A role a super admin wrote, which replaces `role` when it is set. + * + * `role` is kept rather than cleared, and that is what makes deleting a + * custom role safe: the foreign key nulls this column, and the member lands + * back on the built-in role they were on before. A grant that evaporated + * with the role would be a way to lock the console's last editor out by + * tidying up. + */ + customRoleId: uuid("custom_role_id").references(() => adminCustomRoles.id, { + onDelete: "set null", + }), + /** + * One person's exceptions to whichever role they are on. + * + * The reason a console this size does not need twelve roles. "Support, but + * they also publish the release notes" is one permission in `extra`, not a + * role nobody else will ever hold; "editor, but not this quarter's launch + * posts" is one in `denied`. Both are computed against the role rather than + * replacing it, so changing the role still changes what they can do. + * + * Denied wins over extra and over the role — the only ordering that makes + * "take this away" mean it, whatever else is ticked. + */ + extraPermissions: adminPermission("extra_permissions") + .array() + .notNull() + .$type() + .default([]), + deniedPermissions: adminPermission("denied_permissions") + .array() + .notNull() + .$type() + .default([]), status: adminStatus("status").notNull().default("active"), /** * Which mail they may read, independent of the role. @@ -92,8 +168,16 @@ export const sessions = pgTable( ], ); -export const adminUsersRelations = relations(adminUsers, ({ many }) => ({ +export const adminUsersRelations = relations(adminUsers, ({ many, one }) => ({ sessions: many(sessions), + customRole: one(adminCustomRoles, { + fields: [adminUsers.customRoleId], + references: [adminCustomRoles.id], + }), +})); + +export const adminCustomRolesRelations = relations(adminCustomRoles, ({ many }) => ({ + members: many(adminUsers), })); export const sessionsRelations = relations(sessions, ({ one }) => ({ @@ -102,4 +186,6 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({ export type AdminUser = typeof adminUsers.$inferSelect; export type NewAdminUser = typeof adminUsers.$inferInsert; +export type AdminCustomRole = typeof adminCustomRoles.$inferSelect; +export type NewAdminCustomRole = typeof adminCustomRoles.$inferInsert; export type Session = typeof sessions.$inferSelect;