Skip to content

g1t/apps/web/app/components/token-scopes.tsx

258 lines9,468 bytesCodeBlame
1import { ShieldAlert, TriangleAlert } from "lucide-react";
2import { useState } from "react";
3
4import {
5 DANGEROUS_SCOPES,
6 PRESETS,
7 SCOPE_GROUPS,
8 describeScope,
9 isDangerous,
10 levelsOf,
11 presetScopes,
12 scopeLevel,
13 scopeResource,
14 type PresetId,
15 type Scope,
16} from "@g1t/contracts";
17
18import { cn } from "../lib/cn";
19import {
20 DEFAULT_EXPIRY,
21 EXPIRY_CHOICES,
22 accessSummary,
23 everyScope,
24 impliedBy,
25 matchingPreset,
26 normalizeScopes,
27} from "../lib/token-scopes";
28import { Badge } from "./ui/badge";
29import { Hint } from "./ui/hint";
30import { CONTROL } from "./ui/input";
31
32// Choosing what a token or an application may do: a classic checklist.
33// A token reaches whatever its owner can; the boxes say what it may do
34// there. Every box is a plain form field (`scope`), so the form posts the
35// same with or without JavaScript; the script applies presets and ticks the
36// lower levels a higher one includes. `lib/token-scopes.ts` reads it back.
37
38/** One box. Greyed out and ticked when a higher level of its resource is ticked. */
39function ScopeBox({
40 scope,
41 ticked,
42 onToggle,
43}: {
44 scope: Scope;
45 ticked: readonly Scope[];
46 onToggle: (scope: Scope, on: boolean) => void;
47}) {
48 const by = impliedBy(ticked, scope);
49 const checked = by !== null || ticked.includes(scope);
50 return (
51 <Hint label={by ? `Included in ${by}` : undefined}>
52 <label className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}>
53 <input
54 type="checkbox"
55 name="scope"
56 value={scope}
57 checked={checked}
58 disabled={by !== null}
59 onChange={(event) => onToggle(scope, event.target.checked)}
60 className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")}
61 />
62 <span className="min-w-0">
63 <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}>
64 {scope}
65 </span>
66 <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span>
67 {by && <span className="sr-only">Included in {by}</span>}
68 </span>
69 </label>
70 </Hint>
71 );
72}
73
74/**
75 * The scope checklist: presets as quick buttons, then a box per scope,
76 * grouped by area, with admin scopes under "Dangerous". Posts `scope` for
77 * each ticked box and `preset` = `full` for full access.
78 *
79 * With `only`, it is the consent page: just the scopes an application
80 * asked for, all ticked, to untick; nothing can be added.
81 */
82export function ScopeChecklist({
83 initial,
84 allowFull = true,
85 only,
86}: {
87 /** Null: full access. */
88 initial: readonly string[] | null;
89 allowFull?: boolean;
90 only?: readonly Scope[];
91}) {
92 const [full, setFull] = useState(allowFull && !only && initial === null);
93 const [ticked, setTicked] = useState<Scope[]>(() => (initial === null ? everyScope() : normalizeScopes(initial)));
94 const shown = (scope: Scope) => !only || only.includes(scope);
95 const groups = SCOPE_GROUPS.map((group) => ({ ...group, scopes: group.scopes.filter(shown) })).filter(
96 (group) => group.scopes.length > 0,
97 );
98 const dangerous = DANGEROUS_SCOPES.filter(shown);
99 const preset: PresetId | null = full ? "full" : matchingPreset(ticked);
100 const count = full ? null : normalizeScopes(ticked).length;
101
102 const choosePreset = (id: PresetId) => {
103 const scopes = presetScopes(id);
104 setFull(scopes === null);
105 setTicked(scopes === null ? everyScope() : normalizeScopes(scopes));
106 };
107 // Ticking a level includes the lower ones; unticking one leaves the
108 // level below it ticked, so only the box you touched changes.
109 const toggle = (scope: Scope, on: boolean) => {
110 setFull(false);
111 setTicked((current) => {
112 const resource = scopeResource(scope);
113 if (on) return normalizeScopes([...current, scope]);
114 const levels = levelsOf(resource);
115 const below = levels[levels.indexOf(scopeLevel(scope)) - 1];
116 const rest = current.filter((held) => held !== scope);
117 return normalizeScopes(below && shown(`${resource}:${below}` as Scope) ? [...rest, `${resource}:${below}`] : rest);
118 });
119 };
120
121 return (
122 <fieldset className="min-w-0 space-y-3">
123 <legend className="sr-only">Scopes</legend>
124 {full && <input type="hidden" name="preset" value="full" />}
125
126 {!only && (
127 <div className="flex flex-wrap items-center gap-1.5">
128 <span className="mr-1 text-sm font-medium text-muted">Scopes</span>
129 {PRESETS.filter((option) => allowFull || option.id !== "full").map((option) => (
130 <Hint key={option.id} label={option.description}>
131 <button
132 type="button"
133 aria-pressed={preset === option.id}
134 onClick={() => choosePreset(option.id)}
135 className={cn(
136 "rounded-full border px-2.5 py-0.5 text-xs transition-colors",
137 preset === option.id
138 ? option.id === "full"
139 ? "border-danger/50 bg-danger/10 text-danger"
140 : "border-accent/50 bg-accent/10 text-accent"
141 : "border-line text-muted hover:border-line-strong hover:text-fg",
142 )}
143 >
144 {option.label}
145 </button>
146 </Hint>
147 ))}
148 <span className="ml-auto text-xs text-faint">
149 {count === null ? "Everything you can do" : count === 1 ? "1 scope" : `${count} scopes`}
150 </span>
151 </div>
152 )}
153
154 {full && (
155 <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
156 <TriangleAlert size={14} className="mt-px shrink-0" />
157 Full access can do everything you can, including scopes added later. Untick anything to
158 narrow it.
159 </p>
160 )}
161
162 <div className="divide-y divide-line rounded-md border border-line">
163 {groups.map((group) => (
164 <div key={group.id} role="group" aria-labelledby={`scopes-${group.id}`} className="px-3 py-2.5 sm:px-4">
165 <p id={`scopes-${group.id}`} className="mb-1 text-xs font-medium text-muted">
166 {group.label}
167 </p>
168 <div className="grid gap-x-6 sm:grid-cols-2">
169 {group.scopes.map((scope) => (
170 <ScopeBox key={scope} scope={scope} ticked={ticked} onToggle={toggle} />
171 ))}
172 </div>
173 </div>
174 ))}
175 </div>
176
177 {dangerous.length > 0 && (
178 <div role="group" aria-labelledby="scopes-dangerous" className="rounded-md border border-danger/30 px-3 py-2.5 sm:px-4">
179 <p id="scopes-dangerous" className="flex items-center gap-1.5 text-xs font-medium text-danger">
180 <ShieldAlert size={14} className="shrink-0" />
181 Dangerous
182 </p>
183 <p className="mt-0.5 mb-1 text-xs text-faint">
184 Hard to undo, or decides who can reach what. Tick these only for something you trust as
185 much as yourself.
186 </p>
187 <div className="grid gap-x-6 sm:grid-cols-2">
188 {dangerous.map((scope) => (
189 <ScopeBox key={scope} scope={scope} ticked={ticked} onToggle={toggle} />
190 ))}
191 </div>
192 </div>
193 )}
194 </fieldset>
195 );
196}
197
198/** When a new token stops working: 90 days unless chosen otherwise. Posts `expires`. */
199export function ExpiryField({ id = "token-expires" }: { id?: string }) {
200 const [value, setValue] = useState<string>(DEFAULT_EXPIRY);
201 return (
202 <div className="flex flex-col gap-1.5">
203 <label htmlFor={id} className="text-sm font-medium text-muted">
204 Expires
205 </label>
206 <select
207 id={id}
208 name="expires"
209 value={value}
210 onChange={(event) => setValue(event.target.value)}
211 className={CONTROL}
212 >
213 {EXPIRY_CHOICES.map((choice) => (
214 <option key={choice.value} value={choice.value}>
215 {choice.label}
216 </option>
217 ))}
218 </select>
219 {value === "never" && (
220 <p className="flex items-start gap-1.5 text-xs text-warn">
221 <TriangleAlert size={13} className="mt-px shrink-0" />
222 It works until someone deletes it. Prefer an expiry.
223 </p>
224 )}
225 </div>
226 );
227}
228
229/** A token's or an application's access in a list: what it may do. */
230export function AccessSummary({
231 holder,
232 className,
233}: {
234 holder: { scopes: readonly string[] | null; legacy: boolean };
235 className?: string;
236}) {
237 const summary = accessSummary(holder);
238 const preset = matchingPreset(holder.scopes);
239 const scopes = holder.scopes && !preset ? normalizeScopes(holder.scopes) : [];
240 const tone = holder.scopes === null ? (holder.legacy ? "warn" : "danger") : scopes.length === 0 && !preset ? "neutral" : "accent";
241 return (
242 <div className={cn("mt-1.5 flex flex-wrap items-center gap-1.5", className)}>
243 <Badge tone={tone}>{summary}</Badge>
244 {scopes.map((scope) => (
245 <Hint key={scope} label={describeScope(scope)}>
246 <span
247 className={cn(
248 "rounded border px-1.5 py-px font-mono text-[0.6875rem]",
249 isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted",
250 )}
251 >
252 {scope}
253 </span>
254 </Hint>
255 ))}
256 </div>
257 );
258}