Skip to content

g1t/apps/sudo/app/routes/aliases.tsx

165 lines7,608 bytesCodeBlame
1import { ArrowRight, Signpost } from "lucide-react";
2import { Link, data, redirect } from "react-router";
3
4import type { WorkspaceAlias } from "@g1t/contracts";
5
6import type { Route } from "./+types/aliases";
7import { Button, EmptyState, Field, Input, Notice, PageHeader, Section, Textarea, When } from "~/components/ui";
8import { MAX_ALIAS_NOTE, parseNewAlias, parseRemovalReason } from "~/lib/aliases";
9import { text } from "~/lib/forms";
10import { identity } from "~/lib/services.server";
11import { settle } from "~/lib/settle";
12import { requireStaff } from "~/lib/staff";
13
14export const meta: Route.MetaFunction = () => [{ title: "Aliases · sudo" }, { name: "robots", content: "noindex, nofollow" }];
15
16export 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
29type 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 */
36export 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
62export 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
132function 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}