create-astram-app 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/CLAUDE.md +2 -2
- package/template/README.md +9 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +7 -1
- package/template/frontend/src/auth/confirmEmailLink.js +51 -0
- package/template/frontend/src/auth/confirmEmailLink.test.mjs +108 -0
- package/template/frontend/src/auth/deviceEnrollment.js +33 -0
- package/template/frontend/src/auth/deviceEnrollment.test.mjs +56 -0
- package/template/frontend/src/auth/profileEdit.js +58 -0
- package/template/frontend/src/auth/profileEdit.test.mjs +135 -0
- package/template/frontend/src/pages/ConfirmEmail.jsx +123 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +102 -20
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +39 -4
- package/template/frontend/vite.config.js +21 -1
- package/template/server/_env.example +8 -0
- package/template/server/eslint.config.mjs +39 -0
- package/template/server/package.json +4 -4
- package/template/server/src/app.module.ts +3 -1
package/README.md
CHANGED
|
@@ -72,6 +72,16 @@ The wall (`eslint.config.mjs`, plus the `lint:wall` grep for `/internal` and
|
|
|
72
72
|
deliberate bypass (`.call`/`.apply`, a computed `pool[m]()`, an aliased `require`) is a
|
|
73
73
|
code-review matter.
|
|
74
74
|
|
|
75
|
+
The wall also flags uuid-named route and body inputs (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) that are
|
|
76
|
+
read without `UuidPipe` (`@Param('uuid', UuidPipe)`) or `uuidField` / `optionalUuidField` /
|
|
77
|
+
`uuidArrayField` from `@astram/guard`. The database matches uuids ignoring case, trailing spaces and
|
|
78
|
+
character width, so a raw one would slip past exact checks in your code. The rule only matches
|
|
79
|
+
those names: a uuid input under another name (`@Param('target')`), a destructured body, a body
|
|
80
|
+
bound to another variable name, a value read off `req`, or a param given any other pipe is not
|
|
81
|
+
flagged, so a clean lint run does not prove every uuid input
|
|
82
|
+
is canonicalised. Put `UuidPipe` on every uuid route param, whatever its name, and a uuid helper
|
|
83
|
+
on every uuid body or query field.
|
|
84
|
+
|
|
75
85
|
## The frontend (`--with-frontend` / `astram-add-frontend`)
|
|
76
86
|
|
|
77
87
|
A Vite + React + shadcn/ui app whose views you own and edit; the auth and API logic comes from
|
|
@@ -80,7 +90,14 @@ A Vite + React + shadcn/ui app whose views you own and edit; the auth and API lo
|
|
|
80
90
|
|
|
81
91
|
- **Sign-in flows:** login, MFA challenge (TOTP or a recovery code), forced MFA re-enrolment
|
|
82
92
|
after a recovery-code login, forgot password, reset password.
|
|
83
|
-
- **Account:** profile, change password, two-factor devices and recovery codes.
|
|
93
|
+
- **Account:** profile, change password, two-factor devices and recovery codes. Changing the
|
|
94
|
+
email asks for the current password (and an authenticator or recovery code when MFA is on);
|
|
95
|
+
the server mails a link to the new address and the email changes only when it is opened.
|
|
96
|
+
- **Email confirmation:** the public `/confirm-email` page opens that link
|
|
97
|
+
(`/confirm-email#t=…`). The token is in the URL fragment; the page removes it from the address
|
|
98
|
+
bar and history at once, sets `Referrer-Policy: no-referrer` (a `<meta>` tag while mounted, and
|
|
99
|
+
a header from `npm run dev` / `npm run preview`), and posts it once. In production, also send
|
|
100
|
+
`Referrer-Policy: no-referrer` for `/confirm-email` from whatever serves the built frontend.
|
|
84
101
|
- **Administration** (shown only to users whose roles grant it): companies, users (memberships
|
|
85
102
|
and role postings), roles, access (role grants and access bundles), record policies (guided rule
|
|
86
103
|
builder), service accounts (roles, scope, API keys with an explicit expiry), cross-tenant grants
|
|
@@ -126,6 +143,12 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
126
143
|
an `EmailSender` (`send({ to, subject, html, text })`, e.g. SMTP or SendGrid) or
|
|
127
144
|
`emailSender: 'disabled'` (no reset emails; `POST /auth/forgot-password` still answers 204).
|
|
128
145
|
The dev logger, which prints reset links, is never used in production.
|
|
146
|
+
- **`RESET_LINK_BASE_URL` and `EMAIL_CHANGE_LINK_BASE_URL`.** The public URLs of your
|
|
147
|
+
reset-password and confirm-email pages (e.g. `https://app.example.com/reset-password` and
|
|
148
|
+
`https://app.example.com/confirm-email`); `ShieldModule.forRoot({ emailChangeLinkBaseUrl })`
|
|
149
|
+
wins over the env var. Outside `NODE_ENV=development|test`, with an `emailSender`, boot fails
|
|
150
|
+
when either is unset, not a URL, or points at `localhost`, `127.0.0.1`, `::1` or `0.0.0.0`
|
|
151
|
+
(`emailSender: 'disabled'` mails no links, so it skips this check).
|
|
129
152
|
- **Explicit `NODE_ENV`.** Set `NODE_ENV=production`. `npm run dev` sets `development` for you;
|
|
130
153
|
`npm start` sets nothing. An unset `NODE_ENV` counts as production for the email rule, and the
|
|
131
154
|
framework's dev seed migrations run only under `development` (or `ASTRAM_SEED_DEV=1`).
|
|
@@ -193,6 +216,41 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
193
216
|
in-flight `/auth/refresh` per session). Concurrent refreshes of the same token can trip reuse
|
|
194
217
|
detection, which revokes the session (the user must sign in again).
|
|
195
218
|
|
|
219
|
+
## Upgrading from 0.8.x
|
|
220
|
+
|
|
221
|
+
create-astram-app 0.9.0 adds one lint rule (KI #82) and the verified email change (KI #14).
|
|
222
|
+
Bump the ranges in `server/package.json` to `@astram/identity` ^0.8.0, `@astram/guard` ^0.11.0,
|
|
223
|
+
`@astram/engine` ^0.5.2 and `@astram/guide` ^0.2.3, and `frontend/package.json` to
|
|
224
|
+
`@astram/frontend-core` ^0.6.0, then `npm install` in both and run the migrations (identity
|
|
225
|
+
`1710000000020`) with the DDL account before starting the new version.
|
|
226
|
+
|
|
227
|
+
**Lint rule (KI #82):** copy `server/eslint.config.mjs` from a fresh scaffold, run `npm run lint`,
|
|
228
|
+
and put every flagged uuid input through `UuidPipe` (route params) or `uuidField` /
|
|
229
|
+
`optionalUuidField` / `uuidArrayField` (body and query fields).
|
|
230
|
+
|
|
231
|
+
**Verified email change (KI #14) — BREAKING in `@astram/guard` 0.11, `@astram/identity` 0.8:**
|
|
232
|
+
|
|
233
|
+
| Change | What you do |
|
|
234
|
+
|---|---|
|
|
235
|
+
| **BREAKING:** `PATCH /account/profile` with an `email` needs `currentPassword` (and `code` — TOTP or a recovery code — when the user has MFA) and answers **202** `{ pendingEmail: true, profile }` instead of 200. The address changes only after the user opens the link sent to the NEW address. A name-only edit still answers 200. | Send `currentPassword` (+ `code`) with an email change and treat 202 as "check your new inbox". Errors: 400 `invalid_current_password` / `mfa_required` / `invalid_request`, 422 `invalid_email` / `no_changes`, 429 `too_many_requests`, 503 `authentication_unavailable`. |
|
|
236
|
+
| **BREAKING:** adding an MFA device when one is already confirmed needs `code`: `POST /account/mfa/devices/begin` `{ name?, code }` takes a TOTP from an existing device or a recovery code; 400 `mfa_required` without it, 400 `invalid_request` when wrong, 429 / 503. A recovery-login re-enroll session still adds a device without a code. With MFA on, confirm accepts only a device begun in the last 15 minutes (404 otherwise). | Ask for a current code on the add-device form when the user already has a device and send it with `accountService.beginMfaDevice(token, name, code)` (`reason: 'mfa_required'` → ask for it). Scaffolded apps: copy `frontend/src/pages/account/TwoFactorPage.jsx` and `frontend/src/auth/deviceEnrollment.js` (+ its `*.test.mjs`) from a fresh scaffold. |
|
|
237
|
+
| New route `POST /auth/confirm-email` `{ token }` (public): 200 `{ ok: true }`, 400 `invalid_token`, 409 `email_unavailable`, 429, 503. On success every other session of the user is signed out and the old address gets a notice. | Add a confirm page that reads the token from the URL fragment (`#t=…`), POSTs it, and sets `Referrer-Policy: no-referrer`. |
|
|
238
|
+
| New option `ShieldModule.forRoot({ emailChangeLinkBaseUrl })`, default `http://localhost:5173/confirm-email`. The mailed link is `${emailChangeLinkBaseUrl}#t=<token>`. | Set it to your confirm page URL in every non-local environment. |
|
|
239
|
+
| Identity migration `1710000000020-CreateEmailChangeToken` (`shield_identity.email_change_token`, FK to `shield_app.users`). | Run the migrations (DDL account) before starting the new version. |
|
|
240
|
+
| A password reset or change cancels a pending email change. An admin email change (`PUT /identity/users/:uuid`) refuses an address another user or login already has (409), rejects a malformed one (400), and moves the login key when it equalled the old email. | Nothing, unless your admin UI sends an empty email to clear it (now 400). |
|
|
241
|
+
|
|
242
|
+
**Frontend for the email change:** `PATCH /account/profile` with an `email` now needs
|
|
243
|
+
`currentPassword` (plus `code` when MFA is enrolled) and answers 202; the email changes only when
|
|
244
|
+
the user opens the link mailed to the new address. An app scaffolded earlier still saves names,
|
|
245
|
+
but its profile page cannot change the email. Update `@astram/frontend-core` to 0.6 and copy from
|
|
246
|
+
a fresh scaffold: `frontend/src/pages/account/ProfilePage.jsx`, `frontend/src/pages/ConfirmEmail.jsx`,
|
|
247
|
+
`frontend/src/auth/profileEdit.js`, `frontend/src/auth/confirmEmailLink.js` (and their
|
|
248
|
+
`*.test.mjs`), the `/confirm-email` route in `frontend/src/App.jsx`, and the
|
|
249
|
+
`confirmEmailNoReferrer` plugin in `frontend/vite.config.js`. If you build your own UI, use
|
|
250
|
+
`accountService.updateProfile(token, { email, currentPassword, code })` (202 →
|
|
251
|
+
`{ ok: true, pendingEmail: true }`, `reason: 'mfa_required'` → ask for a code) and
|
|
252
|
+
`authService.confirmEmail(token)`.
|
|
253
|
+
|
|
196
254
|
## Upgrading from 0.7.x
|
|
197
255
|
|
|
198
256
|
create-astram-app 0.8 pairs with `@astram/identity` 0.7 and `@astram/guard` 0.10 (breaking wiring
|
package/dist/index.js
CHANGED
|
@@ -258,7 +258,8 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
258
258
|
console.log(' • .astram/guide/ — your own, upgrade-safe project-rules space (ADR-0025).');
|
|
259
259
|
console.log('\n Login, sessions, MFA and the identity admin are built in: enable them in');
|
|
260
260
|
console.log(' server/src/app.module.ts (see its comment block). Before production, read the');
|
|
261
|
-
console.log(' README "Production requirements" (emailSender, NODE_ENV, TRUST_PROXY,
|
|
261
|
+
console.log(' README "Production requirements" (emailSender, link URLs, NODE_ENV, TRUST_PROXY,');
|
|
262
|
+
console.log(' migrations).');
|
|
262
263
|
console.log('\nNext steps:');
|
|
263
264
|
console.log(` cd ${appName}/server`);
|
|
264
265
|
console.log(' npm install');
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
|
|
6
6
|
"type": "module",
|
package/template/CLAUDE.md
CHANGED
|
@@ -11,8 +11,8 @@ The Astram framework ships its hard rules + capabilities in the installed
|
|
|
11
11
|
copies — they are regenerated from the framework package on every install/upgrade):
|
|
12
12
|
|
|
13
13
|
- **`.claude/rules/astram-framework-rules.md`** — the non-negotiable rules: never edit
|
|
14
|
-
the framework, all data through the RecordEngine pipe, classify every route,
|
|
15
|
-
**STOP-and-flag a gap** instead of working around it, fail closed, and — after any
|
|
14
|
+
the framework, all data through the RecordEngine pipe, classify every route, validate
|
|
15
|
+
uuid inputs with `UuidPipe` / `uuidField`, **STOP-and-flag a gap** instead of working around it, fail closed, and — after any
|
|
16
16
|
framework upgrade — **re-read these rules first** (Rule 0).
|
|
17
17
|
- **`.claude/rules/astram-framework-capabilities.md`** — what the framework provides
|
|
18
18
|
this version (check here before building; if it's not there, it's a gap → flag it).
|
package/template/README.md
CHANGED
|
@@ -79,6 +79,9 @@ the credential store, MFA, session handling and the decision authority; you writ
|
|
|
79
79
|
|
|
80
80
|
- Pass an `emailSender` (or `'disabled'`): outside `NODE_ENV=development|test` boot fails
|
|
81
81
|
without one. Set `NODE_ENV=production` explicitly; `npm start` does not set it.
|
|
82
|
+
- Set `RESET_LINK_BASE_URL` and `EMAIL_CHANGE_LINK_BASE_URL` to your public reset-password and
|
|
83
|
+
confirm-email pages: with an `emailSender`, production boot fails when either is unset or
|
|
84
|
+
points at localhost.
|
|
82
85
|
- Set `TRUST_PROXY` to your real proxy hop count, or the per-IP login/reset limits stay off.
|
|
83
86
|
- Set `REFRESH_HMAC_KEY` (a long random secret, never committed) and `REDIS_URL`. Redis is
|
|
84
87
|
required: the request path reads only Redis and fails closed when it is down.
|
|
@@ -100,7 +103,12 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
|
|
|
100
103
|
4. **Data access goes through the framework's RecordEngine** — never a raw DB pool.
|
|
101
104
|
The `server/eslint.config.mjs` wall fails lint on off-road DB access anywhere in
|
|
102
105
|
`src/**` except `app.module.ts` and `main.ts`.
|
|
103
|
-
5.
|
|
106
|
+
5. **Canonicalise every uuid input** with `@astram/guard`: `@Param('uuid', UuidPipe)` on EVERY
|
|
107
|
+
uuid route param, whatever its name, and `uuidField` / `optionalUuidField` / `uuidArrayField`
|
|
108
|
+
on every uuid body or query field. The database matches uuids ignoring case, trailing spaces
|
|
109
|
+
and width, so a raw one slips past exact checks. The lint rule only flags uuid-looking names
|
|
110
|
+
(`id`, `uuid`, `…Id(s)`, `…Uuid(s)`); it is a nudge, not a guarantee.
|
|
111
|
+
6. Add a test (see `server/test/` for the pattern).
|
|
104
112
|
|
|
105
113
|
`server/src/features/` starts empty — it's your space.
|
|
106
114
|
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* /login, /forgot-password → public auth screens (redirect to /dashboard if
|
|
5
5
|
* already signed in)
|
|
6
|
-
* /
|
|
6
|
+
* /confirm-email → public for everyone (the email-change link, KI #14)
|
|
7
|
+
* /dashboard → protected (RequireAuth bounces to /login, saving the
|
|
7
8
|
* return path)
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -16,6 +17,8 @@ import { decideRoute, REENROLL_PATH } from './auth/routeGate';
|
|
|
16
17
|
import Login from './pages/Login';
|
|
17
18
|
import ForgotPassword from './pages/ForgotPassword';
|
|
18
19
|
import ResetPassword from './pages/ResetPassword';
|
|
20
|
+
import ConfirmEmail from './pages/ConfirmEmail';
|
|
21
|
+
import { CONFIRM_EMAIL_PATH } from './auth/confirmEmailLink';
|
|
19
22
|
import Dashboard from './pages/Dashboard';
|
|
20
23
|
import AccountLayout from './pages/account/AccountLayout';
|
|
21
24
|
import ProfilePage from './pages/account/ProfilePage';
|
|
@@ -137,6 +140,9 @@ export default function App() {
|
|
|
137
140
|
</PublicOnly>
|
|
138
141
|
}
|
|
139
142
|
/>
|
|
143
|
+
{/* Email-change confirmation (KI #14): public for everyone, signed in or not. NOT
|
|
144
|
+
PublicOnly — bouncing a signed-in user to /dashboard would drop the `#t=` token. */}
|
|
145
|
+
<Route path={CONFIRM_EMAIL_PATH} element={<ConfirmEmail />} />
|
|
140
146
|
<Route
|
|
141
147
|
path="/dashboard"
|
|
142
148
|
element={
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email-change confirmation link, kept free of React so `node --test` can check it (KI #14).
|
|
3
|
+
*
|
|
4
|
+
* The server mails `<origin>/confirm-email#t=<token>` to the NEW address. The token sits in the
|
|
5
|
+
* URL FRAGMENT, which browsers never send to a server or put in a Referer header. The page
|
|
6
|
+
* still removes it from the address bar and history the moment it has read it, and sets
|
|
7
|
+
* `Referrer-Policy: no-referrer` while it is shown. Never log, store or put the token in a query.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The public route the link opens. */
|
|
11
|
+
export const CONFIRM_EMAIL_PATH = '/confirm-email';
|
|
12
|
+
|
|
13
|
+
// url-safe token alphabet (base64url / hex); anything else is not a link the server minted.
|
|
14
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_-]{1,512}$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `t` value of a location fragment (`#t=…`), or '' when missing or malformed.
|
|
18
|
+
* @param {string | null | undefined} hash `window.location.hash`
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function readConfirmToken(hash) {
|
|
22
|
+
if (typeof hash !== 'string' || hash.length < 2) return '';
|
|
23
|
+
const token = new URLSearchParams(hash.replace(/^#/, '')).get('t') ?? '';
|
|
24
|
+
return TOKEN_SHAPE.test(token) ? token : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drop the fragment from the CURRENT history entry (no new entry, router state kept), so the
|
|
29
|
+
* token does not stay in the address bar, the back/forward list or a bookmark.
|
|
30
|
+
* @param {{ location: { pathname: string, search: string, hash: string },
|
|
31
|
+
* history: { state: unknown, replaceState: Function } }} win `window`
|
|
32
|
+
* @returns {boolean} true when a fragment was removed
|
|
33
|
+
*/
|
|
34
|
+
export function scrubConfirmToken(win) {
|
|
35
|
+
if (!win.location.hash) return false;
|
|
36
|
+
win.history.replaceState(win.history.state, '', win.location.pathname + win.location.search);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Add `<meta name="referrer" content="no-referrer">` for as long as the page is mounted.
|
|
42
|
+
* @param {Document} doc
|
|
43
|
+
* @returns {() => void} removes the tag again
|
|
44
|
+
*/
|
|
45
|
+
export function applyNoReferrer(doc) {
|
|
46
|
+
const meta = doc.createElement('meta');
|
|
47
|
+
meta.setAttribute('name', 'referrer');
|
|
48
|
+
meta.setAttribute('content', 'no-referrer');
|
|
49
|
+
doc.head.appendChild(meta);
|
|
50
|
+
return () => doc.head.removeChild(meta);
|
|
51
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// confirmEmailLink.test.mjs — KI #14: the email-change link carries its single-use token in
|
|
2
|
+
// the URL FRAGMENT (`/confirm-email#t=…`) so it never reaches a server log or a Referer. The
|
|
3
|
+
// page must read it, then drop it from the address bar and history at once.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/confirmEmailLink.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import {
|
|
9
|
+
CONFIRM_EMAIL_PATH,
|
|
10
|
+
readConfirmToken,
|
|
11
|
+
scrubConfirmToken,
|
|
12
|
+
applyNoReferrer,
|
|
13
|
+
} from './confirmEmailLink.js';
|
|
14
|
+
|
|
15
|
+
const TOKEN = ['placeholder', 'link', 'value'].join('_');
|
|
16
|
+
|
|
17
|
+
describe('readConfirmToken', () => {
|
|
18
|
+
test('reads t from the fragment', () => {
|
|
19
|
+
assert.equal(readConfirmToken(`#t=${TOKEN}`), TOKEN);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('ignores other fragment keys and order', () => {
|
|
23
|
+
assert.equal(readConfirmToken(`#x=1&t=${TOKEN}`), TOKEN);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('missing, empty or no fragment → empty string', () => {
|
|
27
|
+
for (const hash of ['', '#', '#t=', '#token=abc', undefined, null]) {
|
|
28
|
+
assert.equal(readConfirmToken(hash), '');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a token with characters outside the url-safe set is refused', () => {
|
|
33
|
+
assert.equal(readConfirmToken('#t=abc%20def'), '');
|
|
34
|
+
assert.equal(readConfirmToken('#t=<script>'), '');
|
|
35
|
+
assert.equal(readConfirmToken(`#t=${'a'.repeat(600)}`), '');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('the page path is /confirm-email', () => {
|
|
39
|
+
assert.equal(CONFIRM_EMAIL_PATH, '/confirm-email');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('scrubConfirmToken', () => {
|
|
44
|
+
function fakeWindow(url) {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
const calls = [];
|
|
47
|
+
return {
|
|
48
|
+
calls,
|
|
49
|
+
location: { pathname: u.pathname, search: u.search, hash: u.hash },
|
|
50
|
+
history: {
|
|
51
|
+
state: { idx: 3, key: 'k' },
|
|
52
|
+
replaceState: (state, title, next) => calls.push({ state, title, next }),
|
|
53
|
+
pushState: () => assert.fail('must not add a history entry'),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('replaces the current entry with the same path minus the fragment, keeping router state', () => {
|
|
59
|
+
const win = fakeWindow(`http://app.test/confirm-email?x=1#t=${TOKEN}`);
|
|
60
|
+
assert.equal(scrubConfirmToken(win), true);
|
|
61
|
+
assert.equal(win.calls.length, 1);
|
|
62
|
+
assert.equal(win.calls[0].next, '/confirm-email?x=1');
|
|
63
|
+
assert.deepEqual(win.calls[0].state, { idx: 3, key: 'k' });
|
|
64
|
+
assert.ok(!JSON.stringify(win.calls).includes(TOKEN));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('never moves the token into the query string', () => {
|
|
68
|
+
const win = fakeWindow(`http://app.test/confirm-email#t=${TOKEN}`);
|
|
69
|
+
scrubConfirmToken(win);
|
|
70
|
+
assert.equal(win.calls[0].next, '/confirm-email');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('nothing to scrub → no history call', () => {
|
|
74
|
+
const win = fakeWindow('http://app.test/confirm-email');
|
|
75
|
+
assert.equal(scrubConfirmToken(win), false);
|
|
76
|
+
assert.equal(win.calls.length, 0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('applyNoReferrer', () => {
|
|
81
|
+
function fakeDocument() {
|
|
82
|
+
const children = [];
|
|
83
|
+
return {
|
|
84
|
+
children,
|
|
85
|
+
head: {
|
|
86
|
+
appendChild: (el) => children.push(el),
|
|
87
|
+
removeChild: (el) => children.splice(children.indexOf(el), 1),
|
|
88
|
+
},
|
|
89
|
+
createElement: (tag) => ({
|
|
90
|
+
tag,
|
|
91
|
+
attrs: {},
|
|
92
|
+
setAttribute(k, v) {
|
|
93
|
+
this.attrs[k] = v;
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('adds <meta name="referrer" content="no-referrer"> and the cleanup removes it', () => {
|
|
100
|
+
const doc = fakeDocument();
|
|
101
|
+
const cleanup = applyNoReferrer(doc);
|
|
102
|
+
assert.equal(doc.children.length, 1);
|
|
103
|
+
assert.equal(doc.children[0].tag, 'meta');
|
|
104
|
+
assert.deepEqual(doc.children[0].attrs, { name: 'referrer', content: 'no-referrer' });
|
|
105
|
+
cleanup();
|
|
106
|
+
assert.equal(doc.children.length, 0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding an authenticator device, kept free of React so `node --test` can check it.
|
|
3
|
+
*
|
|
4
|
+
* When the account already has a confirmed device, the server needs a code from one of them (or
|
|
5
|
+
* a recovery code) before it starts enrolling another, so a stolen session alone cannot add the
|
|
6
|
+
* attacker's app (KI #14 re-review). The recovery-login re-enroll screen is exempt: that user
|
|
7
|
+
* signed in with a recovery code because the device is lost. The code is sent once, never stored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{ deviceCount: number, reenrollMode: boolean, serverAsked?: boolean }} state
|
|
12
|
+
* @returns {boolean} whether the add-device form must ask for a code
|
|
13
|
+
*/
|
|
14
|
+
export function addDeviceNeedsCode({ deviceCount, reenrollMode, serverAsked = false }) {
|
|
15
|
+
if (serverAsked) return true;
|
|
16
|
+
return deviceCount > 0 && !reenrollMode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Turn the add-device form into the begin arguments, or an error to show before sending.
|
|
21
|
+
* @param {{ name: string, code: string, codeRequired: boolean }} form
|
|
22
|
+
* @returns {{ error: string } | { name: string, code?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function planBeginDevice(form) {
|
|
25
|
+
const name = form.name.trim();
|
|
26
|
+
if (!name) return { error: 'Enter a name for this device.' };
|
|
27
|
+
if (!form.codeRequired) return { name };
|
|
28
|
+
const code = form.code.trim();
|
|
29
|
+
if (!code) {
|
|
30
|
+
return { error: 'Enter a code from one of your authenticator apps or a recovery code.' };
|
|
31
|
+
}
|
|
32
|
+
return { name, code };
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// deviceEnrollment.test.mjs — KI #14 re-review: adding an authenticator when the account already
|
|
2
|
+
// has one needs a code from an existing device (or a recovery code); the recovery-login re-enroll
|
|
3
|
+
// screen is exempt.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/deviceEnrollment.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { addDeviceNeedsCode, planBeginDevice } from './deviceEnrollment.js';
|
|
9
|
+
|
|
10
|
+
describe('addDeviceNeedsCode', () => {
|
|
11
|
+
test('no device yet → no code', () => {
|
|
12
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 0, reenrollMode: false }), false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('MFA already on → a code', () => {
|
|
16
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 1, reenrollMode: false }), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('re-enroll screen (lost device still listed) → no code', () => {
|
|
20
|
+
assert.equal(addDeviceNeedsCode({ deviceCount: 1, reenrollMode: true }), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('the server asked for one (mfa_required) → a code, even if the list was empty', () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
addDeviceNeedsCode({ deviceCount: 0, reenrollMode: false, serverAsked: true }),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('planBeginDevice', () => {
|
|
32
|
+
test('a name is required', () => {
|
|
33
|
+
assert.deepEqual(planBeginDevice({ name: ' ', code: '', codeRequired: false }), {
|
|
34
|
+
error: 'Enter a name for this device.',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('no code needed → only the name', () => {
|
|
39
|
+
assert.deepEqual(planBeginDevice({ name: ' Phone ', code: '123', codeRequired: false }), {
|
|
40
|
+
name: 'Phone',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('code needed but empty → error, nothing sent', () => {
|
|
45
|
+
assert.deepEqual(planBeginDevice({ name: 'Tablet', code: ' ', codeRequired: true }), {
|
|
46
|
+
error: 'Enter a code from one of your authenticator apps or a recovery code.',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('code needed → name + trimmed code', () => {
|
|
51
|
+
assert.deepEqual(planBeginDevice({ name: 'Tablet', code: ' 123456 ', codeRequired: true }), {
|
|
52
|
+
name: 'Tablet',
|
|
53
|
+
code: '123456',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-service profile edit, kept free of React so `node --test` can check it (KI #14).
|
|
3
|
+
*
|
|
4
|
+
* A name change applies at once. An email change is VERIFIED by the server: it needs the
|
|
5
|
+
* current password (plus a TOTP or recovery code when the account has MFA) and only takes
|
|
6
|
+
* effect when the user opens the link mailed to the NEW address. Until then the profile keeps
|
|
7
|
+
* the old email. The password and code are sent with this one request and never stored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const PENDING_EMAIL_MESSAGE =
|
|
11
|
+
'We sent a confirmation link to the new address. Your email changes when you open it.';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn the form into the PATCH body, or an error to show before sending anything.
|
|
15
|
+
* @param {{ name: string, email: string, currentPassword: string, code: string,
|
|
16
|
+
* codeRequired: boolean }} form
|
|
17
|
+
* @param {{ name: string, email: string }} initial the current profile values
|
|
18
|
+
* @returns {{ error: string } | { changes: object, emailChanging: boolean }}
|
|
19
|
+
*/
|
|
20
|
+
export function planProfileEdit(form, initial) {
|
|
21
|
+
const name = form.name.trim();
|
|
22
|
+
const email = form.email.trim();
|
|
23
|
+
if (!name) return { error: 'Please enter your name.' };
|
|
24
|
+
|
|
25
|
+
const changes = {};
|
|
26
|
+
if (name !== initial.name) changes.name = name;
|
|
27
|
+
const emailChanging = email !== initial.email;
|
|
28
|
+
if (emailChanging) {
|
|
29
|
+
// The password is sent as typed (spaces are part of it).
|
|
30
|
+
if (!form.currentPassword) {
|
|
31
|
+
return { error: 'Enter your current password to change your email.' };
|
|
32
|
+
}
|
|
33
|
+
const code = form.code.trim();
|
|
34
|
+
if (form.codeRequired && !code) {
|
|
35
|
+
return { error: 'Enter a code from your authenticator app or a recovery code.' };
|
|
36
|
+
}
|
|
37
|
+
changes.email = email;
|
|
38
|
+
changes.currentPassword = form.currentPassword;
|
|
39
|
+
if (code) changes.code = code;
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(changes).length === 0) return { error: 'Nothing to update.' };
|
|
42
|
+
return { changes, emailChanging };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the page does with an `accountService.updateProfile` result.
|
|
47
|
+
* @returns {{ kind: 'saved' } | { kind: 'pending', message: string }
|
|
48
|
+
* | { kind: 'error', error: string, showCode: boolean }}
|
|
49
|
+
*/
|
|
50
|
+
export function interpretProfileResult(r) {
|
|
51
|
+
if (r.ok && r.pendingEmail) return { kind: 'pending', message: PENDING_EMAIL_MESSAGE };
|
|
52
|
+
if (r.ok) return { kind: 'saved' };
|
|
53
|
+
return {
|
|
54
|
+
kind: 'error',
|
|
55
|
+
error: r.error || 'Could not update your profile.',
|
|
56
|
+
showCode: r.reason === 'mfa_required' || r.reason === 'invalid_code',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// profileEdit.test.mjs — KI #14: changing the email needs the current password (and an MFA
|
|
2
|
+
// code when the account has MFA) and only takes effect from the link sent to the new address.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/auth/profileEdit.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { planProfileEdit, interpretProfileResult, PENDING_EMAIL_MESSAGE } from './profileEdit.js';
|
|
8
|
+
|
|
9
|
+
const initial = { name: 'Jane', email: 'old@x.test' };
|
|
10
|
+
const pw = 'p'.repeat(12);
|
|
11
|
+
const form = (over = {}) => ({
|
|
12
|
+
name: 'Jane',
|
|
13
|
+
email: 'old@x.test',
|
|
14
|
+
currentPassword: '',
|
|
15
|
+
code: '',
|
|
16
|
+
codeRequired: false,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('planProfileEdit', () => {
|
|
21
|
+
test('name only → no password asked, only the name is sent', () => {
|
|
22
|
+
assert.deepEqual(planProfileEdit(form({ name: ' New ' }), initial), {
|
|
23
|
+
changes: { name: 'New' },
|
|
24
|
+
emailChanging: false,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a password typed for a name-only edit is not sent', () => {
|
|
29
|
+
const plan = planProfileEdit(form({ name: 'New', currentPassword: pw }), initial);
|
|
30
|
+
assert.deepEqual(plan.changes, { name: 'New' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('empty name → error', () => {
|
|
34
|
+
assert.deepEqual(planProfileEdit(form({ name: ' ' }), initial), {
|
|
35
|
+
error: 'Please enter your name.',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('nothing changed → error', () => {
|
|
40
|
+
assert.deepEqual(planProfileEdit(form(), initial), { error: 'Nothing to update.' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('email changed without a current password → error, nothing sent', () => {
|
|
44
|
+
assert.deepEqual(planProfileEdit(form({ email: 'new@x.test' }), initial), {
|
|
45
|
+
error: 'Enter your current password to change your email.',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('email changed with the password → email + currentPassword sent', () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
planProfileEdit(form({ email: ' new@x.test ', currentPassword: pw }), initial),
|
|
52
|
+
{ changes: { email: 'new@x.test', currentPassword: pw }, emailChanging: true },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('MFA account: a code is required and sent with the email change', () => {
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
planProfileEdit(
|
|
59
|
+
form({ email: 'new@x.test', currentPassword: pw, codeRequired: true }),
|
|
60
|
+
initial,
|
|
61
|
+
),
|
|
62
|
+
{ error: 'Enter a code from your authenticator app or a recovery code.' },
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
planProfileEdit(
|
|
66
|
+
form({ email: 'new@x.test', currentPassword: pw, codeRequired: true, code: ' 123456 ' }),
|
|
67
|
+
initial,
|
|
68
|
+
),
|
|
69
|
+
{
|
|
70
|
+
changes: { email: 'new@x.test', currentPassword: pw, code: '123456' },
|
|
71
|
+
emailChanging: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('name and email together → both sent, plus the proof', () => {
|
|
77
|
+
const plan = planProfileEdit(
|
|
78
|
+
form({ name: 'New', email: 'new@x.test', currentPassword: pw }),
|
|
79
|
+
initial,
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(plan.changes, { name: 'New', email: 'new@x.test', currentPassword: pw });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the password is never trimmed', () => {
|
|
85
|
+
const plan = planProfileEdit(
|
|
86
|
+
form({ email: 'new@x.test', currentPassword: ` ${pw} ` }),
|
|
87
|
+
initial,
|
|
88
|
+
);
|
|
89
|
+
assert.equal(plan.changes.currentPassword, ` ${pw} `);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('interpretProfileResult', () => {
|
|
94
|
+
test('200 → saved', () => {
|
|
95
|
+
assert.deepEqual(interpretProfileResult({ ok: true, profile: {} }), { kind: 'saved' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('202 → pending, with the "check your new inbox" message', () => {
|
|
99
|
+
assert.deepEqual(interpretProfileResult({ ok: true, pendingEmail: true, profile: {} }), {
|
|
100
|
+
kind: 'pending',
|
|
101
|
+
message: PENDING_EMAIL_MESSAGE,
|
|
102
|
+
});
|
|
103
|
+
assert.equal(
|
|
104
|
+
PENDING_EMAIL_MESSAGE,
|
|
105
|
+
'We sent a confirmation link to the new address. Your email changes when you open it.',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('mfa_required / invalid_code → error that shows the code field', () => {
|
|
110
|
+
for (const reason of ['mfa_required', 'invalid_code']) {
|
|
111
|
+
assert.deepEqual(interpretProfileResult({ ok: false, status: 400, reason, error: 'E' }), {
|
|
112
|
+
kind: 'error',
|
|
113
|
+
error: 'E',
|
|
114
|
+
showCode: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('other failures → error, code field unchanged', () => {
|
|
120
|
+
assert.deepEqual(
|
|
121
|
+
interpretProfileResult({
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 400,
|
|
124
|
+
reason: 'invalid_current_password',
|
|
125
|
+
error: 'Current password is incorrect.',
|
|
126
|
+
}),
|
|
127
|
+
{ kind: 'error', error: 'Current password is incorrect.', showCode: false },
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(interpretProfileResult({ ok: false, status: 500 }), {
|
|
130
|
+
kind: 'error',
|
|
131
|
+
error: 'Could not update your profile.',
|
|
132
|
+
showCode: false,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfirmEmail — the page the email-change link opens (`/confirm-email#t=…`, KI #14).
|
|
3
|
+
*
|
|
4
|
+
* Public: the link may be opened on a device with no session, and a signed-in user is not
|
|
5
|
+
* bounced away (that would drop the fragment). On mount it reads the token from the URL
|
|
6
|
+
* fragment, removes the fragment from the address bar and history (`history.replaceState`),
|
|
7
|
+
* and sets `<meta name="referrer" content="no-referrer">` while mounted. It then POSTs the
|
|
8
|
+
* token once through `authService.confirmEmail` (no bearer). The token is never logged,
|
|
9
|
+
* stored or put in a query string. Fragment handling lives in `auth/confirmEmailLink.js`.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
12
|
+
import { Link } from 'react-router-dom';
|
|
13
|
+
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2 } from 'lucide-react';
|
|
14
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
15
|
+
import { applyNoReferrer, readConfirmToken, scrubConfirmToken } from '@/auth/confirmEmailLink';
|
|
16
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
17
|
+
import ThemeToggle from '@/theme/ThemeToggle';
|
|
18
|
+
import { branding } from '@/config/branding';
|
|
19
|
+
|
|
20
|
+
const MISSING_TOKEN = { ok: false, status: 400, error: 'This link is invalid or has expired.' };
|
|
21
|
+
|
|
22
|
+
export default function ConfirmEmail() {
|
|
23
|
+
const { auth, isAuthenticated, refreshProfile } = useAuth();
|
|
24
|
+
// Read only (pure, so StrictMode's double call sees the same value); scrubbed below.
|
|
25
|
+
const [token] = useState(() => readConfirmToken(window.location.hash));
|
|
26
|
+
const [result, setResult] = useState(token ? null : MISSING_TOKEN);
|
|
27
|
+
// The token is single-use: post it once, even when StrictMode re-runs the effect.
|
|
28
|
+
const postedRef = useRef(false);
|
|
29
|
+
|
|
30
|
+
// Before paint: drop the fragment from the URL and history, and send no Referer.
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
scrubConfirmToken(window);
|
|
33
|
+
return applyNoReferrer(document);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!token || postedRef.current) return;
|
|
38
|
+
postedRef.current = true;
|
|
39
|
+
auth.confirmEmail(token).then((r) => {
|
|
40
|
+
setResult(r);
|
|
41
|
+
// Best effort: the header shows the new address at once (a revoked session just
|
|
42
|
+
// signs out on its next request).
|
|
43
|
+
if (r.ok && isAuthenticated) refreshProfile();
|
|
44
|
+
});
|
|
45
|
+
}, [auth, token, isAuthenticated, refreshProfile]);
|
|
46
|
+
|
|
47
|
+
const back = isAuthenticated
|
|
48
|
+
? { to: '/account/profile', label: 'Back to your profile' }
|
|
49
|
+
: { to: '/login', label: 'Sign in' };
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="grid-bg relative flex min-h-screen items-center justify-center bg-background p-6">
|
|
53
|
+
<div className="absolute right-4 top-4">
|
|
54
|
+
<ThemeToggle compact />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<Card className="w-full max-w-md shadow-xl">
|
|
58
|
+
<CardContent className="p-8">
|
|
59
|
+
<div className="mb-7 flex flex-col items-center gap-4">
|
|
60
|
+
<img
|
|
61
|
+
src={branding.logo.light}
|
|
62
|
+
alt={branding.appName}
|
|
63
|
+
className="h-9 w-auto dark:hidden"
|
|
64
|
+
/>
|
|
65
|
+
<img
|
|
66
|
+
src={branding.logo.dark}
|
|
67
|
+
alt={branding.appName}
|
|
68
|
+
className="hidden h-9 w-auto dark:block"
|
|
69
|
+
/>
|
|
70
|
+
<h1 className="text-xl font-bold tracking-tight">Confirm your new email</h1>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{result === null && (
|
|
74
|
+
<div
|
|
75
|
+
role="status"
|
|
76
|
+
aria-live="polite"
|
|
77
|
+
className="flex items-center justify-center gap-2 text-sm text-muted-foreground"
|
|
78
|
+
>
|
|
79
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
80
|
+
Confirming…
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{result?.ok === true && (
|
|
85
|
+
<div
|
|
86
|
+
role="status"
|
|
87
|
+
className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-2.5 text-sm"
|
|
88
|
+
>
|
|
89
|
+
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" />
|
|
90
|
+
<span>
|
|
91
|
+
Your email address has been changed. For your security, your other sessions were
|
|
92
|
+
signed out.
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{result?.ok === false && (
|
|
98
|
+
<div
|
|
99
|
+
role="alert"
|
|
100
|
+
className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
101
|
+
>
|
|
102
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
103
|
+
<span>
|
|
104
|
+
{result.error}
|
|
105
|
+
{result.status === 400 && ' Request the change again from your profile.'}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<div className="mt-6 text-center">
|
|
111
|
+
<Link
|
|
112
|
+
to={back.to}
|
|
113
|
+
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
114
|
+
>
|
|
115
|
+
<ArrowLeft className="size-4" />
|
|
116
|
+
{back.label}
|
|
117
|
+
</Link>
|
|
118
|
+
</div>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -4,15 +4,22 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Pre-fills from the /auth/me profile (via useAuth().profile); on save it PATCHes
|
|
6
6
|
* /account/profile through the shared accountService, then calls refreshProfile() so
|
|
7
|
-
* the header/sidebar user card reflects the new name
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* the header/sidebar user card reflects the new name at once. Only sends the fields that
|
|
8
|
+
* actually changed (partial edit). All display values are NAMES (never UUIDs).
|
|
9
|
+
*
|
|
10
|
+
* Email change (KI #14): the server verifies it. Changing the email asks for the current
|
|
11
|
+
* password, and for a TOTP / recovery code when the account has MFA (detected up front from
|
|
12
|
+
* the device list, and always when the server answers `mfa_required`). The server answers 202
|
|
13
|
+
* and mails a link to the NEW address; the email only changes when that link is opened (the
|
|
14
|
+
* /confirm-email page), so the page keeps showing the old one. The password and code live in
|
|
15
|
+
* component state for one request and are cleared after it. The form logic is in
|
|
16
|
+
* `auth/profileEdit.js`.
|
|
11
17
|
*/
|
|
12
18
|
import { useEffect, useState } from 'react';
|
|
13
19
|
import { toast } from 'sonner';
|
|
14
|
-
import { Loader2, ShieldCheck, UserRound } from 'lucide-react';
|
|
20
|
+
import { Loader2, MailCheck, ShieldCheck, UserRound } from 'lucide-react';
|
|
15
21
|
import { useAuth } from '@/auth/AuthContext';
|
|
22
|
+
import { planProfileEdit, interpretProfileResult } from '@/auth/profileEdit';
|
|
16
23
|
import { Button } from '@/components/ui/button';
|
|
17
24
|
import { Input } from '@/components/ui/input';
|
|
18
25
|
import { Label } from '@/components/ui/label';
|
|
@@ -22,12 +29,18 @@ export default function ProfilePage() {
|
|
|
22
29
|
const { tokens, account, profile, refreshProfile } = useAuth();
|
|
23
30
|
const [name, setName] = useState(profile?.name ?? '');
|
|
24
31
|
const [email, setEmail] = useState(profile?.email ?? '');
|
|
32
|
+
const [currentPassword, setCurrentPassword] = useState('');
|
|
33
|
+
const [code, setCode] = useState('');
|
|
34
|
+
// True when the account has MFA (from the device list) or the server asked for a code.
|
|
35
|
+
const [codeRequired, setCodeRequired] = useState(false);
|
|
36
|
+
const [pendingNotice, setPendingNotice] = useState('');
|
|
25
37
|
const [error, setError] = useState('');
|
|
26
38
|
const [submitting, setSubmitting] = useState(false);
|
|
27
39
|
|
|
28
40
|
const initialName = profile?.name ?? '';
|
|
29
41
|
const initialEmail = profile?.email ?? '';
|
|
30
|
-
const
|
|
42
|
+
const emailDirty = email.trim() !== initialEmail;
|
|
43
|
+
const dirty = name.trim() !== initialName || emailDirty;
|
|
31
44
|
|
|
32
45
|
// The profile may still be loading (null) when this first mounts (e.g. a hard page
|
|
33
46
|
// refresh). Seed the fields once it arrives / changes; a server-side refresh after
|
|
@@ -37,32 +50,58 @@ export default function ProfilePage() {
|
|
|
37
50
|
setEmail(profile?.email ?? '');
|
|
38
51
|
}, [profile?.name, profile?.email]);
|
|
39
52
|
|
|
53
|
+
// Best effort: ask for the MFA code up front when the account has a device. If this
|
|
54
|
+
// fails, the server's `mfa_required` answer shows the field instead.
|
|
55
|
+
const accessToken = tokens?.accessToken;
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!accessToken) return;
|
|
58
|
+
let active = true;
|
|
59
|
+
account.listMfaDevices(accessToken).then((r) => {
|
|
60
|
+
if (active && r.ok && r.mfaEnabled) setCodeRequired(true);
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
active = false;
|
|
64
|
+
};
|
|
65
|
+
}, [account, accessToken]);
|
|
66
|
+
|
|
40
67
|
const onSubmit = async (e) => {
|
|
41
68
|
e.preventDefault();
|
|
42
69
|
setError('');
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
setError(
|
|
71
|
+
const plan = planProfileEdit(
|
|
72
|
+
{ name, email, currentPassword, code, codeRequired },
|
|
73
|
+
{
|
|
74
|
+
name: initialName,
|
|
75
|
+
email: initialEmail,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
if (plan.error) {
|
|
79
|
+
setError(plan.error);
|
|
53
80
|
return;
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
setSubmitting(true);
|
|
57
|
-
const r = await account.updateProfile(tokens?.accessToken, changes);
|
|
84
|
+
const r = await account.updateProfile(tokens?.accessToken, plan.changes);
|
|
85
|
+
// The password and code were for this one request only.
|
|
86
|
+
setCurrentPassword('');
|
|
87
|
+
setCode('');
|
|
58
88
|
setSubmitting(false);
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
const outcome = interpretProfileResult(r);
|
|
91
|
+
if (outcome.kind === 'error') {
|
|
92
|
+
if (outcome.showCode) setCodeRequired(true);
|
|
93
|
+
setError(outcome.error);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await refreshProfile();
|
|
97
|
+
if (outcome.kind === 'pending') {
|
|
98
|
+
// The email is unchanged until the link is opened: show the current one again.
|
|
99
|
+
setEmail(initialEmail);
|
|
100
|
+
setPendingNotice(outcome.message);
|
|
101
|
+
toast.success('Check your new inbox');
|
|
63
102
|
return;
|
|
64
103
|
}
|
|
65
|
-
|
|
104
|
+
toast.success('Profile updated');
|
|
66
105
|
};
|
|
67
106
|
|
|
68
107
|
return (
|
|
@@ -99,6 +138,49 @@ export default function ProfilePage() {
|
|
|
99
138
|
/>
|
|
100
139
|
</div>
|
|
101
140
|
|
|
141
|
+
{emailDirty && (
|
|
142
|
+
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
|
143
|
+
<p className="text-sm text-muted-foreground">
|
|
144
|
+
To change your email, confirm it is you. We will send a link to the new address;
|
|
145
|
+
your email changes when you open it.
|
|
146
|
+
</p>
|
|
147
|
+
<div className="space-y-1.5">
|
|
148
|
+
<Label htmlFor="pf-current-password">Current password</Label>
|
|
149
|
+
<Input
|
|
150
|
+
id="pf-current-password"
|
|
151
|
+
type="password"
|
|
152
|
+
autoComplete="current-password"
|
|
153
|
+
value={currentPassword}
|
|
154
|
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
{codeRequired && (
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
<Label htmlFor="pf-code">Authenticator or recovery code</Label>
|
|
160
|
+
<Input
|
|
161
|
+
id="pf-code"
|
|
162
|
+
type="text"
|
|
163
|
+
inputMode="text"
|
|
164
|
+
autoComplete="one-time-code"
|
|
165
|
+
value={code}
|
|
166
|
+
onChange={(e) => setCode(e.target.value)}
|
|
167
|
+
placeholder="123456"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{pendingNotice && (
|
|
175
|
+
<div
|
|
176
|
+
role="status"
|
|
177
|
+
className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-sm"
|
|
178
|
+
>
|
|
179
|
+
<MailCheck className="mt-0.5 size-4 shrink-0 text-primary" />
|
|
180
|
+
<span>{pendingNotice}</span>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
102
184
|
{error && (
|
|
103
185
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
104
186
|
{error}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
X,
|
|
38
38
|
} from 'lucide-react';
|
|
39
39
|
import { useAuth } from '@/auth/AuthContext';
|
|
40
|
+
import { addDeviceNeedsCode, planBeginDevice } from '@/auth/deviceEnrollment';
|
|
40
41
|
import { Button } from '@/components/ui/button';
|
|
41
42
|
import { Input } from '@/components/ui/input';
|
|
42
43
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
|
@@ -296,6 +297,14 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
296
297
|
const [code, setCode] = useState('');
|
|
297
298
|
const [addError, setAddError] = useState('');
|
|
298
299
|
const [addBusy, setAddBusy] = useState(false);
|
|
300
|
+
// With MFA already on, adding a device needs a code from an existing one (or a recovery code).
|
|
301
|
+
const [stepUpCode, setStepUpCode] = useState('');
|
|
302
|
+
const [serverAskedCode, setServerAskedCode] = useState(false);
|
|
303
|
+
const stepUpRequired = addDeviceNeedsCode({
|
|
304
|
+
deviceCount: devices.length,
|
|
305
|
+
reenrollMode,
|
|
306
|
+
serverAsked: serverAskedCode,
|
|
307
|
+
});
|
|
299
308
|
// The boxed-OTP input (input-otp forwards its ref to the real <input>). Focused when the
|
|
300
309
|
// enroll step opens so the user can type the code straight away without clicking a box.
|
|
301
310
|
const codeInputRef = useRef(null);
|
|
@@ -399,12 +408,15 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
399
408
|
const openAdd = () => {
|
|
400
409
|
setAddError('');
|
|
401
410
|
setNewName('');
|
|
411
|
+
setStepUpCode('');
|
|
402
412
|
setAddPhase('naming');
|
|
403
413
|
};
|
|
404
414
|
|
|
405
415
|
const resetAdd = () => {
|
|
406
416
|
setAddPhase('idle');
|
|
407
417
|
setNewName('');
|
|
418
|
+
setStepUpCode('');
|
|
419
|
+
setServerAskedCode(false);
|
|
408
420
|
setDeviceId('');
|
|
409
421
|
setSecret('');
|
|
410
422
|
setOtpauthUri('');
|
|
@@ -415,18 +427,26 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
415
427
|
const onBegin = async (e) => {
|
|
416
428
|
e.preventDefault();
|
|
417
429
|
setAddError('');
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
430
|
+
const plan = planBeginDevice({
|
|
431
|
+
name: newName,
|
|
432
|
+
code: stepUpCode,
|
|
433
|
+
codeRequired: stepUpRequired,
|
|
434
|
+
});
|
|
435
|
+
if (plan.error) {
|
|
436
|
+
setAddError(plan.error);
|
|
421
437
|
return;
|
|
422
438
|
}
|
|
423
439
|
setAddBusy(true);
|
|
424
|
-
const r = await account.beginMfaDevice(token, name);
|
|
440
|
+
const r = await account.beginMfaDevice(token, plan.name, plan.code);
|
|
425
441
|
setAddBusy(false);
|
|
426
442
|
if (!r.ok) {
|
|
443
|
+
if (r.reason === 'mfa_required') setServerAskedCode(true);
|
|
444
|
+
// A spent or wrong code cannot be retried: clear it.
|
|
445
|
+
if (r.reason === 'invalid_code') setStepUpCode('');
|
|
427
446
|
setAddError(r.error || 'Could not start two-factor setup.');
|
|
428
447
|
return;
|
|
429
448
|
}
|
|
449
|
+
setStepUpCode('');
|
|
430
450
|
setDeviceId(r.deviceId || '');
|
|
431
451
|
setSecret(r.secret || '');
|
|
432
452
|
setOtpauthUri(r.otpauthUri || '');
|
|
@@ -606,6 +626,21 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
606
626
|
A friendly label so you can tell your devices apart.
|
|
607
627
|
</p>
|
|
608
628
|
</div>
|
|
629
|
+
{stepUpRequired && (
|
|
630
|
+
<div className="space-y-1.5">
|
|
631
|
+
<Label htmlFor="mfa-stepup-code">Current code</Label>
|
|
632
|
+
<Input
|
|
633
|
+
id="mfa-stepup-code"
|
|
634
|
+
autoComplete="one-time-code"
|
|
635
|
+
placeholder="6-digit code or recovery code"
|
|
636
|
+
value={stepUpCode}
|
|
637
|
+
onChange={(e) => setStepUpCode(e.target.value)}
|
|
638
|
+
/>
|
|
639
|
+
<p className="text-xs text-muted-foreground">
|
|
640
|
+
From one of your existing authenticator apps, or a recovery code.
|
|
641
|
+
</p>
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
609
644
|
<div className="flex items-center gap-2">
|
|
610
645
|
<Button type="submit" disabled={addBusy}>
|
|
611
646
|
{addBusy ? (
|
|
@@ -21,6 +21,26 @@ function devCsp() {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `Referrer-Policy: no-referrer` on the email-change confirmation page (KI #14), for
|
|
26
|
+
* `npm run dev` and `npm run preview`. The page also sets the same policy with a <meta> tag
|
|
27
|
+
* while it is mounted, and its token is in the URL fragment (never sent in a Referer). In
|
|
28
|
+
* production, send this header for `/confirm-email` from whatever serves the built app.
|
|
29
|
+
*/
|
|
30
|
+
function confirmEmailNoReferrer() {
|
|
31
|
+
const setHeader = (req, res, next) => {
|
|
32
|
+
if ((req.url ?? '').split(/[?#]/)[0] === '/confirm-email') {
|
|
33
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
34
|
+
}
|
|
35
|
+
next();
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
name: 'astram-confirm-email-no-referrer',
|
|
39
|
+
configureServer: (server) => server.middlewares.use(setHeader),
|
|
40
|
+
configurePreviewServer: (server) => server.middlewares.use(setHeader),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
|
|
26
46
|
*
|
|
@@ -29,7 +49,7 @@ function devCsp() {
|
|
|
29
49
|
* `/auth/login`. `@` aliases `src/` (the shadcn import convention).
|
|
30
50
|
*/
|
|
31
51
|
export default defineConfig({
|
|
32
|
-
plugins: [react(), tailwindcss(), devCsp()],
|
|
52
|
+
plugins: [react(), tailwindcss(), devCsp(), confirmEmailNoReferrer()],
|
|
33
53
|
resolve: {
|
|
34
54
|
alias: {
|
|
35
55
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
@@ -60,6 +60,14 @@ NODE_ENV=development
|
|
|
60
60
|
# TRUST_PROXY (above) is set, so the client IP is real rather than the proxy's.
|
|
61
61
|
# LOGIN_IP_MAX_FAILURES=50
|
|
62
62
|
# LOGIN_IP_WINDOW_MS=900000
|
|
63
|
+
#
|
|
64
|
+
# Public URLs of your frontend's reset-password and confirm-email pages; the framework mails
|
|
65
|
+
# `<RESET_LINK_BASE_URL>?token=…` and `<EMAIL_CHANGE_LINK_BASE_URL>#t=…`. Defaults point at the
|
|
66
|
+
# Vite dev server (http://localhost:5173/...). Outside NODE_ENV development/test, with an
|
|
67
|
+
# emailSender, boot FAILS if either is unset or points at localhost/127.0.0.1/::1/0.0.0.0.
|
|
68
|
+
# ShieldModule.forRoot({ emailChangeLinkBaseUrl }) wins over EMAIL_CHANGE_LINK_BASE_URL.
|
|
69
|
+
# RESET_LINK_BASE_URL=https://app.example.com/reset-password
|
|
70
|
+
# EMAIL_CHANGE_LINK_BASE_URL=https://app.example.com/confirm-email
|
|
63
71
|
|
|
64
72
|
# --- Migrations (optional; @astram/identity's migrations run automatically) ---
|
|
65
73
|
# APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
|
|
@@ -224,6 +224,44 @@ const RAW_METHOD_RE = `/^(${RAW_METHODS.join('|')})$/`;
|
|
|
224
224
|
const MORE_RAW_METHOD_RE = `/^(${RAW_METHODS.slice(5).join('|')})$/`;
|
|
225
225
|
const RAW_METHOD_MESSAGE = `Wall: no raw driver method (${RAW_METHODS.join('/')}) in app code, however it is reached — use the RecordEngine (@astram/engine).`;
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* KI #82: uuid route/body inputs go through `UuidPipe` / `uuidField` / `optionalUuidField` /
|
|
229
|
+
* `uuidArrayField` (@astram/guard). MariaDB's utf8mb4_unicode_ci matches a uuid ignoring case,
|
|
230
|
+
* trailing spaces and character width, so a raw `"<id> "` or fullwidth uuid reaches the row while
|
|
231
|
+
* every exact check in JS (own root, a Set of allowed ids, a Redis key) misses it. A uuid-NAMED
|
|
232
|
+
* input (`id`, `uuid`, `…Id(s)`, `…Uuid(s)`) is flagged when it is read without the canonicaliser.
|
|
233
|
+
*
|
|
234
|
+
* This is a NAME-BASED NUDGE, NOT A GUARANTEE. It only sees those names in those shapes: a uuid
|
|
235
|
+
* under another name (`@Param('slug')`, `@Param('target')`, `@Query('owner')`), a destructured
|
|
236
|
+
* body (`const { companyUuid } = body`), a body bound to another variable name, a value read off
|
|
237
|
+
* `req`, or a param with ANY pipe argument (`@Param('id', SomeOtherPipe)`) is not flagged. Put
|
|
238
|
+
* `UuidPipe` on EVERY uuid route param
|
|
239
|
+
* whatever its name, and a uuid helper on every uuid body/query field; a clean lint run does not
|
|
240
|
+
* prove every uuid input is canonicalised.
|
|
241
|
+
*/
|
|
242
|
+
const UUID_NAME_RE = '/^(id|ids|uuid|uuids|[a-z][A-Za-z0-9]*(Id|Ids|Uuid|Uuids))$/';
|
|
243
|
+
const UUID_HELPERS_RE = '/^(uuidField|optionalUuidField|uuidArrayField)$/';
|
|
244
|
+
const UUID_BODY_OBJECTS_RE = '/^(body|dto|b|payload|input)$/';
|
|
245
|
+
const UUID_MESSAGE =
|
|
246
|
+
"Wall: a uuid route/body input goes through UuidPipe (`@Param('uuid', UuidPipe)`) or uuidField / optionalUuidField / uuidArrayField from @astram/guard — the database matches uuids ignoring case, spaces and width, so a raw one slips past exact checks.";
|
|
247
|
+
const UUID_INPUTS = [
|
|
248
|
+
{
|
|
249
|
+
// @Param('uuid') / @Query('recordId') / @Body('roleUuid') with no pipe argument.
|
|
250
|
+
selector: `Decorator > CallExpression[callee.name=/^(Param|Query|Body)$/][arguments.length=1] > Literal.arguments[value=${UUID_NAME_RE}]`,
|
|
251
|
+
message: UUID_MESSAGE,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
// @Param() — the whole params object reaches the handler unvalidated.
|
|
255
|
+
selector: "Decorator > CallExpression[callee.name='Param'][arguments.length=0]",
|
|
256
|
+
message: UUID_MESSAGE,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
// body.companyUuid / body?.roleIds read raw (not as the first argument of a uuid helper).
|
|
260
|
+
selector: `MemberExpression[computed=false][object.name=${UUID_BODY_OBJECTS_RE}][property.name=${UUID_NAME_RE}]:not(CallExpression[callee.name=${UUID_HELPERS_RE}] > MemberExpression):not(CallExpression[callee.name=${UUID_HELPERS_RE}] > ChainExpression > MemberExpression)`,
|
|
261
|
+
message: UUID_MESSAGE,
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
227
265
|
/** App source files: TypeScript and plain JavaScript in every module flavour. */
|
|
228
266
|
const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
|
|
229
267
|
const COMPOSITION_ROOT = ['src/app.module.ts', 'src/main.ts'];
|
|
@@ -315,6 +353,7 @@ export default tseslint.config(
|
|
|
315
353
|
...INTERNAL_DYNAMIC,
|
|
316
354
|
...DEEP_PATHS,
|
|
317
355
|
...NON_STATIC_SPECIFIERS,
|
|
356
|
+
...UUID_INPUTS,
|
|
318
357
|
// The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
|
|
319
358
|
// `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
|
|
320
359
|
// (A named import from the listed package is already reported by no-restricted-imports,
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@astram/core": "^1.4.0",
|
|
24
|
-
"@astram/engine": "^0.5.
|
|
25
|
-
"@astram/guard": "^0.
|
|
26
|
-
"@astram/guide": "^0.2.
|
|
27
|
-
"@astram/identity": "^0.
|
|
24
|
+
"@astram/engine": "^0.5.2",
|
|
25
|
+
"@astram/guard": "^0.11.0",
|
|
26
|
+
"@astram/guide": "^0.2.3",
|
|
27
|
+
"@astram/identity": "^0.8.0",
|
|
28
28
|
"@astram/migrate": "^0.3.2",
|
|
29
29
|
"@astram/sdk": "^0.2.0",
|
|
30
30
|
"@nestjs/common": "^11.2.7",
|
|
@@ -37,7 +37,9 @@ import { DemoController } from './health/demo.controller';
|
|
|
37
37
|
* `.env.example`) — auth needs a real MariaDB + Redis and a refresh HMAC key.
|
|
38
38
|
* 3. Create a user in the `shield_identity.identity_credential` table.
|
|
39
39
|
* 4. Outside development/test (`NODE_ENV`), `auth: true` also requires an
|
|
40
|
-
* `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed
|
|
40
|
+
* `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed —
|
|
41
|
+
* and, with a sender, public `RESET_LINK_BASE_URL` / `EMAIL_CHANGE_LINK_BASE_URL`
|
|
42
|
+
* (not localhost; see `.env.example`).
|
|
41
43
|
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
42
44
|
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
43
45
|
* boot; turning it on requires the config above, by design — fail-closed.)
|