diff --git a/.claude/skills/test-sso-with-keycloak/SKILL.md b/.claude/skills/test-sso-with-keycloak/SKILL.md new file mode 100644 index 000000000..d1fea76b2 --- /dev/null +++ b/.claude/skills/test-sso-with-keycloak/SKILL.md @@ -0,0 +1,49 @@ +--- +name: test-sso-with-keycloak +description: Browser-test learner SAML single sign-on end to end against a real Keycloak IdP on the local stack. Use after any change to learner SSO (apps/api/src/sso-protocol, learner-sso-*, sso-admin, sign-in policy, the admin SSO settings or the storefront SSO button), or when asked to test SSO with a real identity provider. +--- + +# Test learner SSO with Keycloak + +Unit tests and the mock IdP in `apps/api/src/sso-protocol/test-idp.ts` sign responses with our own helpers, so they can't catch how a real IdP behaves. After changing learner SSO, also run this browser test against Keycloak. Testing against Keycloak has already found a turned-off connection that could never be turned back on, and the fact that Keycloak's default client signs only the Response (which we refuse). + +Everything runs through `keycloak-sso.sh` in this folder. Run it from the repository root: `.claude/skills/test-sso-with-keycloak/keycloak-sso.sh `. It reads `PUBLIC_API_URL` and `DATABASE_URL` from `apps/api/.env`. + +## Requirements + +- Docker, and the local stack running: API, worker, admin and storefront, with `SEED=1` and `BILLING_MODE=oss` in `apps/api/.env`. +- **Keycloak must be served over HTTPS with a certificate the browser trusts.** The API only accepts `https` IdP URLs, and the browser refuses self-signed certificates. By default the script serves Keycloak with `tailscale serve` (tailnet-only) on port 9443 under this machine's Tailscale name. Choose another port with `KC_TS_PORT` if 9443 is taken, and never use `tailscale funnel`. Without Tailscale, put Keycloak behind any trusted HTTPS proxy and set `KC_PUBLIC_URL`. +- A browser tool. Use a separate (incognito) profile for the learner, so admin and learner cookies stay apart. + +## Run the test + +1. `keycloak-sso.sh up`. It prints the IdP metadata URL and the learner login, `lena` / `Lena-pass-123` (email `lena.kc@acme.test`). +2. In the admin, sign in as the seeded owner, `owner@example.com`. The sign-in code is printed in the API log (`[CourseLit OTP]`). +3. Open **Settings → Login providers → Single sign-on → Set up**. Paste the output of `keycloak-sso.sh metadata`, set a button label, and select **Create**. Note the connection ID (`sso_…`) in the page URL. + - A school can show only one SSO connection. If one exists, delete it first. The IdP entity ID can't be changed, so a Keycloak with a different hostname also needs a new connection. +4. `keycloak-sso.sh point `. +5. In the admin, add the domain `acme.test`. `.test` domains can't hold a real DNS record, so run `keycloak-sso.sh verify-domain `, then reload the page. +6. Select **Test connection** and sign in at Keycloak as `lena`. The result should show the provider, a persistent subject, `lena.kc@acme.test`, "Lena Keycloak", and "On a verified domain". +7. Select **Turn on**. +8. As the learner, open the school's storefront `/login`, for example `http://school-a.localhost:/login`. Select **Sign in with** followed by the label, sign in at Keycloak, and expect to land on `/dashboard`. `/api/v1/learner/me` should return `lena.kc@acme.test`. + +## Checks worth repeating + +- Signing in again reuses the same identity: still one row in `school_sso_identities` and one school account. +- The learner's `school_sessions` row has `sso_connection_id` set. No Better Auth `session` row exists for the shadow user, whose email is `sso-…@users.invalid`. +- **Require SSO for these domains**: an email code for `someone@acme.test` is refused on the storefront, while other domains still work. +- **Turn off** ends the learner's session at once (`/learner/me` returns 401). +- Changing the subject attribute, sign-in URL or certificates on a turned-off connection makes it **Draft**, so it can be tested and turned on again. +- `keycloak-sso.sh signing response-only`, then **Test connection**: it fails with `signature_invalid` and the hint about signing the assertion. Restore with `keycloak-sso.sh signing assertion`. +- Saving SSO settings with an old admin sign-in asks the owner to sign in again. + +## Gotchas + +- Admin writes need a recent sign-in. If the sign-in dialog appears, sign in again and repeat the change. +- `psql -c "a; b"` runs as one transaction: if one statement fails, the earlier updates roll back. +- Keycloak's session sends `SessionNotOnOrAfter` (10 hours by default), so SSO sessions end sooner than the 7-day default. That's expected. +- Deleting a connection keeps its learners. Their shadow users stay until they sign in another way. + +## Clean up + +Always run `keycloak-sso.sh down` when you finish. It removes the Keycloak container and the `tailscale serve` port, which is a change to the machine's Tailscale config. Close the browser tabs you opened. diff --git a/.claude/skills/test-sso-with-keycloak/keycloak-sso.sh b/.claude/skills/test-sso-with-keycloak/keycloak-sso.sh new file mode 100755 index 000000000..1b7bb1416 --- /dev/null +++ b/.claude/skills/test-sso-with-keycloak/keycloak-sso.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Local Keycloak SAML IdP for browser-testing learner SSO. +# +# keycloak-sso.sh up start Keycloak with realm "acme" and learner "lena" +# keycloak-sso.sh point point the Keycloak client at a CourseLit connection +# keycloak-sso.sh verify-domain [domain] +# mark a domain verified (default acme.test; no real DNS) +# keycloak-sso.sh signing assertion|response-only +# what Keycloak signs (we require a signed assertion) +# keycloak-sso.sh metadata print the IdP metadata XML (paste it into the admin) +# keycloak-sso.sh down remove Keycloak and the HTTPS proxy +# +# Settings (environment): +# KC_PUBLIC_URL HTTPS origin the browser uses for Keycloak. Default: this +# machine's Tailscale name on port KC_TS_PORT, served with +# `tailscale serve` (tailnet-only). The API only accepts https +# IdP URLs, and the browser must trust the certificate. +# KC_TS_PORT Tailscale serve port when KC_PUBLIC_URL is unset. Default 9443. +# KC_HTTP_PORT Local port Keycloak listens on. Default 8180. +# API_ENV API env file for PUBLIC_API_URL and DATABASE_URL. Default apps/api/.env. +set -euo pipefail + +ROOT=$(git rev-parse --show-toplevel) +API_ENV=${API_ENV:-$ROOT/apps/api/.env} +CONTAINER=courselit-sso-keycloak +KC_HTTP_PORT=${KC_HTTP_PORT:-8180} +KC_TS_PORT=${KC_TS_PORT:-9443} +REALM=acme + +env_value() { grep -E "^$1=" "$API_ENV" | tail -1 | cut -d= -f2- | tr -d '"'"'"; } + +public_url() { + if [[ -n "${KC_PUBLIC_URL:-}" ]]; then echo "${KC_PUBLIC_URL%/}"; return; fi + local name + name=$(tailscale status --self --json | bun -e 'const d=JSON.parse(await Bun.stdin.text()); console.log(d.Self.DNSName.replace(/\.$/, ""))') + echo "https://$name:$KC_TS_PORT" +} + +kcadm() { docker exec "$CONTAINER" /opt/keycloak/bin/kcadm.sh "$@"; } +login() { kcadm config credentials --server http://localhost:8080 --realm master --user admin --password admin >/dev/null; } +client_uuid() { kcadm get clients -r "$REALM" --fields id,protocol | bun -e 'const c=JSON.parse(await Bun.stdin.text()).find(x=>x.protocol==="saml"); if(!c) process.exit(1); console.log(c.id)'; } + +sql() { + local url; url=$(env_value DATABASE_URL) + if command -v psql >/dev/null; then psql "$url" -v ON_ERROR_STOP=1 -tAc "$1"; return; fi + # No local psql: use the container that publishes the database port. + local parts; parts=$(bun -e 'const u=new URL(process.argv[1]); console.log(u.port||"5432", decodeURIComponent(u.username), u.pathname.slice(1))' "$url") + read -r port user db <<<"$parts" + docker exec "$(docker ps -q --filter "publish=$port" | head -1)" psql -U "$user" -d "$db" -v ON_ERROR_STOP=1 -tAc "$1" +} + +case "${1:-}" in + up) + url=$(public_url) + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$CONTAINER" -p "127.0.0.1:$KC_HTTP_PORT:8080" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:26.4 start-dev --hostname="$url" --proxy-headers=xforwarded --http-enabled=true >/dev/null + [[ -z "${KC_PUBLIC_URL:-}" ]] && tailscale serve --bg --https="$KC_TS_PORT" "http://127.0.0.1:$KC_HTTP_PORT" >/dev/null + until curl -sf "$url/realms/master" >/dev/null; do sleep 2; done + login + kcadm create realms -s realm=$REALM -s enabled=true >/dev/null + kcadm create users -r $REALM -s username=lena -s email=lena.kc@acme.test -s emailVerified=true \ + -s firstName=Lena -s lastName=Keycloak -s enabled=true >/dev/null + kcadm set-password -r $REALM --username lena --new-password 'Lena-pass-123' + # Keycloak's default client signs only the Response; we require a signed assertion. + cid=$(kcadm create clients -r $REALM -s clientId=placeholder -s protocol=saml -s enabled=true \ + -s frontchannelLogout=false \ + -s 'attributes."saml.server.signature"=true' -s 'attributes."saml.assertion.signature"=true' \ + -s 'attributes."saml.client.signature"=false' -s 'attributes."saml.signature.algorithm"=RSA_SHA256' \ + -s 'attributes."saml_name_id_format"=persistent' -s 'attributes."saml.force.name.id.format"=true' -i) + for p in email firstName lastName; do + kcadm create "clients/$cid/protocol-mappers/models" -r $REALM -s name=$p -s protocol=saml \ + -s protocolMapper=saml-user-property-mapper -s "config.\"user.attribute\"=$p" \ + -s "config.\"attribute.name\"=$p" -s 'config."attribute.nameformat"=Basic' >/dev/null + done + echo "Keycloak is up: $url (admin / admin). Learner: lena / Lena-pass-123 (lena.kc@acme.test)" + echo "IdP metadata URL: $url/realms/$REALM/protocol/saml/descriptor" + ;; + point) + conn=${2:?connection public id, e.g. sso_01...} + api=$(env_value PUBLIC_API_URL); sp="${api%/}/learner-login/sso/$conn" + login; cid=$(client_uuid) + kcadm update "clients/$cid" -r $REALM -s "clientId=$sp/metadata" -s "redirectUris=[\"$sp/acs\"]" \ + -s "attributes.\"saml_assertion_consumer_url_post\"=$sp/acs" + echo "Keycloak client now uses entity ID $sp/metadata and ACS $sp/acs" + ;; + verify-domain) + conn=${2:?connection public id}; domain=${3:-acme.test} + sql "UPDATE school_sso_domains d SET verified_at = now(), consecutive_check_failures = 0 + FROM school_sso_connections c + WHERE c.id = d.connection_id AND c.public_id = '$conn' AND d.domain = '$domain' + RETURNING d.domain" + ;; + signing) + case "${2:-}" in + assertion) value=true ;; + response-only) value=false ;; + *) echo "usage: $0 signing assertion|response-only" >&2; exit 2 ;; + esac + login; cid=$(client_uuid) + kcadm update "clients/$cid" -r $REALM -s "attributes.\"saml.assertion.signature\"=$value" + echo "Keycloak signs: ${2}" + ;; + metadata) + curl -sf "$(public_url)/realms/$REALM/protocol/saml/descriptor"; echo + ;; + down) + [[ -z "${KC_PUBLIC_URL:-}" ]] && tailscale serve --https="$KC_TS_PORT" off >/dev/null 2>&1 || true + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + echo "Keycloak removed" + ;; + *) + sed -n '2,20p' "$0"; exit 2 ;; +esac diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7872d5f73..945788f47 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,10 @@ # Product-owned. Opens one grouped pull request when a CodeLit Platform # release is published; all @codelitdev packages release under one version. # The platform conformance workflow gates the pull request. +# +# The SAML libraries learner SSO signs people in with are pinned exactly and +# have had signature-bypass vulnerabilities, so they get their own grouped +# pull request to review and merge quickly. version: 2 updates: - package-ecosystem: "bun" @@ -9,7 +13,15 @@ updates: interval: "daily" allow: - dependency-name: "@codelitdev/*" + - dependency-name: "@node-saml/node-saml" + - dependency-name: "xml-crypto" + - dependency-name: "@xmldom/xmldom" groups: codelit-platform: patterns: - "@codelitdev/*" + saml: + patterns: + - "@node-saml/node-saml" + - "xml-crypto" + - "@xmldom/xmldom" diff --git a/apps/admin/app/settings/login-providers/page.tsx b/apps/admin/app/settings/login-providers/page.tsx new file mode 100644 index 000000000..bee8ff302 --- /dev/null +++ b/apps/admin/app/settings/login-providers/page.tsx @@ -0,0 +1 @@ +export { default } from "../page"; diff --git a/apps/admin/app/settings/login-providers/sso/[connectionId]/page.tsx b/apps/admin/app/settings/login-providers/sso/[connectionId]/page.tsx new file mode 100644 index 000000000..1e1dcd357 --- /dev/null +++ b/apps/admin/app/settings/login-providers/sso/[connectionId]/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { Suspense } from "react"; +import { AuthGate } from "@/components/auth-gate"; +import { SsoConnectionSettings } from "@/components/settings/sso-connection-settings"; + +/** One SSO connection of the school. */ +export default function SsoConnectionPage() { + const params = useParams<{ connectionId: string }>(); + return ( + +
+
+

