Render SSO buttons per configured Kratos OIDC provider (todo §4); flow-view collects oidc nodes → auth-card submit buttons, server-side visibility, drop mockup #sso-toggle CSS

This commit is contained in:
2026-06-17 18:20:45 +02:00
parent 0928f9dd39
commit 26a7821611
9 changed files with 54 additions and 15 deletions

View File

@@ -213,6 +213,7 @@ const loginFlow = (id: string): Flow => ({
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
node({ name: "password", required: true, type: "password" }, "Password"),
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
],
},
});
@@ -256,6 +257,9 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /name="password"[^>]*type="password"/);
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google).
assert.match(html, /<div class="sso"/);
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google">.*Sign in with Google<\/span><\/button>/s);
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);

View File

@@ -14,6 +14,7 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
sso: { providers: [
{ label: "Continue with Google", logo: "G" },
{ label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" },
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
] },
body: '<div id="fields">FORM</div><button class="btn btn-primary btn-block">Sign in</button>',
alt: { text: "Don't have an account?", href: "/register", label: "Create one" },
@@ -25,6 +26,8 @@ test("auth-card renders head, SSO providers (text logo + icon link), body slot a
assert.match(html, /<div class="sso" aria-label="Single sign-on options"><ul class="sso-list">/);
assert.match(html, /<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G<\/span><span class="sso-label">Continue with Google<\/span><\/button><\/li>/);
assert.match(html, /<li><a class="sso-btn" href="\/sso\/saml"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"\s*\/?><\/svg><\/span><span class="sso-label">Continue with SAML SSO<\/span><\/a><\/li>/);
// A provider with name/value submits to the form (Kratos OIDC) — type="submit", not a decorative button.
assert.match(html, /<li><button type="submit" class="sso-btn" name="provider" value="microsoft"><span class="sso-logo" aria-hidden="true">M<\/span><span class="sso-label">Sign in with Microsoft<\/span><\/button><\/li>/);
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
// Body slot lands inside .auth-form; alt footer renders text + link.

View File

@@ -43,6 +43,9 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
// One submit button carrying its method name/value.
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
// No OIDC providers configured ⇒ no SSO buttons.
assert.deepEqual(view.sso, []);
// Chrome derived from the flow type.
assert.equal(view.title, "Sign in");
assert.equal(view.alt?.href, "/registration");
@@ -72,15 +75,22 @@ test("maps field errors and flow-level messages by tone", () => {
]);
});
test("skips oidc (SSO) nodes but keeps the default-group csrf — SSO buttons are a later item", () => {
test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf and the password submit separate", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
node({ name: "provider", type: "submit", value: "microsoft" }, { label: "Sign in with Microsoft", group: "oidc" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
// One provider button per oidc node — a submit (name/value) posting to the same Kratos form.
assert.deepEqual(view.sso, [
{ label: "Sign in with Google", logo: "G", name: "provider", value: "google" },
{ label: "Sign in with Microsoft", logo: "M", name: "provider", value: "microsoft" },
]);
// SSO nodes don't leak into hidden/buttons.
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
});

View File

@@ -1,8 +1,8 @@
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, and tone-mapped messages. The form posts
// straight back to `flow.ui.action`, so Kratos owns its CSRF; we only render and map errors.
// SSO/oidc buttons are skipped here — they're derived per provider in the next §4 item.
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
@@ -24,6 +24,14 @@ export interface FlowButton {
value?: string;
}
// An OIDC provider, rendered as a submit button (name/value) posting to the same Kratos form.
export interface SsoProvider {
label: string; // Kratos' own label, e.g. "Sign in with Google"
logo: string; // text logo (provider initial) — lucide ships no brand marks
name: string; // submit field (Kratos: "provider")
value: string; // provider id (Kratos: "google")
}
export interface FlowMessage {
text: string;
tone: "info" | "neg" | "pos" | "warn";
@@ -43,6 +51,7 @@ export interface FlowView extends FlowChrome {
hidden: { name: string; value: string }[];
messages: FlowMessage[];
method: string;
sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section
}
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
@@ -79,6 +88,8 @@ function tone(type: string): FlowMessage["tone"] {
return "info";
}
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
const autocomplete = str(node.attributes["autocomplete"]);
@@ -101,12 +112,19 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = [];
const buttons: FlowButton[] = [];
const sso: SsoProvider[] = [];
for (const node of flow.ui.nodes) {
if (node.type !== "input" || node.group === "oidc") continue; // SSO buttons: next §4 item
if (node.type !== "input") continue;
const name = str(node.attributes["name"]) ?? "";
const inputType = str(node.attributes["type"]) ?? "text";
if (inputType === "hidden") {
if (node.group === "oidc") {
// One submit button per configured provider; posts provider=<value> to the same form.
if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]) ?? "";
sso.push({ label: node.meta.label?.text ?? value, logo: ssoLogo(value), name, value });
}
} else if (inputType === "hidden") {
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
@@ -123,6 +141,7 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
sso,
...CHROME[type],
};
}