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