diff --git a/README.md b/README.md index 8cdce24..ddb8bb1 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) -views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, icon sprite) +views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (planned) plugins/ Drop-in plugin folders, auto-discovered (planned) diff --git a/public/css/auth.css b/public/css/auth.css index 3805c1f..89dfde3 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -195,6 +195,7 @@ body:has(#forgot:target) .tpl-forgot { display: flex; } display: none; align-items: center; gap: 6px; margin: 0; font-size: var(--fz-xs); color: var(--neg); } +.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */ .field-error > .ico { flex: 0 0 auto; color: var(--neg); } .field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; } diff --git a/src/auth-card.test.ts b/src/auth-card.test.ts new file mode 100644 index 0000000..614094e --- /dev/null +++ b/src/auth-card.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import * as ejs from "ejs"; + +const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "auth-card.ejs"); +const render = (data: Record = {}): Promise => ejs.renderFile(authCard, data); +const flat = (s: string): string => s.replace(/>\s+<").replace(/\s+/g, " ").trim(); + +test("auth-card renders head, SSO providers (text logo + icon link), body slot and alt footer", async () => { + const html = flat(await render({ + title: "Sign in", sub: "Welcome back.", action: "/login", + sso: { providers: [ + { label: "Continue with Google", logo: "G" }, + { label: "Continue with SAML SSO", icon: "i-shield", href: "/sso/saml" }, + ] }, + body: '
FORM
', + alt: { text: "Don't have an account?", href: "/register", label: "Create one" }, + })); + + assert.match(html, /

Sign in<\/h1>

Welcome back\.<\/p><\/div>/); + + // SSO: text-logo button vs icon-logo link, then the divider. + assert.match(html, /

+
<%= sso.divider || "or" %>
+
+<% } -%> +
<%- locals.body || "" %>
+<% if (alt) { -%> +

<%= alt.text %> <%= alt.label %>

+<% } -%> + diff --git a/views/partials/field.ejs b/views/partials/field.ejs new file mode 100644 index 0000000..bb23899 --- /dev/null +++ b/views/partials/field.ejs @@ -0,0 +1,34 @@ +<%# + Form field: label (+ inline link / "Optional") · icon input · hint · error. + Mirrors html-css-foundation auth markup. Config: + id, name, label + type? (default "text"), value?, placeholder?, autocomplete?, required?, minlength? + icon? input-ico id (e.g. "i-mail") → left-padded input + optional? show an "Optional" tag in the label row + link? { href, label } inline beside the label (e.g. Forgot password?) + hint? muted helper text under the input + error? string | { text } | { html } — server-driven; sets aria-invalid + describedby +%><% + const id = locals.id; + const type = locals.type || "text"; + const icon = locals.icon; + const optional = !!locals.optional; + const link = locals.link; + const hint = locals.hint; + const error = locals.error; + const errId = id + "-err"; +-%> +
+<% if (link || optional) { -%> +
<% if (link) { %><%= link.label %><% } else { %>Optional<% } %>
+<% } else { -%> + +<% } -%> +
<% if (icon) { %><% } %> autocomplete="<%= locals.autocomplete %>"<% } %><% if (locals.placeholder) { %> placeholder="<%= locals.placeholder %>"<% } %><% if (locals.value != null) { %> value="<%= locals.value %>"<% } %><% if (locals.minlength != null) { %> minlength="<%= locals.minlength %>"<% } %><% if (error) { %> aria-invalid="true" aria-describedby="<%= errId %>"<% } %><% if (locals.required) { %> required<% } %>>
+<% if (hint) { -%> + <%= hint %> +<% } -%> +<% if (error) { -%> + +<% } -%> +