Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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