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.

569 linesCodeBlameRaw
1import { useState } from "react";
2import { Form, data } from "react-router";
3import { KeyRound, Sparkles } from "lucide-react";
4
5import { type SsoConfig, type SsoProtocol, type SsoSettings, scimUrl, ssoAddresses } from "@g1t/contracts";
6
7import type { Route } from "./+types/sso";
8import { page } from "../../lib/meta";
9import { DangerAction, DangerZone } from "../../components/danger-zone";
10import { planHref } from "../../components/start-plan";
11import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
12import {
13 AlertDialog,
14 AlertDialogCancel,
15 AlertDialogContent,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogTitle,
20 AlertDialogTrigger,
21} from "../../components/ui/alert-dialog";
22import { Badge } from "../../components/ui/badge";
23import { Button } from "../../components/ui/button";
24import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
25import { SwitchCard } from "../../components/ui/switch";
26import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
27import { Unavailable } from "../../components/boundary";
28import { isFailed, part } from "../../lib/partial";
29import { billing } from "../../lib/services.server";
30import { useAddresses } from "../../lib/addresses";
31import { PROTOCOL_NAMES } from "../../lib/sso";
32import { sso } from "../../lib/sso.server";
33import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
34
35export function meta({ params, ...args }: Route.MetaArgs) {
36 return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
37}
38
39export async function loader({ params, context, request }: Route.LoaderArgs) {
40 // Owners only; to anyone else the page does not exist.
41 const viewer = getViewer(context);
42 if (!viewer || roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 });
43 const slug = params.owner.toLowerCase();
44 const [settings, free] = await Promise.all([
45 sso.settings(viewer, slug),
46 // Whether it can be set up here: every paid plan has it.
47 part(billing.freeWorkspaces([slug]), "The workspace's plan"),
48 ]);
49 return {
50 settings: unwrap(settings),
51 plan: isFailed(free) ? free : free.includes(slug) ? ("free" as const) : ("paid" as const),
52 addresses: ssoAddresses(new URL(request.url).origin, unwrap(settings).workspace_id, slug),
53 };
54}
55
56type Answer = { saved?: string; error?: string; intent?: string; token?: string };
57
58export async function action({ request, params, context }: Route.ActionArgs): Promise<Answer> {
59 assertSameOrigin(request);
60 const user = requireUser(context, request);
61 const slug = params.owner.toLowerCase();
62 const form = await request.formData();
63 const intent = String(form.get("intent") ?? "");
64 const text = (name: string) => String(form.get(name) ?? "").trim() || null;
65 const answer = (result: { ok: true } | { ok: false; error: { message: string } }, saved: string): Answer =>
66 result.ok ? { saved, intent } : { error: result.error.message, intent };
67 switch (intent) {
68 case "save": {
69 const protocol = String(form.get("protocol") ?? "oidc") as SsoProtocol;
70 const config: SsoConfig = {
71 issuer: text("issuer"),
72 client_id: text("client_id"),
73 scopes: text("scopes"),
74 authorization_url: text("authorization_url"),
75 token_url: text("token_url"),
76 userinfo_url: text("userinfo_url"),
77 subject_claim: text("subject_claim"),
78 email_claim: text("email_claim"),
79 idp_entity_id: text("idp_entity_id"),
80 idp_sso_url: text("idp_sso_url"),
81 join_domains: String(form.get("join_domains") ?? "")
82 .split(/[\s,]+/)
83 .map((domain) => domain.trim())
84 .filter(Boolean),
85 };
86 const saved = await sso.save(user, slug, {
87 protocol,
88 config,
89 client_secret: text("client_secret"),
90 certificate: text("certificate"),
91 allow_join: form.get("allow_join") === "on",
92 });
93 return answer(saved, "Saved.");
94 }
95 case "required": {
96 const required = form.get("required") === "on";
97 return answer(await sso.setRequired(user, slug, required), required ? "Single sign-on is required." : "Single sign-on is not required.");
98 }
99 case "unlink": {
100 const username = String(form.get("username") ?? "");
101 return answer(await sso.unlink(user, slug, username), `Unlinked ${username}.`);
102 }
103 case "delete":
104 return answer(await sso.remove(user, slug), "Single sign-on is removed.");
105 case "scim-on": {
106 const made = await sso.enableScim(user, slug);
107 return made.ok ? { saved: "Copy the token now: it is not shown again.", intent, token: made.value.token } : { error: made.error.message, intent };
108 }
109 case "scim-off":
110 return answer(await sso.disableScim(user, slug), "SCIM is off.");
111 default:
112 return { error: "Unknown change.", intent };
113 }
114}
115
116export default function WorkspaceSso({ loaderData, actionData, params }: Route.ComponentProps) {
117 const { settings, plan, addresses } = loaderData;
118 const slug = params.owner.toLowerCase();
119 const free = plan === "free";
120 const said = (intent: string) => (actionData?.intent === intent ? actionData : undefined);
121 return (
122 <div className="max-w-3xl space-y-10">
123 <section aria-labelledby="sso-about">
124 <h2 id="sso-about" className="flex items-center gap-2 font-medium">
125 <KeyRound size={16} className="text-faint" aria-hidden />
126 Single sign-on
127 </h2>
128 <p className="mt-1.5 text-sm text-muted">
129 Connect {slug} to your identity provider over OpenID Connect, OAuth 2.0 or SAML. People sign in to g1t as
130 themselves, then through your provider to use {slug}: the first time, g1t links who they are there to their g1t
131 account. Require it, and members use {slug} only after signing in through it.
132 </p>
133 {isFailed(plan) ? (
134 <div className="mt-4">
135 <Unavailable what={plan.what} />
136 </div>
137 ) : (
138 free && <PlanNeeded slug={slug} />
139 )}
140 </section>
141
142 <ProviderSection
143 // Starts from what is saved whenever that changes.
144 key={settings.connection?.updated_at ?? "none"}
145 settings={settings}
146 addresses={addresses}
147 disabled={free}
148 answer={said("save")}
149 />
150
151 {settings.connection && (
152 <RequireSection settings={settings} slug={slug} disabled={free} answer={said("required")} />
153 )}
154
155 {settings.connection && <PeopleSection settings={settings} answer={said("unlink")} />}
156
157 {settings.connection && (
158 <ScimSection settings={settings} slug={slug} disabled={free} answer={said("scim-on") ?? said("scim-off")} />
159 )}
160
161 {settings.connection && (
162 <DangerZone>
163 <DangerAction
164 title="Remove single sign-on"
165 action={<RemoveDialog slug={slug} />}
166 >
167 Forgets the provider and who everyone is there, and stops requiring it. Members keep their place in {slug}.
168 {said("delete")?.error && <ErrorText>{said("delete")?.error}</ErrorText>}
169 </DangerAction>
170 </DangerZone>
171 )}
172 </div>
173 );
174}
175
176/** SCIM: the provider assigns and unassigns people. */
177function ScimSection({ settings, slug, disabled, answer }: { settings: SsoSettings; slug: string; disabled: boolean; answer?: Answer }) {
178 const scim = settings.scim ?? null;
179 const url = scimUrl(useAddresses().api, settings.workspace_id);
180 return (
181 <section aria-labelledby="sso-scim">
182 <h2 id="sso-scim" className="font-medium">
183 SCIM provisioning
184 </h2>
185 <p className="mt-1.5 text-sm text-muted">
186 Let your provider tell g1t who is assigned to {slug}. Then only people it has assigned sign in through it, and
187 someone it unassigns or deactivates leaves {slug} at once, with their roles and sessions. Owners are never removed
188 that way. People, not groups: assign a group&apos;s members to g1t.
189 </p>
190 <div className="mt-4 space-y-3">
191 <Field label="SCIM base URL">
192 <CopyLine text={url} breakAtSlashes />
193 </Field>
194 {answer?.token && (
195 <Field label="SCIM token, shown this once" hint="Give it to your provider as the bearer token (API token or secret token).">
196 <CopyLine text={answer.token} />
197 </Field>
198 )}
199 {scim && !answer?.token && (
200 <p className="text-sm text-muted">
201 Token ending {scim.hint}, made {scim.created_by ? `by ${scim.created_by} ` : ""}
202 <TimeAgo at={scim.created_at} />
203 {scim.last_used_at ? (
204 <>
205 , last used <TimeAgo at={scim.last_used_at} />
206 </>
207 ) : (
208 ", not used yet"
209 )}
210 .
211 </p>
212 )}
213 <div className="flex flex-wrap items-center gap-3">
214 <Form method="post">
215 <input type="hidden" name="intent" value="scim-on" />
216 <SubmitButton variant={scim ? "outline" : "default"} size={scim ? "sm" : "default"} match={{ intent: "scim-on" }} pending="Making a token…" disabled={disabled}>
217 {scim ? "Make a new token" : "Turn on SCIM"}
218 </SubmitButton>
219 </Form>
220 {scim && (
221 <Form method="post">
222 <input type="hidden" name="intent" value="scim-off" />
223 <SubmitButton variant="ghost" size="sm" match={{ intent: "scim-off" }} pending="Turning off…">
224 Turn off SCIM
225 </SubmitButton>
226 </Form>
227 )}
228 <Saved answer={answer} />
229 </div>
230 </div>
231 {scim && (
232 <div className="mt-5">
233 {scim.users.length === 0 ? (
234 <p className="text-sm text-faint">Your provider has not assigned anyone yet.</p>
235 ) : (
236 <Table>
237 <TableHeader>
238 <TableRow>
239 <TableHead>Assigned</TableHead>
240 <TableHead>On g1t</TableHead>
241 <TableHead>State</TableHead>
242 </TableRow>
243 </TableHeader>
244 <TableBody>
245 {scim.users.map((user) => (
246 <TableRow key={user.id}>
247 <TableCell className="max-w-64 truncate">
248 <span className="font-medium">{user.display_name ?? user.user_name}</span>
249 {user.display_name && <span className="block truncate text-xs text-faint">{user.user_name}</span>}
250 </TableCell>
251 <TableCell className="text-muted">{user.username ?? "Not signed in yet"}</TableCell>
252 <TableCell>{user.active ? <Badge tone="success">Active</Badge> : <Badge tone="neutral">Deactivated</Badge>}</TableCell>
253 </TableRow>
254 ))}
255 </TableBody>
256 </Table>
257 )}
258 </div>
259 )}
260 </section>
261 );
262}
263
264/** Removing it, once the owner says so in a dialog. */
265function RemoveDialog({ slug }: { slug: string }) {
266 return (
267 <AlertDialog>
268 <AlertDialogTrigger asChild>
269 <Button variant="destructive">Remove</Button>
270 </AlertDialogTrigger>
271 <AlertDialogContent>
272 <AlertDialogHeader>
273 <AlertDialogTitle>Remove single sign-on from {slug}?</AlertDialogTitle>
274 <AlertDialogDescription>
275 g1t forgets your provider and who everyone is there, and stops requiring it. Setting it up again links everyone
276 afresh.
277 </AlertDialogDescription>
278 </AlertDialogHeader>
279 <Form method="post">
280 <input type="hidden" name="intent" value="delete" />
281 <AlertDialogFooter>
282 <AlertDialogCancel>Cancel</AlertDialogCancel>
283 <SubmitButton variant="destructive" match={{ intent: "delete" }} pending="Removing…">
284 Remove single sign-on
285 </SubmitButton>
286 </AlertDialogFooter>
287 </Form>
288 </AlertDialogContent>
289 </AlertDialog>
290 );
291}
292
293/** For a free workspace: it is there, and comes with any paid plan. */
294function PlanNeeded({ slug }: { slug: string }) {
295 return (
296 <div role="note" className="mt-4 rounded-xl border border-accent/30 bg-accent/5 p-4">
297 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
298 <Sparkles size={16} className="hidden shrink-0 text-accent sm:block" aria-hidden />
299 <div className="min-w-0 grow">
300 <p className="text-sm font-medium">Single sign-on comes with every paid plan</p>
301 <p className="mt-1 text-sm text-muted">
302 {slug} is a free workspace. Start the plan to connect your identity provider: there is nothing extra to pay
303 for it.
304 </p>
305 </div>
306 <span className="shrink-0">
307 <ButtonLink to={planHref(slug)}>Start the plan</ButtonLink>
308 </span>
309 </div>
310 </div>
311 );
312}
313
314function Saved({ answer }: { answer?: Answer }) {
315 if (!answer) return null;
316 return answer.error ? <ErrorText>{answer.error}</ErrorText> : <span className="text-sm text-success">{answer.saved}</span>;
317}
318
319function ProviderSection({
320 settings,
321 addresses,
322 disabled,
323 answer,
324}: {
325 settings: SsoSettings;
326 addresses: ReturnType<typeof ssoAddresses>;
327 disabled: boolean;
328 answer?: Answer;
329}) {
330 const connection = settings.connection;
331 const [protocol, setProtocol] = useState<SsoProtocol>(connection?.protocol ?? "oidc");
332 // What is saved for the protocol shown; another protocol starts empty.
333 const saved = connection?.protocol === protocol ? connection.config : {};
334 const value = (name: keyof SsoConfig) => (saved[name] as string | null | undefined) ?? "";
335 const keepsSecret = connection?.protocol === protocol && connection.has_client_secret;
336 return (
337 <section aria-labelledby="sso-provider">
338 <h2 id="sso-provider" className="font-medium">
339 Identity provider
340 </h2>
341 <Form method="post" className="mt-5 space-y-5">
342 <input type="hidden" name="intent" value="save" />
343 <fieldset disabled={disabled} className="space-y-5 disabled:opacity-60">
344 <RadioGroup name="protocol" value={protocol} onValueChange={(next) => setProtocol(next as SsoProtocol)} className="sm:grid-cols-3">
345 <RadioOption value="oidc" label={PROTOCOL_NAMES.oidc} description="Okta, Microsoft Entra ID, Google, Auth0 and most others." />
346 <RadioOption value="saml" label={PROTOCOL_NAMES.saml} description="For providers set up for SAML apps." />
347 <RadioOption value="oauth2" label={PROTOCOL_NAMES.oauth2} description="A provider without OpenID Connect." />
348 </RadioGroup>
349
350 <div className="space-y-2">
351 <p className="text-sm font-medium text-muted">In your provider, set up an app with these</p>
352 {protocol === "saml" ? (
353 <>
354 <Field label="Entity id (audience) and metadata">
355 <CopyLine text={addresses.entityId} breakAtSlashes />
356 </Field>
357 <Field label="Assertion consumer service (ACS) URL, HTTP-POST">
358 <CopyLine text={addresses.acsUrl} breakAtSlashes />
359 </Field>
360 </>
361 ) : (
362 <Field label="Redirect URI (callback)">
363 <CopyLine text={addresses.callbackUrl} breakAtSlashes />
364 </Field>
365 )}
366 </div>
367
368 {protocol === "oidc" && (
369 <>
370 <Field label="Issuer" hint="Where its /.well-known/openid-configuration lives, such as https://acme.okta.com.">
371 <Input name="issuer" type="url" required defaultValue={value("issuer")} placeholder="https://" />
372 </Field>
373 <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="openid email profile" />
374 </>
375 )}
376
377 {protocol === "oauth2" && (
378 <>
379 <Field label="Authorization URL">
380 <Input name="authorization_url" type="url" required defaultValue={value("authorization_url")} placeholder="https://" />
381 </Field>
382 <Field label="Token URL">
383 <Input name="token_url" type="url" required defaultValue={value("token_url")} placeholder="https://" />
384 </Field>
385 <Field label="Userinfo URL" hint="Read with the access token: who signed in.">
386 <Input name="userinfo_url" type="url" required defaultValue={value("userinfo_url")} placeholder="https://" />
387 </Field>
388 <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="" />
389 <div className="grid gap-4 sm:grid-cols-2">
390 <Field label="Subject claim" hint="The person's stable id in the userinfo answer. sub, then id, when empty.">
391 <Input name="subject_claim" defaultValue={value("subject_claim")} placeholder="sub" />
392 </Field>
393 <Field label="Email claim" hint="Shown beside each person; never used to sign in.">
394 <Input name="email_claim" defaultValue={value("email_claim")} placeholder="email" />
395 </Field>
396 </div>
397 </>
398 )}
399
400 {protocol === "saml" && (
401 <>
402 <Field label="Identity provider entity id" hint="Its issuer, as its metadata says it.">
403 <Input name="idp_entity_id" required defaultValue={value("idp_entity_id")} />
404 </Field>
405 <Field label="Single sign-on URL" hint="Where it takes SAML requests (HTTP-Redirect).">
406 <Input name="idp_sso_url" type="url" required defaultValue={value("idp_sso_url")} placeholder="https://" />
407 </Field>
408 <Field
409 label="Signing certificate"
410 hint={
411 connection?.protocol === "saml" && connection.certificates.length > 0 ? (
412 <>
413 Saved, by SHA-256:{" "}
414 {connection.certificates.map((c) => (
415 <span key={c.fingerprint} className="block font-mono break-all">
416 {c.fingerprint}
417 </span>
418 ))}
419 Paste new ones to replace them; several while you rotate.
420 </>
421 ) : (
422 "PEM, starting -----BEGIN CERTIFICATE-----. Sign the assertion or the response with SHA-256, and leave the assertion unencrypted."
423 )
424 }
425 >
426 <Textarea name="certificate" rows={5} required={!(connection?.protocol === "saml" && connection.certificates.length > 0)} />
427 </Field>
428 </>
429 )}
430
431 <SwitchCard name="allow_join" title="Let people from your provider join" defaultChecked={connection?.allow_join ?? false}>
432 Anyone your provider signs in becomes a member of the workspace, with no invitation. Off: only members can sign in
433 through it. Leave it off for a provider anyone can sign in to, such as a personal Google or GitHub account.
434 </SwitchCard>
435 <Field
436 label="Only addresses at"
437 hint={
438 protocol === "oauth2"
439 ? "Plain OAuth 2.0 never vouches for an address, so with domains set nobody can join; leave it empty, or use OpenID Connect."
440 : "Who may join, when joining is on: people whose address your provider vouches for is at one of these domains, such as acme.com. Microsoft Entra ID vouches for none over OpenID Connect; use SAML to limit by domain. Empty: anyone your provider signs in."
441 }
442 >
443 <Input name="join_domains" defaultValue={(connection?.config.join_domains ?? []).join(", ")} placeholder="acme.com" />
444 </Field>
445 </fieldset>
446 <div className="flex flex-wrap items-center gap-3">
447 <SubmitButton match={{ intent: "save" }} pending="Checking…" disabled={disabled}>
448 {connection ? "Save" : "Set up single sign-on"}
449 </SubmitButton>
450 <Saved answer={answer} />
451 </div>
452 </Form>
453 </section>
454 );
455}
456
457function ClientFields({ value, keepsSecret, defaultScopes }: { value: (name: keyof SsoConfig) => string; keepsSecret: boolean; defaultScopes: string }) {
458 return (
459 <>
460 <div className="grid gap-4 sm:grid-cols-2">
461 <Field label="Client id">
462 <Input name="client_id" required defaultValue={value("client_id")} />
463 </Field>
464 <Field label="Client secret" hint={keepsSecret ? "Saved, and never shown. Enter a new one to replace it." : "Kept encrypted, and never shown again."}>
465 <Input name="client_secret" type="password" autoComplete="new-password" required={!keepsSecret} placeholder={keepsSecret ? "••••••••" : undefined} />
466 </Field>
467 </div>
468 <Field label="Scopes" hint={defaultScopes ? `Space-separated. ${defaultScopes} when empty.` : "Space-separated, as the provider needs to answer userinfo."}>
469 <Input name="scopes" defaultValue={value("scopes")} placeholder={defaultScopes} />
470 </Field>
471 </>
472 );
473}
474
475function RequireSection({ settings, slug, disabled, answer }: { settings: SsoSettings; slug: string; disabled: boolean; answer?: Answer }) {
476 const connection = settings.connection!;
477 const [chosen, setChosen] = useState(settings.required);
478 const back = encodeURIComponent(`/${slug}/-/sso`);
479 // Requiring it needs the owner to have signed in through it; never needed to stop.
480 const locked = !settings.required && (disabled || !settings.you_linked);
481 return (
482 <section aria-labelledby="sso-require">
483 <h2 id="sso-require" className="font-medium">
484 Test and require
485 </h2>
486 <div className="mt-4 flex flex-wrap items-center gap-3">
487 {connection.tested_at ? (
488 <Badge tone="success">
489 Tested by {connection.tested_by ?? "someone"} <TimeAgo at={connection.tested_at} />
490 </Badge>
491 ) : (
492 <Badge tone="warn">Not tested yet</Badge>
493 )}
494 <ButtonLink to={`/auth/sso/${slug}?next=${back}`} variant="outline" size="sm">
495 {settings.you_linked ? "Sign in through it again" : "Test sign-in"}
496 </ButtonLink>
497 </div>
498 <Form method="post" className="mt-5 space-y-3">
499 <input type="hidden" name="intent" value="required" />
500 <SwitchCard name="required" title="Require single sign-on" checked={chosen} onCheckedChange={setChosen} disabled={locked}>
501 {locked && !disabled
502 ? "Sign in through your provider yourself first, with Test sign-in."
503 : `Members use ${slug} only from a session that signed in through your provider in the last day, and their access tokens and SSH keys only once they have linked. Owners always can, so a provider that breaks never locks you out. Outside collaborators are not asked.`}
504 </SwitchCard>
505 <div className="flex flex-wrap items-center gap-3">
506 <SubmitButton variant="outline" size="sm" match={{ intent: "required" }} pending="Saving…" disabled={chosen === settings.required}>
507 Save
508 </SubmitButton>
509 <Saved answer={answer} />
510 </div>
511 </Form>
512 </section>
513 );
514}
515
516function PeopleSection({ settings, answer }: { settings: SsoSettings; answer?: Answer }) {
517 return (
518 <section aria-labelledby="sso-people">
519 <h2 id="sso-people" className="font-medium">
520 Linked people
521 </h2>
522 <p className="mt-1.5 text-sm text-muted">
523 Who has signed in through your provider, and who they are there. Unlink someone to let them link another account
524 there; their sessions sign in through it again.
525 </p>
526 <div className="mt-4">
527 {settings.identities.length === 0 ? (
528 <p className="text-sm text-faint">No one yet.</p>
529 ) : (
530 <Table>
531 <TableHeader>
532 <TableRow>
533 <TableHead>Person</TableHead>
534 <TableHead>At your provider</TableHead>
535 <TableHead>Last signed in</TableHead>
536 <TableHead>
537 <span className="sr-only">Unlink</span>
538 </TableHead>
539 </TableRow>
540 </TableHeader>
541 <TableBody>
542 {settings.identities.map((identity) => (
543 <TableRow key={identity.username}>
544 <TableCell className="font-medium">{identity.display_username ?? identity.username}</TableCell>
545 <TableCell className="max-w-56 truncate text-muted">{identity.email ?? identity.subject}</TableCell>
546 <TableCell className="text-muted">
547 <TimeAgo at={identity.last_used_at} />
548 </TableCell>
549 <TableCell className="text-right">
550 <Form method="post">
551 <input type="hidden" name="intent" value="unlink" />
552 <input type="hidden" name="username" value={identity.username} />
553 <SubmitButton variant="ghost" size="sm" match={{ intent: "unlink", username: identity.username }} pending="Unlinking…">
554 Unlink
555 </SubmitButton>
556 </Form>
557 </TableCell>
558 </TableRow>
559 ))}
560 </TableBody>
561 </Table>
562 )}
563 <div className="mt-2">
564 <Saved answer={answer} />
565 </div>
566 </div>
567 </section>
568 );
569}