Single sign-on

+

+ Let learners sign in through your own identity provider. +

+
+ + + +
+
+ ); +} diff --git a/apps/admin/app/settings/page.tsx b/apps/admin/app/settings/page.tsx index 25b575694..9d4f0325c 100644 --- a/apps/admin/app/settings/page.tsx +++ b/apps/admin/app/settings/page.tsx @@ -10,6 +10,7 @@ import { Globe, Info, Key, + LogIn, Mail, Palette, RotateCcw, @@ -26,6 +27,7 @@ import { AiAssistantSettings } from "@/components/settings/ai-assistant-settings import { ApiKeySettings } from "@/components/settings/api-key-settings"; import { BrandingSettings } from "@/components/settings/branding-settings"; import { EmailDeliveryNotice } from "@/components/mails/email-delivery-notice"; +import { LoginProviderSettings } from "@/components/settings/login-provider-settings"; import { NotificationSettings } from "@/components/settings/notification-settings"; import { TeamSettings } from "@/components/settings/team-settings"; import { CodeInjectionSettings } from "@/components/website/code-injection-settings"; @@ -58,7 +60,7 @@ import { import { Textarea } from "@/components/ui/codelit/textarea"; import currencies from "@/data/currencies.json"; -const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "api-keys", "ai-assistants"] as const; +const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants"] as const; const MAIL_SETTINGS_TABS = ["delivery"] as const; const WEBSITE_SETTINGS_TABS = ["branding", "code-injection", "domain"] as const; type SettingsMode = "general" | "website" | "mails"; @@ -144,7 +146,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { } : { title: "Settings", - description: "Manage payments, team access, notifications, and API keys for this school.", + description: "Manage payments, team access, notifications, login providers, and API keys for this school.", }; function selectWebsiteTab(tab: string) { @@ -421,6 +423,11 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { label: "Notifications", icon: , }, + { + value: "login-providers", + label: "Login providers", + icon: , + }, // API keys belong to the school; only its owner manages them. ...(isOwner ? [{ value: "api-keys", label: "API keys", icon: }] @@ -734,6 +741,14 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { ) : null} + + {mode === "general" && selectedTab === "login-providers" ? ( +
+ +
+ ) : null} +
+ {mode === "general" && selectedTab === "api-keys" && isOwner ? (
diff --git a/apps/admin/components/permission-denied-feedback.tsx b/apps/admin/components/permission-denied-feedback.tsx index 810143987..d760b8f82 100644 --- a/apps/admin/components/permission-denied-feedback.tsx +++ b/apps/admin/components/permission-denied-feedback.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; export const PERMISSION_DENIED_MESSAGE = "You do not have permission to perform that action."; +const SELF_EXPLAINING_REASONS = new Set(["recent_authentication_required", "plan_required"]); const PERMISSION_DENIED_TOAST_ID = "courselit-permission-denied"; export function showPermissionDeniedToast() { @@ -38,7 +39,13 @@ export function PermissionDeniedFeedback() { ) => { const response = await originalFetch(input, init); if (response.status === 403 && isCourseLitAdminApiRequest(input)) { - showPermissionDeniedToast(); + // Some 403s are not about permission; the screen explains those itself. + const reason = await response + .clone() + .json() + .then((body: { details?: { reason?: unknown } }) => body?.details?.reason) + .catch(() => null); + if (!SELF_EXPLAINING_REASONS.has(reason as string)) showPermissionDeniedToast(); } return response; }; diff --git a/apps/admin/components/settings/login-provider-settings.tsx b/apps/admin/components/settings/login-provider-settings.tsx new file mode 100644 index 000000000..9631a3cfd --- /dev/null +++ b/apps/admin/components/settings/login-provider-settings.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { Badge } from "@codelitdev/design-system"; +import { KeyRound, Mail, ShieldCheck } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CourseLitLoading } from "@/components/loading"; +import { RecentSignInDialog } from "@/components/settings/recent-sign-in-dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/codelit/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/codelit/dialog"; +import { Input } from "@/components/ui/codelit/input"; +import { Label } from "@/components/ui/codelit/label"; +import { Switch } from "@/components/ui/codelit/switch"; +import { Textarea } from "@/components/ui/codelit/textarea"; +import { schoolRequest, useSelectedSchool } from "@/lib/school-api"; +import { + type LoginMethod, + RECENT_AUTHENTICATION_REASON, + type SchoolLoginMethods, + SSO_STATUS_LABELS, + type SsoConnection, + errorReason, + methodsAfterToggle, + primarySsoConnection, + ssoErrorMessage, + ssoStatusKind, + wouldLeaveNoMethod, +} from "@/lib/sso"; + +const METHOD_LABELS: Record = { email: "Email codes", google: "Google" }; + +function MethodRow({ + icon, + title, + description, + control, +}: { + icon: ReactNode; + title: string; + description: ReactNode; + control: ReactNode; +}) { + return ( +
+
+
{icon}
+
+

{title}

+
{description}
+
+
+
{control}
+
+ ); +} + +function ssoBadge(connection: SsoConnection | null) { + const kind = ssoStatusKind(connection); + const variant = + kind === "active" ? "success" : kind === "suspended" ? "warning" : kind === "draft" ? "neutral" : "outline"; + return {SSO_STATUS_LABELS[kind]}; +} + +/** Settings → Login providers: how learners can sign in to this school's website. */ +export function LoginProviderSettings() { + const router = useRouter(); + const { loading: schoolLoading, schoolId, isOwner } = useSelectedSchool(); + const [methods, setMethods] = useState(null); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [confirmOff, setConfirmOff] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [reauth, setReauth] = useState(false); + + const load = useCallback(async () => { + if (!schoolId) return; + setError(null); + try { + const [loginMethods, sso] = await Promise.all([ + schoolRequest(schoolId, "/api/v1/school/login-methods"), + schoolRequest<{ items: SsoConnection[] }>(schoolId, "/api/v1/sso/connections"), + ]); + if (!loginMethods.ok || !loginMethods.body) throw new Error("Unable to load sign-in methods."); + setMethods(loginMethods.body); + setConnections(sso.ok ? (sso.body?.items ?? []) : []); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Unable to load sign-in methods."); + } finally { + setLoading(false); + } + }, [schoolId]); + + useEffect(() => { + if (schoolId) void load(); + else if (!schoolLoading) setLoading(false); + }, [schoolId, schoolLoading, load]); + + async function saveMethods(next: LoginMethod[]) { + if (!schoolId || saving) return; + setSaving(true); + try { + const result = await schoolRequest(schoolId, "/api/v1/school/login-methods", { + method: "PATCH", + body: { loginMethods: next }, + }); + if (!result.ok || !result.body) { + if (result.status === 403) return; + throw new Error(ssoErrorMessage(result.body, "We couldn't save that. Please try again.")); + } + setMethods(result.body); + toast.success("Sign-in methods saved."); + } catch (caught) { + toast.error(caught instanceof Error ? caught.message : "Something went wrong."); + } finally { + setSaving(false); + setConfirmOff(null); + } + } + + function toggle(method: LoginMethod, on: boolean) { + if (!methods) return; + if (on) { + void saveMethods(methodsAfterToggle(methods, method, true)); + return; + } + if (wouldLeaveNoMethod(methods, method)) { + toast.error("At least one sign-in method has to stay on."); + return; + } + setConfirmOff(method); + } + + if (loading || schoolLoading) { + return ; + } + if (error || !methods) { + return ( + + +

{error ?? "Unable to load sign-in methods."}

+ +
+
+ ); + } + + const sso = primarySsoConnection(connections); + const emailOn = methods.loginMethods.includes("email"); + const googleOn = methods.loginMethods.includes("google"); + + return ( +
+ + + Login providers + + Choose how learners sign in to your school's website. At least one has to stay on. + + + + } + title={METHOD_LABELS.email} + description="Learners get a one-time code by email." + control={ + toggle("email", on)} + /> + } + /> + } + title={METHOD_LABELS.google} + description={ + methods.googleAvailable + ? "Learners sign in with their Google account." + : "Google sign-in isn't set up on this platform." + } + control={ + toggle("google", on)} + /> + } + /> + } + title="Single sign-on (SSO)" + description={ + sso + ? `${sso.displayName}. Learners sign in through your identity provider (Okta, Entra ID, Google Workspace and similar).` + : "Let learners sign in through your own identity provider (Okta, Entra ID, Google Workspace and similar)." + } + control={ + <> + {ssoBadge(sso)} + {sso ? ( + + ) : isOwner ? ( + + ) : null} + + } + /> + + + {!isOwner ? ( +

Only the school owner can set up or change SSO.

+ ) : null} + + !open && setConfirmOff(null)}> + + + Turn off {confirmOff ? METHOD_LABELS[confirmOff] : ""}? + + Learners who signed in this way will be signed out. They can sign in again with another method. + + + + + + + + + + {schoolId ? ( + setReauth(true)} + onCreated={(connection) => + router.push(`/settings/login-providers/sso/${encodeURIComponent(connection.id)}`) + } + /> + ) : null} + +
+ ); +} + +function CreateConnectionDialog({ + open, + onOpenChange, + schoolId, + onCreated, + onNeedsSignIn, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + schoolId: string; + onCreated: (connection: SsoConnection) => void; + onNeedsSignIn: () => void; +}) { + const [displayName, setDisplayName] = useState(""); + const [metadataXml, setMetadataXml] = useState(""); + const [entityId, setEntityId] = useState(""); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + async function create() { + if (working) return; + setWorking(true); + setError(null); + try { + const result = await schoolRequest(schoolId, "/api/v1/sso/connections", { + method: "POST", + body: { + displayName: displayName.trim(), + ...(metadataXml.trim() ? { metadataXml: metadataXml.trim() } : { idpEntityId: entityId.trim() }), + }, + }); + if (!result.ok || !result.body) { + if (errorReason(result.body) === RECENT_AUTHENTICATION_REASON) { + onOpenChange(false); + onNeedsSignIn(); + return; + } + setError(ssoErrorMessage(result.body, "We couldn't create the connection.")); + return; + } + onOpenChange(false); + onCreated(result.body); + } finally { + setWorking(false); + } + } + + const ready = displayName.trim().length > 0 && (metadataXml.trim().length > 0 || entityId.trim().length > 0); + + return ( + + + + Set up SSO + + Start with your provider's metadata. You can finish the rest on the next page. + + +
{ + event.preventDefault(); + if (ready) void create(); + }} + > +
+ + setDisplayName(event.target.value)} + /> +

Learners see this on the sign-in button.

+
+
+ +