Use one verb per action in the English UI: sign in, sign out, create account
CI / full-gate (push) Successful in 2m39s
CI / full-gate (push) Successful in 2m39s
This commit is contained in:
@@ -275,6 +275,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
|||||||
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
|
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
|
||||||
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
|
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
|
||||||
in words; handlers pass `ctx.t`.
|
in words; handlers pass `ctx.t`.
|
||||||
|
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
|
||||||
|
"log out" or "sign up" — a second spelling for one button reads as a second thing. Guarded by
|
||||||
|
`src/i18n/load.test.ts` (a test file cannot live in `locales/`, which loads every `.ts` in it as a
|
||||||
|
catalog); the noun ("a sign-in error") is unaffected. A plugin's catalog
|
||||||
|
and every other locale follow the same one-verb-per-action rule in their own language.
|
||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||||
and not "ids=x,y".
|
and not "ids=x,y".
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
|||||||
|
|
||||||
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
|
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
|
// Open the app at the URL the first-run banner prints, then follow the landing's "Sign in" action.
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("link", { name: "Log in" }).click();
|
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
|
||||||
await signIn(page);
|
await signIn(page);
|
||||||
|
|
||||||
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
|
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
|
||||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
|
||||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
|
||||||
|
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||||
await shot(page, "live-05-public-landing");
|
await shot(page, "live-05-public-landing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
|||||||
const rule = "─".repeat(58);
|
const rule = "─".repeat(58);
|
||||||
return [
|
return [
|
||||||
`┌${rule}`,
|
`┌${rule}`,
|
||||||
`│ Plainpages is ready — log in at ${opts.appUrl}`,
|
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
|
||||||
`│ email: ${opts.email}`,
|
`│ email: ${opts.email}`,
|
||||||
`│ password: ${opts.password}`,
|
`│ password: ${opts.password}`,
|
||||||
`│ ⚠ Demo admin credentials — change them before production.`,
|
`│ ⚠ Demo admin credentials — change them before production.`,
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ test("the shipped core catalogs load and agree key for key", async () => {
|
|||||||
assert.ok(loaded.available.includes("sv-SE"));
|
assert.ok(loaded.available.includes("sv-SE"));
|
||||||
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
|
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
|
||||||
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
|
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
|
||||||
|
|
||||||
|
// One verb per action: sign in / sign out / create account. Two spellings for one button ("Log in"
|
||||||
|
// on the landing, "Sign in" in the sidebar) read as two different things. Nouns ("a sign-in error")
|
||||||
|
// are fine — only the competing verbs are out. AGENTS.md → Rules.
|
||||||
|
const competing = /\b(log[\s-]?in|log[\s-]?out|sign[\s-]?up)\b/i;
|
||||||
|
const offenders = Object.entries(loaded.core.get("en-US") ?? {})
|
||||||
|
.map(([key, message]) => [key, typeof message === "string" ? message : Object.values(message).join(" ")] as const)
|
||||||
|
.filter(([, text]) => competing.test(text))
|
||||||
|
.map(([key, text]) => `${key}: ${text}`);
|
||||||
|
assert.deepEqual(offenders, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
|
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const messages = {
|
|||||||
// "Email" on the login form and "First name" on a registration form with that trait, so it can
|
// "Email" on the login form and "First name" on a registration form with that trait, so it can
|
||||||
// only be translated per field (auth.field.* above), never per id.
|
// only be translated per field (auth.field.* above), never per id.
|
||||||
"kratos.1010022": "Sign in with password",
|
"kratos.1010022": "Sign in with password",
|
||||||
"kratos.1040001": "Sign up",
|
"kratos.1040001": "Create account",
|
||||||
"kratos.1060003":
|
"kratos.1060003":
|
||||||
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
|
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
|
||||||
"kratos.1070008": "Resend code",
|
"kratos.1070008": "Resend code",
|
||||||
@@ -127,7 +127,7 @@ const messages = {
|
|||||||
"landing.lead":
|
"landing.lead":
|
||||||
"{{brand}} is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.",
|
"{{brand}} is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.",
|
||||||
"landing.register": "Create account",
|
"landing.register": "Create account",
|
||||||
"landing.signIn": "Log in",
|
"landing.signIn": "Sign in",
|
||||||
"landing.title": "Operational web apps, without the boilerplate.",
|
"landing.title": "Operational web apps, without the boilerplate.",
|
||||||
|
|
||||||
"locale.label": "Language",
|
"locale.label": "Language",
|
||||||
@@ -137,7 +137,7 @@ const messages = {
|
|||||||
|
|
||||||
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
|
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
|
||||||
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
|
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
|
||||||
"oauth.logoutExpired": "This logout request has expired. Please start again from the application you were signing out of.",
|
"oauth.logoutExpired": "This sign-out request has expired. Please start again from the application you were signing out of.",
|
||||||
|
|
||||||
"pagination.go": "Go",
|
"pagination.go": "Go",
|
||||||
"pagination.label": "Pagination",
|
"pagination.label": "Pagination",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
## Unfinnished work
|
## Unfinnished work
|
||||||
|
|
||||||
- [ ] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language.
|
|
||||||
- [ ] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it.
|
- [ ] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it.
|
||||||
- [ ] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something?
|
- [ ] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something?
|
||||||
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
|
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
|
||||||
@@ -46,6 +45,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
|
|||||||
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
||||||
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
||||||
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
||||||
|
- [x] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says **sign in / sign out / create account** everywhere; Swedish was already uniform. Three outliers went: the landing's `landing.signIn` "Log in", the registration submit `kratos.1040001` "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and `oauth.logoutExpired`'s "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too. Guarded in `src/i18n/load.test.ts`, which fails on a competing verb in any shipped core English value — it lives there because `locales/` loads every `.ts` in it as a catalog — and recorded in AGENTS.md → Rules; the two e2e specs that clicked "Log in" now scope to `#main-content`, since the anonymous sidebar carries a "Sign in" link of its own.)
|
||||||
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
|
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|||||||
Reference in New Issue
Block a user