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.

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