Add field + auth-card partials (todo §1); data-driven .field (label/icon/hint/server error) and auth-card shell (head/SSO/body/alt)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
47
src/auth-card.test.ts
Normal file
47
src/auth-card.test.ts
Normal file
@@ -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<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").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: '<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" },
|
||||
}));
|
||||
|
||||
assert.match(html, /<form class="auth-card" method="post" action="\/login"><div class="auth-head"><h1>Sign in<\/h1><p class="auth-sub">Welcome back\.<\/p><\/div>/);
|
||||
|
||||
// SSO: text-logo button vs icon-logo link, then the divider.
|
||||
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>/);
|
||||
assert.match(html, /<\/ul><div class="auth-divider">or<\/div><\/div>/);
|
||||
|
||||
// Body slot lands inside .auth-form; alt footer renders text + link.
|
||||
assert.match(html, /<div class="auth-form"><div id="fields">FORM<\/div><button class="btn btn-primary btn-block">Sign in<\/button><\/div>/);
|
||||
assert.match(html, /<p class="auth-alt">Don't have an account\? <a href="\/register">Create one<\/a><\/p><\/form>/); // apostrophe is escaped
|
||||
});
|
||||
|
||||
test("auth-card renders a back link, omits SSO/alt when absent, escapes title, and never throws", async () => {
|
||||
const back = flat(await render({
|
||||
title: "Reset password", sub: "Enter your email.",
|
||||
back: { href: "/login", label: "Back to sign in" },
|
||||
body: "<button>Send</button>",
|
||||
}));
|
||||
assert.match(back, /<div class="auth-head"><a class="auth-back" href="\/login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"\s*\/?><\/svg>Back to sign in<\/a><h1>Reset password<\/h1>/);
|
||||
assert.doesNotMatch(back, /class="sso"|auth-alt/);
|
||||
|
||||
// Defaults: post method, empty form, escaped title, no throw.
|
||||
assert.match(flat(await render({ title: "<x>" })), /<form class="auth-card" method="post"><div class="auth-head"><h1><x><\/h1><\/div><div class="auth-form"><\/div><\/form>/);
|
||||
assert.match(flat(await render()), /<form class="auth-card" method="post">/);
|
||||
});
|
||||
49
src/field.test.ts
Normal file
49
src/field.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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 field = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "field.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
||||
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
|
||||
test("field renders label, icon input, hint, inline link/optional, and a server-driven error", async () => {
|
||||
// Error field: aria-invalid + aria-describedby wiring, icon, error markup with raw HTML.
|
||||
const errored = flat(await render({
|
||||
id: "reg-email", name: "email", label: "Email", type: "email",
|
||||
autocomplete: "email", placeholder: "you@company.com", required: true, icon: "i-mail",
|
||||
error: { html: 'Already used. <a href="/login">Sign in</a>.' },
|
||||
}));
|
||||
assert.match(errored, /<div class="field has-error"><label for="reg-email">Email<\/label>/);
|
||||
assert.match(errored, /<div class="input-wrap"><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"\s*\/?><\/svg>/);
|
||||
assert.match(errored, /<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-invalid="true" aria-describedby="reg-email-err" required>/);
|
||||
assert.match(errored, /<p class="field-error" id="reg-email-err" role="alert"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"\s*\/?><\/svg><span>Already used\. <a href="\/login">Sign in<\/a>\.<\/span><\/p>/);
|
||||
|
||||
// field-top with an inline link, hint, minlength; no error → no has-error / aria-invalid.
|
||||
const withLink = flat(await render({
|
||||
id: "login-password", name: "password", label: "Password", type: "password",
|
||||
autocomplete: "current-password", placeholder: "••••••••", required: true, minlength: 8, icon: "i-lock",
|
||||
link: { href: "/forgot", label: "Forgot password?" }, hint: "Use 8 or more characters.",
|
||||
}));
|
||||
assert.match(withLink, /<div class="field"><div class="field-top"><label for="login-password">Password<\/label><a class="field-link" href="\/forgot">Forgot password\?<\/a><\/div>/);
|
||||
assert.match(withLink, /<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" minlength="8" required>/);
|
||||
assert.match(withLink, /<span class="field-hint">Use 8 or more characters\.<\/span><\/div>/);
|
||||
assert.doesNotMatch(withLink, /has-error|aria-invalid/);
|
||||
|
||||
// field-top with an "Optional" tag, a value, no icon → plain input.
|
||||
const optional = flat(await render({
|
||||
id: "reg-name", name: "name", label: "Name", optional: true, value: "Avery", autocomplete: "name", placeholder: "Avery Kline",
|
||||
}));
|
||||
assert.match(optional, /<div class="field-top"><label for="reg-name">Name<\/label><span class="optional">Optional<\/span><\/div>/);
|
||||
assert.match(optional, /<div class="input-wrap"><input class="input" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline" value="Avery"><\/div>/);
|
||||
});
|
||||
|
||||
test("field defaults to a bare text input, escapes a string error, and never throws", async () => {
|
||||
const bare = flat(await render({ id: "x", name: "x", label: "X" }));
|
||||
assert.match(bare, /<div class="field"><label for="x">X<\/label><div class="input-wrap"><input class="input" id="x" name="x" type="text"><\/div><\/div>/);
|
||||
|
||||
const stringErr = flat(await render({ id: "x", name: "x", label: "X", error: "<b>Required</b>." }));
|
||||
assert.match(stringErr, /<span><b>Required<\/b>\.<\/span>/); // string error is escaped
|
||||
assert.match(stringErr, /aria-describedby="x-err"/);
|
||||
});
|
||||
2
todo.md
2
todo.md
@@ -32,7 +32,7 @@ everything via Docker.
|
||||
- [x] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). → `views/partials/filter-bar.ejs`: data-driven `<form method="get">` (server-side, zero-JS). `rows: Control[][]`, `type ∈ search|segmented|select|chips|daterange|spacer`, each reflecting current value (checked/selected); plus applied `pills` (+ remove links, Clear all) and Reset/Apply actions. Columns/“more filters” menus deferred to the menu/popover item. `filter-bar.test.ts` covers every type + value reflection + pills + defaults.
|
||||
- [x] Data-table partial — sortable headers, row-select, badges, kebab row actions. → `views/partials/data-table.ejs`: data-driven, zero-JS. `columns` ({ label, sortable, sort, href, className }) render sort as `<a class="th-sort">` + `aria-sort` (links, not the mockup's inert buttons); `selectable`/`actions` toggle the check/kebab columns. `rows` carry typed `cells` (string | text+class | user/avatar | badge tone | raw html) + kebab `actions` (link or danger button, separators). `data-table.test.ts` covers the matrix + minimal/empty defaults.
|
||||
- [x] Pagination partial — rows-per-page + page numbers, query-param driven. → `views/partials/pagination.ejs`: data-driven, zero-JS. `summary {from,to,total}`, rows-per-page GET `<form>` (select + submit, `hidden[]` carries list state), `pages: {label,href?,current?,ellipsis?}[]` (links; current/ellipsis inert), `prev`/`next` (href ⇒ link, omit ⇒ disabled). Reuses the mockup's `.pager` CSS, no changes. `pagination.test.ts` covers the matrix + value reflection + empty defaults.
|
||||
- [ ] Form-field partials (input/label/hint/error) + auth-card partial.
|
||||
- [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `<form class="auth-card">` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults.
|
||||
- [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`).
|
||||
- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree.
|
||||
- [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`.
|
||||
|
||||
31
views/partials/auth-card.ejs
Normal file
31
views/partials/auth-card.ejs
Normal file
@@ -0,0 +1,31 @@
|
||||
<%#
|
||||
Auth card: the <form class="auth-card"> shell — head (back · title · sub),
|
||||
optional SSO providers + divider, a body slot (field partials + submit), alt footer.
|
||||
Mirrors html-css-foundation auth markup. Config:
|
||||
title, sub?
|
||||
method? (default "post"), action?
|
||||
back? { href, label } back link above the title
|
||||
sso? { label?, divider?, providers: Provider[] } omit / empty ⇒ no SSO section
|
||||
Provider: { label, logo? (text) | icon? (sprite id), href? ⇒ <a>, else <button> }
|
||||
body pre-rendered HTML placed inside .auth-form (fields + submit)
|
||||
alt? { text, href, label } centered footer line
|
||||
%><%
|
||||
const method = locals.method || "post";
|
||||
const back = locals.back;
|
||||
const sso = locals.sso;
|
||||
const providers = (sso && sso.providers) || [];
|
||||
const alt = locals.alt;
|
||||
-%>
|
||||
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>>
|
||||
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= back.href %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
||||
<% if (providers.length) { -%>
|
||||
<div class="sso" aria-label="<%= sso.label || "Single sign-on options" %>">
|
||||
<ul class="sso-list"><% providers.forEach((p) => { %><li><% if (p.href) { %><a class="sso-btn" href="<%= p.href %>"><% } else { %><button type="button" class="sso-btn"><% } %><span class="sso-logo" aria-hidden="true"><% if (p.icon) { %><svg class="ico ico-sm"><use href="#<%= p.icon %>"/></svg><% } else { %><%= p.logo %><% } %></span><span class="sso-label"><%= p.label %></span><% if (p.href) { %></a><% } else { %></button><% } %></li><% }) %></ul>
|
||||
<div class="auth-divider"><%= sso.divider || "or" %></div>
|
||||
</div>
|
||||
<% } -%>
|
||||
<div class="auth-form"><%- locals.body || "" %></div>
|
||||
<% if (alt) { -%>
|
||||
<p class="auth-alt"><%= alt.text %> <a href="<%= alt.href %>"><%= alt.label %></a></p>
|
||||
<% } -%>
|
||||
</form>
|
||||
34
views/partials/field.ejs
Normal file
34
views/partials/field.ejs
Normal file
@@ -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";
|
||||
-%>
|
||||
<div class="field<% if (error) { %> has-error<% } %>">
|
||||
<% if (link || optional) { -%>
|
||||
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional">Optional</span><% } %></div>
|
||||
<% } else { -%>
|
||||
<label for="<%= id %>"><%= locals.label %></label>
|
||||
<% } -%>
|
||||
<div class="input-wrap"><% if (icon) { %><svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#<%= icon %>"/></svg><% } %><input class="input<% if (icon) { %> has-ico<% } %>" id="<%= id %>" name="<%= locals.name %>" type="<%= type %>"<% if (locals.autocomplete) { %> 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<% } %>></div>
|
||||
<% if (hint) { -%>
|
||||
<span class="field-hint"><%= hint %></span>
|
||||
<% } -%>
|
||||
<% if (error) { -%>
|
||||
<p class="field-error" id="<%= errId %>" role="alert"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg><span><% if (error.html != null) { %><%- error.html %><% } else { %><%= typeof error === "string" ? error : error.text %><% } %></span></p>
|
||||
<% } -%>
|
||||
</div>
|
||||
Reference in New Issue
Block a user