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