| 1 | import { useState } from "react"; |
| 2 | import { Form, data } from "react-router"; |
| 3 | import { KeyRound, Sparkles } from "lucide-react"; |
| 4 | |
| 5 | import { type SsoConfig, type SsoProtocol, type SsoSettings, scimUrl, ssoAddresses } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/sso"; |
| 8 | import { page } from "../../lib/meta"; |
| 9 | import { DangerAction, DangerZone } from "../../components/danger-zone"; |
| 10 | import { planHref } from "../../components/start-plan"; |
| 11 | import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; |
| 12 | import { |
| 13 | AlertDialog, |
| 14 | AlertDialogCancel, |
| 15 | AlertDialogContent, |
| 16 | AlertDialogDescription, |
| 17 | AlertDialogFooter, |
| 18 | AlertDialogHeader, |
| 19 | AlertDialogTitle, |
| 20 | AlertDialogTrigger, |
| 21 | } from "../../components/ui/alert-dialog"; |
| 22 | import { Badge } from "../../components/ui/badge"; |
| 23 | import { Button } from "../../components/ui/button"; |
| 24 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; |
| 25 | import { SwitchCard } from "../../components/ui/switch"; |
| 26 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; |
| 27 | import { Unavailable } from "../../components/boundary"; |
| 28 | import { isFailed, part } from "../../lib/partial"; |
| 29 | import { billing } from "../../lib/services.server"; |
| 30 | import { useAddresses } from "../../lib/addresses"; |
| 31 | import { PROTOCOL_NAMES } from "../../lib/sso"; |
| 32 | import { sso } from "../../lib/sso.server"; |
| 33 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 34 | |
| 35 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 36 | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); |
| 37 | } |
| 38 | |
| 39 | export 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 | |
| 56 | type Answer = { saved?: string; error?: string; intent?: string; token?: string }; |
| 57 | |
| 58 | export 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 | |
| 116 | export 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. */ |
| 177 | function 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'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. */ |
| 265 | function 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. */ |
| 294 | function 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 | |
| 314 | function 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 | |
| 319 | function 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 | |
| 457 | function 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 | |
| 475 | function 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 | |
| 516 | function 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 | } |