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

257 lines9,258 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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