Sudo: Aliases, to add and remove workspace aliases with a reason
Lists each alias with its workspace, note, and who added it when; adds one (alias, workspace, why) and removes one (why), in plain forms. Both are in the audit log as alias_added and alias_removed.
10 files+282−20/10 viewed
| 31 | 31 | Server, | |
| 32 | 32 | ShieldAlert, | |
| 33 | 33 | ShieldCheck, | |
| 34 | + | Signpost, | |
| 34 | 35 | Siren, | |
| 35 | 36 | Tags, | |
| 36 | 37 | Ticket, | |
| ⋯ | |||
| 72 | 73 | overages: TrendingUp, | |
| 73 | 74 | velocity: Activity, | |
| 74 | 75 | invites: Ticket, | |
| 76 | + | aliases: Signpost, | |
| 75 | 77 | customers: UsersRound, | |
| 76 | 78 | spend: Gauge, | |
| 77 | 79 | revenue: CircleDollarSign, | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { MAX_ALIAS_NOTE, parseNewAlias, parseRemovalReason } from "./aliases.ts"; | |
| 5 | + | ||
| 6 | + | test("an alias, its workspace and why are read as identity takes them", () => { | |
| 7 | + | assert.deepEqual(parseNewAlias(" G1T ", " Flagon-IO ", " The product's name, for Flagon, Inc. "), { | |
| 8 | + | ok: true, | |
| 9 | + | value: { alias: "g1t", workspace: "flagon-io", note: "The product's name, for Flagon, Inc." }, | |
| 10 | + | }); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("a malformed alias or workspace, or no reason, is refused before identity is asked", () => { | |
| 14 | + | for (const [alias, workspace, note] of [ | |
| 15 | + | ["g--1t", "flagon-io", "x"], | |
| 16 | + | ["-g1t", "flagon-io", "x"], | |
| 17 | + | ["g1t_inc", "flagon-io", "x"], | |
| 18 | + | ["g1t", "", "x"], | |
| 19 | + | ["g1t", "flagon io", "x"], | |
| 20 | + | ["g1t", "flagon-io", " "], | |
| 21 | + | ["flagon-io", "flagon-io", "x"], | |
| 22 | + | ["g1t", "flagon-io", "x".repeat(MAX_ALIAS_NOTE + 1)], | |
| 23 | + | ]) { | |
| 24 | + | assert.equal(parseNewAlias(alias!, workspace!, note!).ok, false, `${alias} ${workspace}`); | |
| 25 | + | } | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("removing an alias needs a reason", () => { | |
| 29 | + | assert.deepEqual(parseRemovalReason(" Flagon renamed the product "), { ok: true, value: "Flagon renamed the product" }); | |
| 30 | + | assert.equal(parseRemovalReason("").ok, false); | |
| 31 | + | assert.equal(parseRemovalReason("x".repeat(MAX_ALIAS_NOTE + 1)).ok, false); | |
| 32 | + | }); |
| 1 | + | /** | |
| 2 | + | * Workspace aliases, as the Aliases page reads its forms: a name staff | |
| 3 | + | * point at a workspace (`g1t` leads to `flagon-io`). Identity checks each | |
| 4 | + | * again, and knows what this cannot: whether a person or workspace has the | |
| 5 | + | * name. No Workers imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import { isSlug, type Parsed } from "./forms.ts"; | |
| 8 | + | ||
| 9 | + | /** The longest note or reason, as identity allows. */ | |
| 10 | + | export const MAX_ALIAS_NOTE = 500; | |
| 11 | + | ||
| 12 | + | export type NewAlias = { alias: string; workspace: string; note: string }; | |
| 13 | + | ||
| 14 | + | function note(raw: string, missing: string): Parsed<string> { | |
| 15 | + | const value = raw.trim(); | |
| 16 | + | if (!value) return { ok: false, error: missing }; | |
| 17 | + | if ([...value].length > MAX_ALIAS_NOTE) return { ok: false, error: `Keep it to ${MAX_ALIAS_NOTE} characters.` }; | |
| 18 | + | return { ok: true, value }; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The add form: an alias, the workspace it leads to, and why. */ | |
| 22 | + | export function parseNewAlias(alias: string, workspace: string, why: string): Parsed<NewAlias> { | |
| 23 | + | const name = alias.trim().toLowerCase(); | |
| 24 | + | if (!isSlug(name)) return { ok: false, error: "An alias uses lowercase letters, digits and single hyphens, up to 39 characters." }; | |
| 25 | + | const slug = workspace.trim().toLowerCase(); | |
| 26 | + | if (!isSlug(slug)) return { ok: false, error: "Give the workspace's slug, such as flagon-io." }; | |
| 27 | + | if (name === slug) return { ok: false, error: "An alias cannot be the workspace's own slug." }; | |
| 28 | + | const reason = note(why, "Say why the alias exists."); | |
| 29 | + | if (!reason.ok) return reason; | |
| 30 | + | return { ok: true, value: { alias: name, workspace: slug, note: reason.value } }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** The remove form's reason, for sudo's audit log. */ | |
| 34 | + | export function parseRemovalReason(why: string): Parsed<string> { | |
| 35 | + | return note(why, "Say why the alias is being removed."); | |
| 36 | + | } |
| 110 | 110 | // From identity: deleted workspaces staff restored or purged. | |
| 111 | 111 | workspace_restored: "Workspace restored", | |
| 112 | 112 | workspace_purged: "Workspace purged", | |
| 113 | + | // From identity: workspace aliases staff set or removed. | |
| 114 | + | alias_added: "Alias added", | |
| 115 | + | alias_removed: "Alias removed", | |
| 113 | 116 | // From the status page (apps/status), merged in by the Audit log page. | |
| 114 | 117 | incident_declared: "Incident declared", | |
| 115 | 118 | incident_detected: "Incident detected", |
| 40 | 40 | ||
| 41 | 41 | test("the built pages are not marked soon", () => { | |
| 42 | 42 | const built = navItems().filter((item) => !item.soon).map((item) => item.to); | |
| 43 | − | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]); | |
| 43 | + | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]); | |
| 44 | 44 | }); | |
| 45 | 45 | ||
| 46 | 46 | test("every soon page says what it will do, why, and what it will have", () => { |
| 32 | 32 | | "overages" | |
| 33 | 33 | | "velocity" | |
| 34 | 34 | | "invites" | |
| 35 | + | | "aliases" | |
| 35 | 36 | // Sections | |
| 36 | 37 | | "customers" | |
| 37 | 38 | | "spend" | |
| ⋯ | |||
| 103 | 104 | count: "waitlist", | |
| 104 | 105 | }, | |
| 105 | 106 | { | |
| 107 | + | label: "Aliases", | |
| 108 | + | to: "/aliases", | |
| 109 | + | icon: "aliases", | |
| 110 | + | about: "Names that lead to a workspace, such as g1t to flagon-io. Staff set them; customers cannot.", | |
| 111 | + | }, | |
| 112 | + | { | |
| 106 | 113 | label: "People", | |
| 107 | 114 | to: "/people", | |
| 108 | 115 | icon: "people", | |
| 10 | 10 | route("users/:username", "routes/user.tsx"), | |
| 11 | 11 | route("enterprises", "routes/enterprises.tsx"), | |
| 12 | 12 | route("invites", "routes/invites.tsx"), | |
| 13 | + | route("aliases", "routes/aliases.tsx"), | |
| 13 | 14 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 14 | 15 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 15 | 16 | route("reach-out", "routes/reach-out.tsx"), |
| 1 | + | import { ArrowRight, Signpost } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { WorkspaceAlias } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/aliases"; | |
| 7 | + | import { Button, EmptyState, Field, Input, Notice, PageHeader, Section, Textarea, When } from "~/components/ui"; | |
| 8 | + | import { MAX_ALIAS_NOTE, parseNewAlias, parseRemovalReason } from "~/lib/aliases"; | |
| 9 | + | import { text } from "~/lib/forms"; | |
| 10 | + | import { identity } from "~/lib/services.server"; | |
| 11 | + | import { settle } from "~/lib/settle"; | |
| 12 | + | import { requireStaff } from "~/lib/staff"; | |
| 13 | + | ||
| 14 | + | export const meta: Route.MetaFunction = () => [{ title: "Aliases · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | + | ||
| 16 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 17 | + | requireStaff(context); | |
| 18 | + | const url = new URL(request.url); | |
| 19 | + | const aliases = await settle(identity.aliases()); | |
| 20 | + | const done = url.searchParams.get("done"); | |
| 21 | + | const name = url.searchParams.get("alias") ?? ""; | |
| 22 | + | return { | |
| 23 | + | aliases: aliases.ok ? aliases.value : [], | |
| 24 | + | error: aliases.ok ? null : aliases.error, | |
| 25 | + | done: done === "added" ? `${name} is an alias now.` : done === "removed" ? `Removed ${name}.` : null, | |
| 26 | + | }; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type ActionData = { error: string; alias?: string; values?: { alias: string; workspace: string; note: string } }; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Adding and removing. Identity checks each again (a person's or a | |
| 33 | + | * workspace's name is never an alias) and records it in sudo's audit log, | |
| 34 | + | * naming the staff member. | |
| 35 | + | */ | |
| 36 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 37 | + | const staff = requireStaff(context); | |
| 38 | + | const form = await request.formData(); | |
| 39 | + | const back = (done: string, alias: string) => redirect(`/aliases?done=${done}&alias=${encodeURIComponent(alias)}`); | |
| 40 | + | switch (text(form, "intent")) { | |
| 41 | + | case "add": { | |
| 42 | + | const values = { alias: text(form, "alias"), workspace: text(form, "workspace"), note: text(form, "note") }; | |
| 43 | + | const parsed = parseNewAlias(values.alias, values.workspace, values.note); | |
| 44 | + | if (!parsed.ok) return data<ActionData>({ error: parsed.error, values }, { status: 422 }); | |
| 45 | + | const { alias, workspace, note } = parsed.value; | |
| 46 | + | const result = await identity.setAlias(alias, workspace, note, staff.email); | |
| 47 | + | if (!result.ok) return data<ActionData>({ error: result.error.message, values }, { status: 422 }); | |
| 48 | + | throw back("added", alias); | |
| 49 | + | } | |
| 50 | + | case "remove": { | |
| 51 | + | const alias = text(form, "alias"); | |
| 52 | + | const reason = parseRemovalReason(text(form, "reason")); | |
| 53 | + | if (!reason.ok) return data<ActionData>({ error: reason.error, alias }, { status: 422 }); | |
| 54 | + | const result = await identity.removeAlias(alias, reason.value, staff.email); | |
| 55 | + | if (!result.ok) return data<ActionData>({ error: result.error.message, alias }, { status: 422 }); | |
| 56 | + | throw back("removed", alias); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return data<ActionData>({ error: "Unknown action." }, { status: 400 }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export default function Aliases({ loaderData, actionData }: Route.ComponentProps) { | |
| 63 | + | const { aliases, error, done } = loaderData; | |
| 64 | + | const adding = actionData && !actionData.alias ? actionData : null; | |
| 65 | + | return ( | |
| 66 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 67 | + | <PageHeader | |
| 68 | + | title="Aliases" | |
| 69 | + | description="Names that lead to a workspace. Every address under an alias (pages, git, the API, packages) leads to the workspace under its own name, and the alias follows it through renames. Only staff set them, for a company's trading name such as g1t for Flagon, Inc.; customers cannot make one." | |
| 70 | + | /> | |
| 71 | + | <div className="mt-6 space-y-3"> | |
| 72 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 73 | + | {error && <Notice tone="error">Identity did not answer: {error}</Notice>} | |
| 74 | + | </div> | |
| 75 | + | <div className="mt-6"> | |
| 76 | + | {aliases.length === 0 ? ( | |
| 77 | + | <EmptyState title="No aliases">An alias appears here once staff add one below.</EmptyState> | |
| 78 | + | ) : ( | |
| 79 | + | <ul className="space-y-3"> | |
| 80 | + | {aliases.map((alias) => ( | |
| 81 | + | <AliasRow key={alias.alias} alias={alias} error={actionData && actionData.alias === alias.alias ? actionData.error : null} /> | |
| 82 | + | ))} | |
| 83 | + | </ul> | |
| 84 | + | )} | |
| 85 | + | </div> | |
| 86 | + | <Section | |
| 87 | + | className="mt-6" | |
| 88 | + | title="Add an alias" | |
| 89 | + | description="A name nobody has: never a person's username or a workspace's slug, and not one of the site's own routes. Reserved names such as g1t can be. It is nobody's to register while it is an alias." | |
| 90 | + | > | |
| 91 | + | <form method="post" className="space-y-3"> | |
| 92 | + | <input type="hidden" name="intent" value="add" /> | |
| 93 | + | <div className="grid gap-3 sm:grid-cols-2"> | |
| 94 | + | <Field label="Alias"> | |
| 95 | + | <Input name="alias" required maxLength={39} spellCheck={false} placeholder="acme-corp" defaultValue={adding?.values?.alias} className="font-mono" /> | |
| 96 | + | </Field> | |
| 97 | + | <Field label="Leads to workspace"> | |
| 98 | + | <Input | |
| 99 | + | name="workspace" | |
| 100 | + | required | |
| 101 | + | maxLength={39} | |
| 102 | + | spellCheck={false} | |
| 103 | + | placeholder="acme" | |
| 104 | + | defaultValue={adding?.values?.workspace} | |
| 105 | + | className="font-mono" | |
| 106 | + | /> | |
| 107 | + | </Field> | |
| 108 | + | </div> | |
| 109 | + | <Field label="Why" hint="Kept with the alias, and in the audit log."> | |
| 110 | + | <Textarea | |
| 111 | + | name="note" | |
| 112 | + | rows={2} | |
| 113 | + | maxLength={MAX_ALIAS_NOTE} | |
| 114 | + | required | |
| 115 | + | placeholder="e.g. Acme's trading name, asked for by their CTO." | |
| 116 | + | defaultValue={adding?.values?.note} | |
| 117 | + | /> | |
| 118 | + | </Field> | |
| 119 | + | {adding && <Notice tone="error">{adding.error}</Notice>} | |
| 120 | + | <div className="flex justify-end"> | |
| 121 | + | <Button type="submit" variant="lavender"> | |
| 122 | + | <Signpost size={14} /> | |
| 123 | + | Add alias | |
| 124 | + | </Button> | |
| 125 | + | </div> | |
| 126 | + | </form> | |
| 127 | + | </Section> | |
| 128 | + | </main> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | function AliasRow({ alias, error }: { alias: WorkspaceAlias; error: string | null }) { | |
| 133 | + | return ( | |
| 134 | + | <li id={`alias-${alias.alias}`} className="scroll-mt-20 rounded-lg border border-line bg-surface p-4 sm:p-5"> | |
| 135 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 136 | + | <span className="font-mono font-medium">{alias.alias}</span> | |
| 137 | + | <ArrowRight size={14} className="text-faint" aria-label="leads to" /> | |
| 138 | + | <Link to={`/workspaces/${alias.workspace}`} className="font-mono hover:underline"> | |
| 139 | + | {alias.workspace} | |
| 140 | + | </Link> | |
| 141 | + | <span className="text-sm text-muted">{alias.workspaceName}</span> | |
| 142 | + | </div> | |
| 143 | + | <p className="mt-2 text-sm text-fg-soft">{alias.note || <span className="text-faint">No note.</span>}</p> | |
| 144 | + | <p className="mt-1 text-xs text-muted"> | |
| 145 | + | Added by <span className="text-fg-soft">{alias.createdBy}</span> <When at={alias.createdAt} time /> | |
| 146 | + | </p> | |
| 147 | + | {error && ( | |
| 148 | + | <div className="mt-3"> | |
| 149 | + | <Notice tone="error">{error}</Notice> | |
| 150 | + | </div> | |
| 151 | + | )} | |
| 152 | + | <form method="post" action={`/aliases#alias-${alias.alias}`} className="mt-4 flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-end"> | |
| 153 | + | <input type="hidden" name="intent" value="remove" /> | |
| 154 | + | <input type="hidden" name="alias" value={alias.alias} /> | |
| 155 | + | <label className="grid flex-1 gap-1 text-xs text-muted"> | |
| 156 | + | <span>Why remove it</span> | |
| 157 | + | <Input name="reason" required maxLength={MAX_ALIAS_NOTE} aria-label={`Why remove ${alias.alias}`} /> | |
| 158 | + | </label> | |
| 159 | + | <Button type="submit" variant="danger"> | |
| 160 | + | Remove | |
| 161 | + | </Button> | |
| 162 | + | </form> | |
| 163 | + | </li> | |
| 164 | + | ); | |
| 165 | + | } |
| 272 | 272 | deletedWorkspaces: () => call("admin_deleted_workspaces", {}), | |
| 273 | 273 | restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }), | |
| 274 | 274 | purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }), | |
| 275 | + | aliases: () => call("admin_aliases", {}), | |
| 276 | + | setAlias: (alias, workspace, note, staff) => call("admin_set_alias", { alias, workspace, note, staff }), | |
| 277 | + | removeAlias: (alias, reason, staff) => call("admin_remove_alias", { alias, reason, staff }), | |
| 275 | 278 | }; | |
| 276 | 279 | } | |
| 277 | 280 |
| 316 | 316 | * `workspace.deleted`. | |
| 317 | 317 | */ | |
| 318 | 318 | purgeWorkspace(workspaceId: string, staff: string, confirm: string): Promise<Result<boolean>>; | |
| 319 | + | ||
| 320 | + | /** Every workspace alias, by name. */ | |
| 321 | + | aliases(): Promise<WorkspaceAlias[]>; | |
| 322 | + | /** | |
| 323 | + | * Points `alias` at the workspace whose slug is `workspace`. Refused for | |
| 324 | + | * one of the site's routes, anyone's username, a workspace's slug (deleted | |
| 325 | + | * or held after a rename) and an existing alias. `note` says why. | |
| 326 | + | */ | |
| 327 | + | setAlias(alias: string, workspace: string, note: string, staff: string): Promise<Result<WorkspaceAlias>>; | |
| 328 | + | /** Removes an alias; `reason` goes in sudo's audit log. */ | |
| 329 | + | removeAlias(alias: string, reason: string, staff: string): Promise<Result<boolean>>; | |
| 319 | 330 | } | |
| 320 | 331 | ||
| 332 | + | /** | |
| 333 | + | * A name g1t's staff point at a workspace, so its addresses lead there under | |
| 334 | + | * the workspace's own name: `g1t`, the product, leads to `flagon-io`, Flagon, | |
| 335 | + | * Inc. Staff-managed only; it follows the workspace through renames. | |
| 336 | + | */ | |
| 337 | + | export type WorkspaceAlias = { | |
| 338 | + | alias: string; | |
| 339 | + | workspaceId: string; | |
| 340 | + | /** The workspace's slug and name now. */ | |
| 341 | + | workspace: string; | |
| 342 | + | workspaceName: string; | |
| 343 | + | /** Why it exists. */ | |
| 344 | + | note: string; | |
| 345 | + | /** The staff member who set it, or `migration`. */ | |
| 346 | + | createdBy: string; | |
| 347 | + | /** RFC 3339. */ | |
| 348 | + | createdAt: string; | |
| 349 | + | }; | |
| 350 | + | ||
| 321 | 351 | /** Who is asking. Every read and write in every service takes one. */ | |
| 322 | 352 | export type Viewer = User | null; | |
| 323 | 353 | ||
| ⋯ | |||
| 549 | 579 | checkWorkspaceRename(actor: User, slug: string, newSlug: string): Promise<Result<boolean>>; | |
| 550 | 580 | /** | |
| 551 | 581 | * The workspace's current slug when `slug` is one it was renamed from | |
| 552 | − | * within `SLUG_HOLD_DAYS`; null otherwise, including for a slug in use. | |
| 582 | + | * within `SLUG_HOLD_DAYS`, or when `slug` is an alias staff set for it | |
| 583 | + | * (`WorkspaceAlias`); null otherwise, including for a slug in use. | |
| 553 | 584 | */ | |
| 554 | 585 | resolveSlug(slug: string): Promise<string | null>; | |
| 555 | 586 | /** | |