diff --git a/README.md b/README.md index 20ea436..27727ef 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ you'd rather assemble pages from building blocks than fight a framework or hand- auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out of your domain logic. It's not a no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see -[Auth](#auth-sessions--permissions-planned)) reads as obvious rather than a surprise, +[Auth](#auth-sessions--permissions)) reads as obvious rather than a surprise, you're the audience. ## Project goals @@ -46,16 +46,17 @@ makes **semantic, accessible markup** a priority: real landmarks, one `

` per lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`) only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). -> **Status.** This README describes the target architecture. Built today (see `todo.md`): -> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, -> filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host** -> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the -> **Ory stack** wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL -> namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The **auth** wiring that -> consumes these — and Hydra's login/consent handlers — are the roadmap; sections marked -> _(planned)_ are not built yet. +> **Status.** Nearly all of the architecture this README describes is built today (see `todo.md`): +> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, filters, +> pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the +> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT +> tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset / +> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent +> / logout handlers** — all driven end-to-end by the Playwright suites. What's left is mainly +> **production & ops hardening** (the prod compose profile, security headers, observability, a +> key-rotation runbook) — tracked in `todo.md` (§9). -## The MVP — "clone, one command, hack on a plugin" _(planned)_ +## The MVP — "clone, one command, hack on a plugin" The bar for a first usable release: **clone, run one command, get a working register/login, and start building your own plugin** — no manual key generation, no @@ -83,7 +84,7 @@ the menu and pages by **verifying the JWT in-process, with no per-request call t Ory**. Keto answers the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login & consent provider** for other apps. It reaches the Ory services over their **REST APIs using Node's built-in `fetch`** — no SDK -dependency. See [Auth, sessions & permissions](#auth-sessions--permissions-planned). +dependency. See [Auth, sessions & permissions](#auth-sessions--permissions). So the `web` app is **stateless** and its npm footprint stays tiny — a small, pinned set of runtime deps (today **`ejs`** for templating and **`lucide-static`** @@ -271,6 +272,20 @@ Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user- is covered end-to-end; tests are independent and run **fully in parallel** for speed ([AGENTS.md](AGENTS.md) §6) — keep new tests side-effect-free so the suite stays fast. +### The full gate (one command) + +`scripts/ci.sh` is the whole gate in one reproducible command — typecheck → unit tests → each E2E +suite against its own fresh stack, with a guaranteed `down -v` after each (even on failure) and a +non-zero exit on the first failure. Run it locally before a release, or wire it into your CI service: + +```bash +bash scripts/ci.sh +``` + +Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh revokes +the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the gate runs them +serially, one stack up/down per suite. + ## Building a plugin A plugin is a folder under `plugins/`. The host discovers it at boot — no @@ -385,7 +400,7 @@ The menu is **driven entirely by config** and assembled from two sources: Every nav item may carry a `permission`; the rendered tree is **filtered per user** by reading the roles in the session JWT (no per-request authz call — see -[Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu +[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu only ever shows what that person can reach. The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar @@ -419,7 +434,7 @@ Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla JS) on top of working server-rendered HTML. The baseline never depends on it. -## Auth, sessions & permissions _(planned)_ +## Auth, sessions & permissions Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization in a **locally-validated JWT**, and **Keto** is reserved for the rare @@ -609,7 +624,7 @@ ory/ Ory service config (kratos/: identity schema, kratos.yml, o plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) -e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them +e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full].yml run them html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. ``` diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml index 6112141..f54b061 100644 --- a/compose.e2e-auth.yml +++ b/compose.e2e-auth.yml @@ -6,6 +6,16 @@ # docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after services: web: + # This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap, + # not Hydra. Drop the base web→hydra dep so the leaner stack doesn't boot Hydra (which the e2e + # overlays don't run with --dev, so it would refuse its http issuer and never become healthy). + depends_on: !override + bootstrap: + condition: service_completed_successfully + kratos: + condition: service_healthy + keto: + condition: service_healthy # Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as # expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly. environment: diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts index e542f23..48c5215 100644 --- a/e2e/full-flow.spec.ts +++ b/e2e/full-flow.spec.ts @@ -18,6 +18,7 @@ const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't coll // Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in. async function loginPassword(page: Page): Promise { await page.goto("/login"); + await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset await page.fill('input[name="identifier"]', ADMIN_EMAIL); await page.fill('input[name="password"]', ADMIN_PASSWORD); await page.locator('.auth-form button[type="submit"]').click(); diff --git a/e2e/package.json b/e2e/package.json index 10a36c0..344d1de 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -2,7 +2,7 @@ "name": "plainpages-e2e", "version": "0.1.0", "private": true, - "description": "Playwright visual + functional E2E: live app vs the html-css-foundation design.", + "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).", "type": "module", "scripts": { "test": "playwright test" diff --git a/public/css/auth.css b/public/css/auth.css index a9f2fbd..501c6bc 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -122,6 +122,10 @@ body:has(#forgot:target) #login { display: none; } .auth-alt a, .auth-alt button { color: var(--accent); font-weight: 550; } .auth-alt button { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; } .auth-alt a:hover, .auth-alt button:hover { text-decoration: underline; } +/* "Forgot password?" — a small link under the fields, above the submit button */ +.auth-aside { margin: -4px 0 0; text-align: right; font-size: var(--fz-sm); } +.auth-aside a { color: var(--accent); } +.auth-aside a:hover { text-decoration: underline; } /* legal footnote */ .auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); } diff --git a/public/css/styles.css b/public/css/styles.css index c749b8e..7e72732 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -558,6 +558,7 @@ table.table { .col-num { text-align: right; } .cell-strong { font-weight: 550; } .cell-muted { color: var(--text-muted); } +.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; } .cell-mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); } diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..34d9bcc --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# The full CI gate (todo §8): typecheck → unit tests → every E2E suite, each against a FRESH stack +# that is always torn down. One reproducible command — run it locally or wire it into your CI +# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host). +# +# bash scripts/ci.sh +# +# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at +# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto). +set -euo pipefail +cd "$(dirname "$0")/.." + +step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; } + +# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures. +step "Playwright pin lockstep (Dockerfile.e2e image == e2e/package.json @playwright/test)" +# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report. +img=$(grep -oE 'playwright:v[0-9.]+' Dockerfile.e2e | grep -oE '[0-9.]+$' || true) +pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e/package.json | grep -oE '[0-9.]+' || true) +[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; } +echo "ok ($img)" + +step "Typecheck" +docker compose run --rm --no-deps web npm run typecheck + +step "Unit tests" +units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; } +echo "$units" | grep -E '^. (tests|pass|fail) ' || true +# Sanity floor: catch a glob that matches too few files (a full empty glob already exits non-zero above). +count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true) +[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; } + +# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The +# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite. +e2e() { + step "E2E: $1" + local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name + local rc=0 + docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$? + docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true + [ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; } +} + +e2e compose.e2e.yml # visual / design-system parity (Ory-free) +e2e compose.e2e-auth.yml # token timeout + silent re-mint +e2e compose.e2e-oauth.yml # OAuth2 login + consent +e2e compose.e2e-full.yml # full browser flow: login (password + SSO), menu, CRUD, plugin, logout + +step "ALL GREEN" diff --git a/src/data-table.test.ts b/src/data-table.test.ts index 4aa071a..ff6a9c6 100644 --- a/src/data-table.test.ts +++ b/src/data-table.test.ts @@ -82,3 +82,15 @@ test("data-table renders a minimal table (plain string cells, no select/actions) assert.match(flat(await render()), /<\/tr><\/thead><\/tbody><\/table>/); }); + +test("data-table shows an empty-state row spanning all columns when there are no rows", async () => { + // colspan covers the data columns + the select + actions columns (2 + 1 + 1 = 4). + const html = flat(await render({ actions: true, columns: [{ label: "Name" }, { label: "Email" }], rows: [], selectable: true })); + assert.match(html, /
Nothing here yet\.<\/td><\/tr><\/tbody>/); + + // a caller-supplied message overrides the default + assert.match(flat(await render({ columns: [{ label: "Shift" }], emptyText: "No shifts yet.", rows: [] })), /No shifts yet\.<\/td>/); + + // a populated table has no empty-state row + assert.doesNotMatch(flat(await render({ columns: [{ label: "Name" }], rows: [{ cells: ["A"] }] })), /table-empty/); +}); diff --git a/src/flow-view.test.ts b/src/flow-view.test.ts index 3485c3c..7b9951a 100644 --- a/src/flow-view.test.ts +++ b/src/flow-view.test.ts @@ -49,6 +49,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s // Chrome derived from the flow type. assert.equal(view.title, "Sign in"); assert.equal(view.alt?.href, "/registration"); + assert.equal(view.recoverHref, "/recovery"); // login offers a path to password reset assert.equal(view.messages.length, 0); }); @@ -99,6 +100,7 @@ test("chrome varies per flow type: registration alt, recovery back link", () => const reg = buildFlowView(flow([]), "registration"); assert.equal(reg.title, "Create account"); assert.equal(reg.alt?.href, "/login"); + assert.equal(reg.recoverHref, undefined); // only login shows the reset link const rec = buildFlowView(flow([]), "recovery"); assert.equal(rec.back?.href, "/login"); diff --git a/src/flow-view.ts b/src/flow-view.ts index 8b9bc01..0ddf2c7 100644 --- a/src/flow-view.ts +++ b/src/flow-view.ts @@ -51,6 +51,7 @@ export interface FlowView extends FlowChrome { hidden: { name: string; value: string }[]; messages: FlowMessage[]; method: string; + recoverHref?: string; // login only: a "Forgot password?" link to the recovery flow sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section } @@ -142,6 +143,7 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView { messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })), method: flow.ui.method || "post", sso, + ...(type === "login" ? { recoverHref: "/recovery" } : {}), ...CHROME[type], }; } diff --git a/todo.md b/todo.md index b4a8fe5..f124bd2 100644 --- a/todo.md +++ b/todo.md @@ -120,7 +120,7 @@ everything via Docker. - [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0–§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent). - [x] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. → New browser-UI suite `e2e/full-flow.spec.ts` (`compose.e2e-full.yml`) — the real Playwright UI the earlier full-stack suites deferred here ("browser-UI login is owned by §8"). The themed login form posts straight to Kratos' action and cookies are host-scoped, so a tiny **stdlib reverse proxy** (`e2e/proxy.mjs`) fronts web + Kratos on **one origin** (the browser's only host), exactly like a prod reverse proxy; `ory/kratos/e2e-proxy.yml` points Kratos' base_url + every self-service URL at it, and Kratos runs `--dev` so cookies aren't marked Secure over http (Kratos marks them Secure for a non-loopback host like the gateway). Coverage (6 tests, all green): **password login** (themed form → Kratos → `/auth/complete` → dashboard); **mocked SSO** (a stdlib **mock OIDC provider** `e2e/mock-oidc.mjs` — RS256-signed id_token, nonce-bound single-use codes — wired via `SELFSERVICE_METHODS_OIDC_*` env + the committed claims jsonnet; click the provider button → auto-approve → identity created → signed in); **menu filtering by role** (the admin sees the gated Admin section + the plugin nav; anon/SSO-user don't); **users/groups/roles CRUD** (create → list → delete a user via the confirm step; create a group + role, each with a first member since a Keto set needs ≥1); the **permission-gated plugin page** (`/scheduling/shifts` renders the mock upstream's shifts in the native shell); **logout** (sign-out ends the session → back to /login, admin nav gone). **Found + fixed a real bug the E2E surfaced:** the SSO submit button sits in the same `
` as the `required` email/password fields, so clicking it tripped HTML5 validation ("Please fill out this field") and never submitted — added `formnovalidate` to the SSO buttons (`views/partials/auth-card.ejs`), tests-first (`auth-card.test.ts` + `app.test.ts`); password login still validates (separate button). Stability-reviewer run as a local PR: **APPROVE, no Critical/High** — every dev/insecure knob (`--dev`, `SECURE_COOKIES=false`, the OIDC provider + mock + proxy) is confined to the e2e overlay, base/prod compose unaffected; addressed its top follow-up (documented the file's parallel-safety invariant). typecheck + **305 units** green; full-flow **6/6 green on a clean stack**, then `down -v` torn down. README E2E section + suite count updated. - [x] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. → Delivered by the §8 full-flow harness (`compose.e2e-full.yml`): one `run` brings up the whole stack (Postgres + Kratos + Keto + the one-command **bootstrap** + web + the same-origin gateway + the reference plugin's mock upstream + the mock OIDC provider), the bootstrap **seeds the demo admin identity in Kratos + its Keto roles** (`admin` + the plugin's declared `scheduling:read`/`write` tokens), the browser suite runs against that seeded identity, and `docker compose … down -v` **tears everything down** (run live, 6/6 green, torn down). The §4 auth-refresh + §6 oauth-login suites use the same full-stack-up / seed / tear-down pattern; this completes it for the browser-UI flows. -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §8 Testing & CI surfaces). **Fixed now (tests-first where code):** (1) **CRITICAL (arch)** — §8 is "Testing & **CI**" but the CI automation was missing: the gate was five hand-run commands with manual teardown. Added **`scripts/ci.sh`** — the whole gate in one reproducible command: a Playwright pin-lockstep check (M4) → typecheck → units (with a count-floor guard, L1) → the four E2E suites, each on its **own named fresh stack** with a guaranteed `down -v` and a non-zero exit on first failure (documented in README). **The gate immediately earned its keep:** it surfaced a latent bug — the auth-refresh suite's `web` inherited the §6 `web→hydra` dep, but the e2e overlays don't run Hydra with `--dev`, so Hydra refused its http issuer and never went healthy → the suite couldn't boot. Fixed by dropping Hydra from the auth suite's `web` deps (`!override`, mirroring the full suite — it never needed Hydra). (2) **Product 🔴 (doc correctness)** — the README **Status** note still claimed auth + Hydra handlers were "the roadmap"/unbuilt (materially false after §4/§6/§8); rewrote it to reflect built reality and dropped the now-false `_(planned)_` markers from the **Auth** + **MVP** headings + fixed their anchor links. (3) **Product 🟡** — the recovery flow existed but nothing linked to it from `/login` (an end-user lockout gap): added a login-only **"Forgot password?"** link (`flow-view.ts` `recoverHref` → `flow-body.ejs`, `.auth-aside` CSS), tests-first (flow-view unit + a full-flow E2E assertion). (4) **Product 🟡 (recurring §5/§6/§7)** — empty list tables rendered blank: added an empty-state row to `data-table.ejs` (overridable `emptyText`, colspan over all columns, only when the table has columns) + `.table-empty` CSS — fixes all five list surfaces at once; tests-first (data-table unit). (5) **M3 (docs)** — README Layout `e2e/` line + `e2e/package.json` description updated for the §8 full-flow suite + harness files. Stability-reviewer run as a local PR: **APPROVE (with nits), no Critical/High**; addressed both robustness nits (per-suite compose **project names** so a flaky teardown can't cross-contaminate; `|| true` on the pin/count greps so `set -e`+pipefail doesn't abort before the diagnostic) — and caught a bug they introduced (a `.` in the derived project name is invalid → sanitized with `tr`). **Corrected a reviewer error:** arch claimed the bootstrap service uses `restart: unless-stopped` (infinite crash-loop); it actually uses `restart: "on-failure:5"` (bounded retries for transient Ory-not-ready blips) — defensible, left as-is. typecheck + **306 units** green; **`scripts/ci.sh` green end-to-end** (visual 9 · auth 1 · oauth 2 · full 6, every stack torn down). **Deferred (reviewer-scoped):** the host **internal route-table** (fold the admin/oauth if-ladder in `app.ts` into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` — arch H1, widened by §6/§7) → **§9** (top structural item, raised urgency); **visual-parity (computed-style) checks for the admin + consent screens** (visual.spec only covers the dashboard — arch M2) → §9/polish; a **JWT key-rotation E2E** (L2) → ships with the §9 rotation-runbook item; `shifts.test.ts` deep `src/*` imports → the plugin-api barrel (L3) → the §8 test-cleanup item below; success-flash after writes (product 🟢) → deferred (stateless, known); a **CI-service workflow** (Actions YAML calling `scripts/ci.sh`) → needs the operator's CI platform + Docker-capable runners (the portable gate script is in place). - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. @@ -137,4 +137,4 @@ everything via Docker. ## 10. User added stuff - [ ] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. -- [ ] Make some pages optionally available publicly. \ No newline at end of file +- [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available. \ No newline at end of file diff --git a/views/partials/data-table.ejs b/views/partials/data-table.ejs index 127fbc0..21edd18 100644 --- a/views/partials/data-table.ejs +++ b/views/partials/data-table.ejs @@ -14,6 +14,7 @@ const withActions = !!locals.actions; const columns = locals.columns || []; const rows = locals.rows || []; + const emptyText = locals.emptyText || "Nothing here yet."; // shown when a table that has columns has no rows -%>
@@ -38,6 +39,9 @@ +<% if (rows.length === 0 && columns.length) { -%> + +<% } -%> <% rows.forEach((row) => { -%> <% if (selectable) { -%> diff --git a/views/partials/flow-body.ejs b/views/partials/flow-body.ejs index ff7a952..396b552 100644 --- a/views/partials/flow-body.ejs +++ b/views/partials/flow-body.ejs @@ -12,6 +12,9 @@ <% flow.fields.forEach((field) => { -%> <%- include("field", field) %> <% }) -%> +<% if (flow.recoverHref) { -%> +

Forgot password?

+<% } -%> <% flow.buttons.forEach((b, i) => { -%> <% }) -%>
<%= emptyText %>