create-astram-app 0.10.0 → 0.11.0
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/package.json +1 -1
- package/template/README.md +13 -2
- package/template/frontend/src/auth/resetWording.js +28 -0
- package/template/frontend/src/auth/resetWording.test.mjs +35 -0
- package/template/frontend/src/pages/ResetPassword.jsx +17 -12
- package/template/frontend/src/pages/users/UsersPage.jsx +79 -4
- package/template/frontend/src/pages/users/inviteAction.js +37 -0
- package/template/frontend/src/pages/users/inviteAction.test.mjs +63 -0
- package/template/server/_env.example +37 -0
- package/template/server/package.json +6 -5
- package/template/server/src/app.module.ts +8 -1
- package/template/server/src/bootstrap-org.args.ts +10 -1
- package/template/server/src/bootstrap-org.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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/README.md
CHANGED
|
@@ -80,7 +80,15 @@ the credential store, MFA, session handling and the decision authority; you writ
|
|
|
80
80
|
### Before you deploy with auth on
|
|
81
81
|
|
|
82
82
|
- Pass an `emailSender` (or `'disabled'`): outside `NODE_ENV=development|test` boot fails
|
|
83
|
-
without one. Set `NODE_ENV=production` explicitly; `npm start` does not set it.
|
|
83
|
+
without one. Set `NODE_ENV=production` explicitly; `npm start` does not set it. The easiest is
|
|
84
|
+
`emailSender: 'env'`: the provider (SMTP, Brevo, SendGrid or Mailgun) comes from `EMAIL_PROVIDER`
|
|
85
|
+
and the MESSAGING block in `server/_env.example`, with no mail code in the app. It needs
|
|
86
|
+
`@astram/messaging` installed (the template already lists it) and, outside development, a
|
|
87
|
+
`SECRETS_PROVIDER` for the credentials. Every framework email is then bounded and audited, and
|
|
88
|
+
admins can invite pending users from the Users page.
|
|
89
|
+
- Keep the framework pins in `server/package.json` exact (`@astram/core`, `engine`, `guard`,
|
|
90
|
+
`identity`, `migrate`): the packages pin each other exactly, and a `^` range can install a
|
|
91
|
+
second `@astram/core`. `npm ls @astram/core` must show one copy.
|
|
84
92
|
- Set `RESET_LINK_BASE_URL` and `EMAIL_CHANGE_LINK_BASE_URL` to your public reset-password and
|
|
85
93
|
confirm-email pages: with an `emailSender`, production boot fails when either is unset or
|
|
86
94
|
points at localhost.
|
|
@@ -134,9 +142,12 @@ ASTRAM_BOOTSTRAP_OPERATOR=jane.doe npm run bootstrap:org -- --file ../deploy/org
|
|
|
134
142
|
```
|
|
135
143
|
|
|
136
144
|
- Flags: `--apply`, `--claim-unowned-roles`, `--reissue-links`, `--link-ttl-hours <n>`,
|
|
137
|
-
`--links-to-stdout`, `--interactive` (prompt for root passwords without echo).
|
|
145
|
+
`--links-to-stdout`, `--email-links`, `--interactive` (prompt for root passwords without echo).
|
|
138
146
|
- Every non-root user gets a one-time set-password link, printed once, only to a terminal
|
|
139
147
|
(pass `--links-to-stdout` to print them to a pipe). Links are never logged or written to a file.
|
|
148
|
+
With `emailSender` configured, `--email-links` emails each link to its user instead. Root
|
|
149
|
+
users' links are never emailed (a root link is a platform takeover): they are printed to a
|
|
150
|
+
terminal as before, or reported as not delivered.
|
|
140
151
|
- Root passwords come from `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a `secret-ref:` outside
|
|
141
152
|
development) or `--interactive`. Put the printed root uuids in `SHIELD_ROOT_IDS` and pass
|
|
142
153
|
`rootIdsFromEnv()` to both `forRoot` calls (see `server/_env.example`).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// resetWording.js — the reset page's words (G-010 R11, AC-15). An invite link carries
|
|
2
|
+
// `purpose=invite`; it opens the same page and posts the same token to the same
|
|
3
|
+
// `POST /auth/reset-password`, so the marker changes wording only and grants nothing.
|
|
4
|
+
|
|
5
|
+
const RESET = Object.freeze({
|
|
6
|
+
isInvite: false,
|
|
7
|
+
title: 'Choose a new password',
|
|
8
|
+
subtitle: 'Enter a new password for your account.',
|
|
9
|
+
submit: 'Reset password',
|
|
10
|
+
success: 'Your password has been reset. Please sign in with your new password.',
|
|
11
|
+
deadLink: 'Request a new reset link',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const INVITE = Object.freeze({
|
|
15
|
+
isInvite: true,
|
|
16
|
+
title: 'Set your password',
|
|
17
|
+
subtitle: 'Choose a password to finish setting up your account.',
|
|
18
|
+
submit: 'Set password',
|
|
19
|
+
success: 'Your password is set. Please sign in.',
|
|
20
|
+
deadLink: 'Ask your administrator for a new invite',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string | null | undefined} purpose the link's `purpose` query value
|
|
25
|
+
*/
|
|
26
|
+
export function resetWording(purpose) {
|
|
27
|
+
return purpose === 'invite' ? INVITE : RESET;
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// resetWording.test.mjs — G-010 AC-15: an invite link (`purpose=invite`) opens the same reset
|
|
2
|
+
// page with "Set your password" wording; any other link keeps the reset wording. The marker only
|
|
3
|
+
// changes words: the page posts the same token to the same route either way.
|
|
4
|
+
//
|
|
5
|
+
// node --test src/auth/resetWording.test.mjs
|
|
6
|
+
import { test, describe } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { resetWording } from './resetWording.js';
|
|
9
|
+
|
|
10
|
+
describe('resetWording', () => {
|
|
11
|
+
test('purpose=invite → set-password wording', () => {
|
|
12
|
+
const w = resetWording('invite');
|
|
13
|
+
assert.equal(w.isInvite, true);
|
|
14
|
+
assert.equal(w.title, 'Set your password');
|
|
15
|
+
assert.equal(w.subtitle, 'Choose a password to finish setting up your account.');
|
|
16
|
+
assert.equal(w.submit, 'Set password');
|
|
17
|
+
assert.equal(w.success, 'Your password is set. Please sign in.');
|
|
18
|
+
assert.equal(w.deadLink, 'Ask your administrator for a new invite');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('anything else → today’s reset wording', () => {
|
|
22
|
+
for (const purpose of [null, undefined, '', 'reset', 'INVITE', 'invite ']) {
|
|
23
|
+
const w = resetWording(purpose);
|
|
24
|
+
assert.equal(w.isInvite, false);
|
|
25
|
+
assert.equal(w.title, 'Choose a new password');
|
|
26
|
+
assert.equal(w.subtitle, 'Enter a new password for your account.');
|
|
27
|
+
assert.equal(w.submit, 'Reset password');
|
|
28
|
+
assert.equal(
|
|
29
|
+
w.success,
|
|
30
|
+
'Your password has been reset. Please sign in with your new password.',
|
|
31
|
+
);
|
|
32
|
+
assert.equal(w.deadLink, 'Request a new reset link');
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
|
|
|
18
18
|
import { Card, CardContent } from '@/components/ui/card';
|
|
19
19
|
import ThemeToggle from '@/theme/ThemeToggle';
|
|
20
20
|
import { branding } from '@/config/branding';
|
|
21
|
+
import { resetWording } from '@/auth/resetWording';
|
|
21
22
|
|
|
22
23
|
const MIN_PASSWORD_LEN = 8;
|
|
23
24
|
|
|
@@ -26,6 +27,8 @@ export default function ResetPassword() {
|
|
|
26
27
|
const navigate = useNavigate();
|
|
27
28
|
const [params] = useSearchParams();
|
|
28
29
|
const token = params.get('token') ?? '';
|
|
30
|
+
// G-010: invite links carry `purpose=invite` (wording only; the same token, the same route).
|
|
31
|
+
const words = resetWording(params.get('purpose'));
|
|
29
32
|
|
|
30
33
|
const [next, setNext] = useState('');
|
|
31
34
|
const [confirm, setConfirm] = useState('');
|
|
@@ -59,7 +62,7 @@ export default function ResetPassword() {
|
|
|
59
62
|
setBusy(false);
|
|
60
63
|
|
|
61
64
|
if (r.ok) {
|
|
62
|
-
toast.success(
|
|
65
|
+
toast.success(words.success);
|
|
63
66
|
navigate('/login', { replace: true });
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
@@ -89,10 +92,8 @@ export default function ResetPassword() {
|
|
|
89
92
|
className="hidden h-9 w-auto dark:block"
|
|
90
93
|
/>
|
|
91
94
|
<div className="text-center">
|
|
92
|
-
<h1 className="text-xl font-bold tracking-tight">
|
|
93
|
-
<p className="mt-1 text-sm text-muted-foreground">
|
|
94
|
-
Enter a new password for your account.
|
|
95
|
-
</p>
|
|
95
|
+
<h1 className="text-xl font-bold tracking-tight">{words.title}</h1>
|
|
96
|
+
<p className="mt-1 text-sm text-muted-foreground">{words.subtitle}</p>
|
|
96
97
|
</div>
|
|
97
98
|
</div>
|
|
98
99
|
|
|
@@ -105,12 +106,16 @@ export default function ResetPassword() {
|
|
|
105
106
|
|
|
106
107
|
{linkDead ? (
|
|
107
108
|
<div className="text-center">
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
{words.isInvite ? (
|
|
110
|
+
<p className="text-sm text-muted-foreground">{words.deadLink}</p>
|
|
111
|
+
) : (
|
|
112
|
+
<Link
|
|
113
|
+
to="/forgot-password"
|
|
114
|
+
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
115
|
+
>
|
|
116
|
+
{words.deadLink}
|
|
117
|
+
</Link>
|
|
118
|
+
)}
|
|
114
119
|
</div>
|
|
115
120
|
) : (
|
|
116
121
|
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
|
@@ -143,7 +148,7 @@ export default function ResetPassword() {
|
|
|
143
148
|
) : (
|
|
144
149
|
<>
|
|
145
150
|
<KeyRound className="size-4" />
|
|
146
|
-
|
|
151
|
+
{words.submit}
|
|
147
152
|
</>
|
|
148
153
|
)}
|
|
149
154
|
</Button>
|
|
@@ -8,18 +8,24 @@
|
|
|
8
8
|
* The user EDIT page (/users/edit/:uuid) is where the Assignments (membership + posting)
|
|
9
9
|
* editor will live in Phase 5 — UserForm leaves a clear placeholder for it.
|
|
10
10
|
*
|
|
11
|
+
* Invite (G-010): a "Sign-in" column shows whether each user has set a password yet
|
|
12
|
+
* (`row.signInStatus`, computed server-side), with an Invite button for pending users when the
|
|
13
|
+
* caller holds identity:user:invite (`inviteAction.js`; UX only, the server decides).
|
|
14
|
+
*
|
|
11
15
|
* ⛔ No raw UUIDs: columns render username/email/status; `row.id` is a key/route param only.
|
|
12
16
|
*/
|
|
13
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
17
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
14
18
|
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
|
15
|
-
import { Users } from 'lucide-react';
|
|
19
|
+
import { Send, Users } from 'lucide-react';
|
|
16
20
|
import { toast } from 'sonner';
|
|
17
|
-
import { useAuth } from '@/auth/AuthContext';
|
|
21
|
+
import { useAuth, useCan } from '@/auth/AuthContext';
|
|
18
22
|
import AppShell from '@/shell/AppShell';
|
|
19
23
|
import PageHeader from '@/components/PageHeader';
|
|
20
24
|
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
21
25
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
22
26
|
import { Badge } from '@/components/ui/badge';
|
|
27
|
+
import { Button } from '@/components/ui/button';
|
|
28
|
+
import { INVITE_PERMISSION, canInvite, inviteOutcome } from './inviteAction';
|
|
23
29
|
|
|
24
30
|
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
25
31
|
function formatDate(v) {
|
|
@@ -35,6 +41,38 @@ function formatDate(v) {
|
|
|
35
41
|
}
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** Sign-in status badge text (server-computed `signInStatus`). */
|
|
45
|
+
const SIGN_IN_LABEL = { pending: 'Pending', active: 'Active', none: 'No sign-in' };
|
|
46
|
+
|
|
47
|
+
/** The Sign-in column: status badge + Invite for pending users (G-010). Not sortable. */
|
|
48
|
+
function signInColumn(holdsInvite, invitingId, onInvite) {
|
|
49
|
+
return {
|
|
50
|
+
key: 'signInStatus',
|
|
51
|
+
label: 'Sign-in',
|
|
52
|
+
sortable: false,
|
|
53
|
+
render: (row) => (
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<Badge variant={row.signInStatus === 'active' ? 'success' : 'secondary'}>
|
|
56
|
+
{SIGN_IN_LABEL[row.signInStatus] ?? '—'}
|
|
57
|
+
</Badge>
|
|
58
|
+
{canInvite(row, holdsInvite) && (
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="ghost"
|
|
62
|
+
size="sm"
|
|
63
|
+
disabled={invitingId === row.id}
|
|
64
|
+
onClick={() => onInvite(row)}
|
|
65
|
+
aria-label={`Invite ${row.fields?.username || 'this user'}`}
|
|
66
|
+
>
|
|
67
|
+
<Send className="size-4" />
|
|
68
|
+
Invite
|
|
69
|
+
</Button>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
38
76
|
// Sortable columns (server-side): Username, Email, Status, Created.
|
|
39
77
|
const COLUMNS = [
|
|
40
78
|
{
|
|
@@ -121,6 +159,9 @@ export default function UsersPage() {
|
|
|
121
159
|
setSearchParams(p, { replace: true });
|
|
122
160
|
}, [query, setSearchParams]);
|
|
123
161
|
|
|
162
|
+
const holdsInvite = useCan(INVITE_PERMISSION);
|
|
163
|
+
const [invitingId, setInvitingId] = useState(null);
|
|
164
|
+
|
|
124
165
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
125
166
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
126
167
|
|
|
@@ -152,6 +193,40 @@ export default function UsersPage() {
|
|
|
152
193
|
const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
|
|
153
194
|
const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
|
|
154
195
|
|
|
196
|
+
const onInvite = useCallback(
|
|
197
|
+
async (row) => {
|
|
198
|
+
if (!token || invitingId) return;
|
|
199
|
+
setInvitingId(row.id);
|
|
200
|
+
const outcome = inviteOutcome(await identity.inviteUser(token, row.id));
|
|
201
|
+
setInvitingId(null);
|
|
202
|
+
if (outcome.kind === 'error') {
|
|
203
|
+
toast.error(outcome.text);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (outcome.link) {
|
|
207
|
+
// The one-time link is shown nowhere else: copy it, or show it once if the copy fails.
|
|
208
|
+
try {
|
|
209
|
+
await navigator.clipboard.writeText(outcome.link);
|
|
210
|
+
toast.success(outcome.text);
|
|
211
|
+
} catch {
|
|
212
|
+
toast.success(outcome.text.replace('Link copied', 'Copy this link now'), {
|
|
213
|
+
description: outcome.link,
|
|
214
|
+
duration: 60_000,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
toast.success(outcome.text);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
[identity, token, invitingId],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const columns = useMemo(() => {
|
|
225
|
+
const out = [...COLUMNS];
|
|
226
|
+
out.splice(3, 0, signInColumn(holdsInvite, invitingId, onInvite));
|
|
227
|
+
return out;
|
|
228
|
+
}, [holdsInvite, invitingId, onInvite]);
|
|
229
|
+
|
|
155
230
|
const onConfirmDelete = async () => {
|
|
156
231
|
if (!deleteTarget) return;
|
|
157
232
|
setDeleteBusy(true);
|
|
@@ -185,7 +260,7 @@ export default function UsersPage() {
|
|
|
185
260
|
/>
|
|
186
261
|
|
|
187
262
|
<EntityList
|
|
188
|
-
columns={
|
|
263
|
+
columns={columns}
|
|
189
264
|
rows={rows}
|
|
190
265
|
search={query.search}
|
|
191
266
|
sortBy={query.sortBy}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// inviteAction.js — the Users page's Invite action (G-010 R13, AC-16). Pure logic, tested by
|
|
2
|
+
// inviteAction.test.mjs; UsersPage renders from it. Showing or hiding the action is UX only: the
|
|
3
|
+
// server decides (`POST /identity/users/:uuid/invite` needs identity:user:invite, the tenant wall
|
|
4
|
+
// and the dominance ceiling).
|
|
5
|
+
|
|
6
|
+
/** The permission key the invite route is gated on. */
|
|
7
|
+
export const INVITE_PERMISSION = 'identity:user:invite';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Show Invite for this row? Only for users whose sign-in is still pending (created or invited,
|
|
11
|
+
* no password yet) and only when the caller holds {@link INVITE_PERMISSION}.
|
|
12
|
+
* @param {{ signInStatus?: string } | null | undefined} row
|
|
13
|
+
* @param {boolean} holdsInvite
|
|
14
|
+
*/
|
|
15
|
+
export function canInvite(row, holdsInvite) {
|
|
16
|
+
return Boolean(holdsInvite && row && row.signInStatus === 'pending');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The toast for an `identity.inviteUser` result. A returned one-time link (only when the app opted
|
|
21
|
+
* in to `returnLinkToAdmin`) is handed back for the page to copy to the clipboard.
|
|
22
|
+
* @param {{ ok: boolean, emailed?: boolean, link?: string, error?: string }} result
|
|
23
|
+
* @returns {{ kind: 'success' | 'error', text: string, link?: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function inviteOutcome(result) {
|
|
26
|
+
if (!result || !result.ok) {
|
|
27
|
+
return { kind: 'error', text: (result && result.error) || 'Could not invite the user.' };
|
|
28
|
+
}
|
|
29
|
+
if (result.link) {
|
|
30
|
+
return {
|
|
31
|
+
kind: 'success',
|
|
32
|
+
text: result.emailed ? 'Invite sent · Link copied' : 'Link copied',
|
|
33
|
+
link: result.link,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { kind: 'success', text: 'Invite sent' };
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// inviteAction.test.mjs — G-010 AC-16: the Users page shows Invite only for users whose sign-in
|
|
2
|
+
// is still pending, and only to callers holding identity:user:invite (UX only; the server decides).
|
|
3
|
+
//
|
|
4
|
+
// node --test src/pages/users/inviteAction.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { INVITE_PERMISSION, canInvite, inviteOutcome } from './inviteAction.js';
|
|
8
|
+
|
|
9
|
+
describe('canInvite', () => {
|
|
10
|
+
test('the permission key is identity:user:invite', () => {
|
|
11
|
+
assert.equal(INVITE_PERMISSION, 'identity:user:invite');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('pending users, for a caller who holds the key', () => {
|
|
15
|
+
assert.equal(canInvite({ id: 'u1', signInStatus: 'pending' }, true), true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('never for active users, users with no credential, or unknown status', () => {
|
|
19
|
+
for (const signInStatus of ['active', 'none', undefined, 'PENDING']) {
|
|
20
|
+
assert.equal(canInvite({ id: 'u1', signInStatus }, true), false);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('never without the permission, or without a row', () => {
|
|
25
|
+
assert.equal(canInvite({ id: 'u1', signInStatus: 'pending' }, false), false);
|
|
26
|
+
assert.equal(canInvite(null, true), false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('inviteOutcome', () => {
|
|
31
|
+
test('emailed → "Invite sent"', () => {
|
|
32
|
+
assert.deepEqual(inviteOutcome({ ok: true, emailed: true }), {
|
|
33
|
+
kind: 'success',
|
|
34
|
+
text: 'Invite sent',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a returned link → copy it, "Link copied"; emailed too → both', () => {
|
|
39
|
+
const link = 'https://app.example/reset-password?token=t&purpose=invite';
|
|
40
|
+
assert.deepEqual(inviteOutcome({ ok: true, emailed: false, link }), {
|
|
41
|
+
kind: 'success',
|
|
42
|
+
text: 'Link copied',
|
|
43
|
+
link,
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(inviteOutcome({ ok: true, emailed: true, link }), {
|
|
46
|
+
kind: 'success',
|
|
47
|
+
text: 'Invite sent · Link copied',
|
|
48
|
+
link,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('failures show the service message (409 reasons included)', () => {
|
|
53
|
+
const reason = 'This user has already set a password. Use forgot-password instead.';
|
|
54
|
+
assert.deepEqual(inviteOutcome({ ok: false, status: 409, error: reason }), {
|
|
55
|
+
kind: 'error',
|
|
56
|
+
text: reason,
|
|
57
|
+
});
|
|
58
|
+
assert.deepEqual(inviteOutcome({ ok: false, status: 500, error: '' }), {
|
|
59
|
+
kind: 'error',
|
|
60
|
+
text: 'Could not invite the user.',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -135,6 +135,43 @@ NODE_ENV=development
|
|
|
135
135
|
# Upload size cap in bytes (default 10 MiB; above the 50 MiB ceiling fails boot).
|
|
136
136
|
# OBJECT_STORAGE_MAX_BYTES=10485760
|
|
137
137
|
|
|
138
|
+
# --- OPTIONAL: MESSAGING — email delivery (G-010) ---
|
|
139
|
+
# Read only when src/app.module.ts passes ShieldModule.forRoot({ auth: true, emailSender: 'env' })
|
|
140
|
+
# (install @astram/messaging). Password resets, admin invites, email-change confirmation and
|
|
141
|
+
# security notices go out through this provider; every send is bounded and audited.
|
|
142
|
+
# One of smtp | brevo | sendgrid | mailgun | disabled (no default: unset fails boot).
|
|
143
|
+
# EMAIL_PROVIDER=brevo
|
|
144
|
+
# EMAIL_FROM_ADDRESS=no-reply@example.com
|
|
145
|
+
# EMAIL_FROM_NAME=Example
|
|
146
|
+
# Provider credentials: outside development/test they MUST be `secret-ref:` references, which
|
|
147
|
+
# needs SECRETS_PROVIDER configured (above). A literal value fails boot.
|
|
148
|
+
# BREVO_API_KEY=secret-ref:mail/brevo-api-key
|
|
149
|
+
# SENDGRID_API_KEY=secret-ref:mail/sendgrid-api-key
|
|
150
|
+
# MAILGUN_API_KEY=secret-ref:mail/mailgun-api-key
|
|
151
|
+
# MAILGUN_DOMAIN=mg.example.com
|
|
152
|
+
# MAILGUN_REGION=us
|
|
153
|
+
# Optional https base for an HTTP provider (a relay or test stub); http is refused.
|
|
154
|
+
# EMAIL_API_BASE_URL=
|
|
155
|
+
# SMTP: TLS is starttls (default, port 587) or implicit (465); none only to a loopback host
|
|
156
|
+
# outside development/test. Certificates are always verified; SMTP_TLS_CA adds a private CA.
|
|
157
|
+
# SMTP_HOST=smtp.example.com
|
|
158
|
+
# SMTP_PORT=587
|
|
159
|
+
# SMTP_TLS=starttls
|
|
160
|
+
# SMTP_USER=mailer
|
|
161
|
+
# SMTP_PASSWORD=secret-ref:mail/smtp-password
|
|
162
|
+
# SMTP_TLS_CA=/etc/ssl/private-ca.pem
|
|
163
|
+
# Per-send bound in ms (default 10000, max 30000; one retry on a temporary failure inside it).
|
|
164
|
+
# MESSAGE_SEND_TIMEOUT_MS=10000
|
|
165
|
+
# 1 = check reachability and credentials at boot without sending (a failure refuses boot).
|
|
166
|
+
# MESSAGE_BOOT_PROBE=1
|
|
167
|
+
# SMS and WhatsApp: ports only this release; anything but `disabled` fails boot.
|
|
168
|
+
# SMS_PROVIDER=disabled
|
|
169
|
+
# WHATSAPP_PROVIDER=disabled
|
|
170
|
+
# Admin invites (POST /identity/users/:uuid/invite): link lifetime in hours (1-168, default 72)
|
|
171
|
+
# and invites per admin per hour (default 30).
|
|
172
|
+
# INVITE_LINK_TTL_HOURS=72
|
|
173
|
+
# INVITE_RATE_PER_ADMIN=30
|
|
174
|
+
|
|
138
175
|
# --- OPTIONAL: organisation bootstrap (`npm run bootstrap:org`; needs IdentityFeatureModule wired) ---
|
|
139
176
|
# Platform-root user uuids, comma-separated, lower-case canonical form. Read by rootIdsFromEnv()
|
|
140
177
|
# in src/app.module.ts (pass it to BOTH forRoot calls); bootstrap:org prints the uuid of each root.
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@astram/core": "
|
|
25
|
-
"@astram/engine": "
|
|
26
|
-
"@astram/guard": "
|
|
24
|
+
"@astram/core": "1.9.0",
|
|
25
|
+
"@astram/engine": "0.10.1",
|
|
26
|
+
"@astram/guard": "0.18.0",
|
|
27
27
|
"@astram/guide": "^0.2.8",
|
|
28
|
-
"@astram/identity": "
|
|
29
|
-
"@astram/
|
|
28
|
+
"@astram/identity": "0.15.0",
|
|
29
|
+
"@astram/messaging": "^0.1.1",
|
|
30
|
+
"@astram/migrate": "0.6.1",
|
|
30
31
|
"@astram/sdk": "^0.2.0",
|
|
31
32
|
"@nestjs/common": "^11.2.7",
|
|
32
33
|
"@nestjs/core": "^11.2.7",
|
|
@@ -39,7 +39,14 @@ import { DemoController } from './health/demo.controller';
|
|
|
39
39
|
* 4. Outside development/test (`NODE_ENV`), `auth: true` also requires an
|
|
40
40
|
* `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed —
|
|
41
41
|
* and, with a sender, public `RESET_LINK_BASE_URL` / `EMAIL_CHANGE_LINK_BASE_URL`
|
|
42
|
-
* (not localhost; see `.env.example`).
|
|
42
|
+
* (not localhost; see `.env.example`). The simplest sender is configuration only:
|
|
43
|
+
* `ShieldModule.forRoot({ auth: true, emailSender: 'env' })` with `EMAIL_PROVIDER`
|
|
44
|
+
* (smtp | brevo | sendgrid | mailgun | disabled), `EMAIL_FROM_ADDRESS` and the
|
|
45
|
+
* provider's variables (MESSAGING block in `.env.example`; credentials must be
|
|
46
|
+
* `secret-ref:` values outside development/test, so set up `SECRETS_PROVIDER` first).
|
|
47
|
+
* Admins can then invite users (`POST /identity/users/:uuid/invite`, the Users page
|
|
48
|
+
* Invite button); `IdentityFeatureModule.forRoot({ invite: { returnLinkToAdmin: true } })`
|
|
49
|
+
* also shows the one-time link to the admin once.
|
|
43
50
|
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
44
51
|
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
45
52
|
* boot; turning it on requires the config above, by design — fail-closed.)
|
|
@@ -15,6 +15,8 @@ export interface BootstrapArgs {
|
|
|
15
15
|
/** Link lifetime in whole hours; the runner validates the range. */
|
|
16
16
|
linkTtlHours: number | undefined;
|
|
17
17
|
linksToStdout: boolean;
|
|
18
|
+
/** Email the set-password links (needs a configured emailSender) instead of printing them. */
|
|
19
|
+
emailLinks: boolean;
|
|
18
20
|
/** Prompt for the root passwords (no echo) instead of reading the environment. */
|
|
19
21
|
interactive: boolean;
|
|
20
22
|
}
|
|
@@ -28,7 +30,7 @@ export class BootstrapArgsError extends Error {
|
|
|
28
30
|
|
|
29
31
|
export const BOOTSTRAP_USAGE =
|
|
30
32
|
'usage: npm run bootstrap:org -- --file <path> [--apply] [--claim-unowned-roles] ' +
|
|
31
|
-
'[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout] [--interactive]';
|
|
33
|
+
'[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout | --email-links] [--interactive]';
|
|
32
34
|
|
|
33
35
|
export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
34
36
|
const args: BootstrapArgs = {
|
|
@@ -38,6 +40,7 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
|
38
40
|
reissueLinks: false,
|
|
39
41
|
linkTtlHours: undefined,
|
|
40
42
|
linksToStdout: false,
|
|
43
|
+
emailLinks: false,
|
|
41
44
|
interactive: false,
|
|
42
45
|
};
|
|
43
46
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -64,6 +67,9 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
|
64
67
|
case '--links-to-stdout':
|
|
65
68
|
args.linksToStdout = true;
|
|
66
69
|
break;
|
|
70
|
+
case '--email-links':
|
|
71
|
+
args.emailLinks = true;
|
|
72
|
+
break;
|
|
67
73
|
case '--interactive':
|
|
68
74
|
args.interactive = true;
|
|
69
75
|
break;
|
|
@@ -83,5 +89,8 @@ export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
if (args.file === '') throw new BootstrapArgsError('--file <path> is required');
|
|
92
|
+
if (args.emailLinks && args.linksToStdout) {
|
|
93
|
+
throw new BootstrapArgsError('--email-links cannot be combined with --links-to-stdout');
|
|
94
|
+
}
|
|
86
95
|
return args;
|
|
87
96
|
}
|
|
@@ -12,10 +12,13 @@ import {
|
|
|
12
12
|
} from './bootstrap-org.args';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* `npm run bootstrap:org -- --file org.json [--apply]`: creates your organisation (tenants,
|
|
15
|
+
* `npm run bootstrap:org -- --file org.json [--apply] [--email-links]`: creates your organisation (tenants,
|
|
16
16
|
* companies, roles, users, grants) from a declarative file, through the framework's own
|
|
17
17
|
* services, against THIS app's `AppModule` (so grants can name your own object types).
|
|
18
18
|
* Without `--apply` it is a dry run that prints the plan and writes nothing.
|
|
19
|
+
* Set-password links print to the terminal; with `--email-links` (needs an `emailSender` in
|
|
20
|
+
* `ShieldModule.forRoot`) each one is emailed instead and none is printed, except ROOT links:
|
|
21
|
+
* those are never emailed and print only to a terminal (or with `--links-to-stdout`).
|
|
19
22
|
*
|
|
20
23
|
* No security logic lives here: `runOrgBootstrap` (from `@astram/identity`) does the work.
|
|
21
24
|
* Same boot order as `main.ts`: `resolveBootSecrets()` runs BEFORE `AppModule` is loaded,
|
|
@@ -63,6 +66,7 @@ async function run(args: BootstrapArgs): Promise<number> {
|
|
|
63
66
|
reissueLinks: args.reissueLinks,
|
|
64
67
|
...(args.linkTtlHours !== undefined ? { linkTtlHours: args.linkTtlHours } : {}),
|
|
65
68
|
linksToStdout: args.linksToStdout,
|
|
69
|
+
emailLinks: args.emailLinks,
|
|
66
70
|
...(process.env.ASTRAM_BOOTSTRAP_OPERATOR !== undefined
|
|
67
71
|
? { operator: process.env.ASTRAM_BOOTSTRAP_OPERATOR }
|
|
68
72
|
: {}),
|
|
@@ -78,7 +82,12 @@ async function run(args: BootstrapArgs): Promise<number> {
|
|
|
78
82
|
// Counts only: links and passwords were already shown through `out`, never repeated.
|
|
79
83
|
console.log(
|
|
80
84
|
`${report.applied ? 'applied' : 'dry run (nothing written; pass --apply to write)'}: ` +
|
|
81
|
-
`${report.plan.entries.length} planned, ${report.links} link(s) issued
|
|
85
|
+
`${report.plan.entries.length} planned, ${report.links} link(s) issued` +
|
|
86
|
+
(args.emailLinks
|
|
87
|
+
? ` (${report.linksEmailed} emailed, ${report.linksPrintedRoots} root link(s) printed, ` +
|
|
88
|
+
`${report.linksNotEmailed} not delivered)`
|
|
89
|
+
: '') +
|
|
90
|
+
', ' +
|
|
82
91
|
`${report.rootIds.length} root(s)`,
|
|
83
92
|
);
|
|
84
93
|
return 0;
|
|
@@ -104,7 +113,8 @@ async function main(): Promise<void> {
|
|
|
104
113
|
} catch (err) {
|
|
105
114
|
process.exitCode = 1;
|
|
106
115
|
if (err instanceof OrgBootstrapError) {
|
|
107
|
-
|
|
116
|
+
// `--email-links` without a sender is also `not_wired`; its own message says what to set.
|
|
117
|
+
if (err.code === 'not_wired' && !err.message.startsWith('--email-links')) {
|
|
108
118
|
console.error(
|
|
109
119
|
'wire IdentityFeatureModule in AppModule before running bootstrap:org (see the header of src/app.module.ts)',
|
|
110
120
|
);
|