create-astram-app 0.9.8 → 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 +50 -3
- 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 +48 -0
- package/template/server/package.json +8 -6
- package/template/server/src/app.module.ts +18 -5
- package/template/server/src/bootstrap-org.args.ts +96 -0
- package/template/server/src/bootstrap-org.ts +132 -0
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
|
@@ -59,7 +59,7 @@ wiring:
|
|
|
59
59
|
import { ShieldModule } from '@astram/guard';
|
|
60
60
|
|
|
61
61
|
@Module({
|
|
62
|
-
imports: [ShieldModule.forRoot()],
|
|
62
|
+
imports: [ShieldModule.forRoot()], // framework owns enforcement
|
|
63
63
|
controllers: [HealthController, DemoController],
|
|
64
64
|
})
|
|
65
65
|
export class AppModule {}
|
|
@@ -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.
|
|
@@ -100,7 +108,7 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
|
|
|
100
108
|
2. Register it in `server/src/app.module.ts` (add the module to `imports`, or its
|
|
101
109
|
controller to `controllers`).
|
|
102
110
|
3. **Classify every route:** `@Public()` for open routes, `@Classified({ typeKey,
|
|
103
|
-
|
|
111
|
+
action })` for guarded ones. Anything unclassified is denied by design (403; with
|
|
104
112
|
`IdentityFeatureModule` wired, the app refuses to boot).
|
|
105
113
|
4. **Data access goes through the framework's RecordEngine** — never a raw DB pool.
|
|
106
114
|
The `server/eslint.config.mjs` wall fails lint on off-road DB access anywhere in
|
|
@@ -114,6 +122,45 @@ The full list, and how to upgrade an app scaffolded with an earlier version, is
|
|
|
114
122
|
|
|
115
123
|
`server/src/features/` starts empty — it's your space.
|
|
116
124
|
|
|
125
|
+
## Bootstrap your organisation
|
|
126
|
+
|
|
127
|
+
`npm run bootstrap:org` (in `server/`) creates your organisation (tenants, companies,
|
|
128
|
+
departments, branches, roles, users and grants) from one JSON file, through the framework's
|
|
129
|
+
own services against your `AppModule`. It needs auth on and `IdentityFeatureModule` wired in
|
|
130
|
+
`server/src/app.module.ts` (see its header), a built app (`npm run build`) and the migrations
|
|
131
|
+
applied. Without the module it exits non-zero with "wire IdentityFeatureModule in AppModule
|
|
132
|
+
before running bootstrap:org". The file format is in the framework capabilities synced into
|
|
133
|
+
`.claude/rules/astram-framework-capabilities.md` (section "Organisation bootstrap").
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
cd server
|
|
137
|
+
npm run build
|
|
138
|
+
# Dry run: prints the plan, writes nothing.
|
|
139
|
+
npm run bootstrap:org -- --file ../deploy/org.json
|
|
140
|
+
# Apply (needs ASTRAM_BOOTSTRAP_OPERATOR; safe to re-run, it converges).
|
|
141
|
+
ASTRAM_BOOTSTRAP_OPERATOR=jane.doe npm run bootstrap:org -- --file ../deploy/org.json --apply
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- Flags: `--apply`, `--claim-unowned-roles`, `--reissue-links`, `--link-ttl-hours <n>`,
|
|
145
|
+
`--links-to-stdout`, `--email-links`, `--interactive` (prompt for root passwords without echo).
|
|
146
|
+
- Every non-root user gets a one-time set-password link, printed once, only to a terminal
|
|
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.
|
|
151
|
+
- Root passwords come from `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a `secret-ref:` outside
|
|
152
|
+
development) or `--interactive`. Put the printed root uuids in `SHIELD_ROOT_IDS` and pass
|
|
153
|
+
`rootIdsFromEnv()` to both `forRoot` calls (see `server/_env.example`).
|
|
154
|
+
|
|
155
|
+
### Existing apps
|
|
156
|
+
|
|
157
|
+
An app scaffolded before this release has no `bootstrap:org`. To add it: upgrade
|
|
158
|
+
`@astram/identity` and `@astram/guard` (`npm update @astram/identity @astram/guard`), copy
|
|
159
|
+
`src/bootstrap-org.ts` and `src/bootstrap-org.args.ts` from a freshly scaffolded app (or from
|
|
160
|
+
`create-astram-app/template/server/src/` in the framework repo) into `server/src/`, and add
|
|
161
|
+
`"bootstrap:org": "node --env-file-if-exists=.env dist/bootstrap-org.js"` to the `scripts` of
|
|
162
|
+
`server/package.json`. `npm run build` compiles both files; no other change is needed.
|
|
163
|
+
|
|
117
164
|
## The frontend
|
|
118
165
|
|
|
119
166
|
With `--with-frontend` (or `npx -p create-astram-app astram-add-frontend` from this
|
|
@@ -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,54 @@ 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
|
+
|
|
175
|
+
# --- OPTIONAL: organisation bootstrap (`npm run bootstrap:org`; needs IdentityFeatureModule wired) ---
|
|
176
|
+
# Platform-root user uuids, comma-separated, lower-case canonical form. Read by rootIdsFromEnv()
|
|
177
|
+
# in src/app.module.ts (pass it to BOTH forRoot calls); bootstrap:org prints the uuid of each root.
|
|
178
|
+
# SHIELD_ROOT_IDS=
|
|
179
|
+
# Who is running the bootstrap (audit "on behalf of"). Required with --apply.
|
|
180
|
+
# ASTRAM_BOOTSTRAP_OPERATOR=jane.doe
|
|
181
|
+
# Password of each root user declared in the org file, one variable per username (upper-cased).
|
|
182
|
+
# Outside development/test it must be a `secret-ref:` reference (a literal fails the run);
|
|
183
|
+
# or pass --interactive to be prompted without echo.
|
|
184
|
+
# ASTRAM_BOOTSTRAP_ROOT_PASSWORD_ADMIN=secret-ref:bootstrap/root-password-admin
|
|
185
|
+
|
|
138
186
|
# --- Migrations (optional; @astram/identity's migrations run automatically) ---
|
|
139
187
|
# APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
|
|
140
188
|
# APP_MIGRATION_TABLE=schema_migrations
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
10
10
|
"build": "tsc -p tsconfig.build.json",
|
|
11
11
|
"start": "node --env-file-if-exists=.env dist/main.js",
|
|
12
|
+
"bootstrap:org": "node --env-file-if-exists=.env dist/bootstrap-org.js",
|
|
12
13
|
"dev": "node scripts/dev.mjs",
|
|
13
14
|
"test": "vitest run",
|
|
14
15
|
"migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
|
|
@@ -20,12 +21,13 @@
|
|
|
20
21
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
|
-
"@astram/core": "
|
|
24
|
-
"@astram/engine": "
|
|
25
|
-
"@astram/guard": "
|
|
26
|
-
"@astram/guide": "^0.2.
|
|
27
|
-
"@astram/identity": "
|
|
28
|
-
"@astram/
|
|
24
|
+
"@astram/core": "1.9.0",
|
|
25
|
+
"@astram/engine": "0.10.1",
|
|
26
|
+
"@astram/guard": "0.18.0",
|
|
27
|
+
"@astram/guide": "^0.2.8",
|
|
28
|
+
"@astram/identity": "0.15.0",
|
|
29
|
+
"@astram/messaging": "^0.1.1",
|
|
30
|
+
"@astram/migrate": "0.6.1",
|
|
29
31
|
"@astram/sdk": "^0.2.0",
|
|
30
32
|
"@nestjs/common": "^11.2.7",
|
|
31
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.)
|
|
@@ -52,13 +59,18 @@ import { DemoController } from './health/demo.controller';
|
|
|
52
59
|
* identity → guard), so the consumer wires it (ADR-0026):
|
|
53
60
|
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
54
61
|
* nav lights up), and import the identity module:
|
|
62
|
+
* import { rootIdsFromEnv, ShieldModule } from '@astram/guard';
|
|
55
63
|
* import { identityAuthActionKeys, identityControlPlaneKeys,
|
|
56
64
|
* IdentityFeatureModule } from '@astram/identity';
|
|
57
65
|
* imports: [
|
|
58
|
-
* ShieldModule.forRoot({ auth: true,
|
|
66
|
+
* ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(),
|
|
59
67
|
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
|
|
60
|
-
*
|
|
68
|
+
* // mounts /identity/{companies,users,roles,…}
|
|
69
|
+
* IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() }),
|
|
61
70
|
* ],
|
|
71
|
+
* `rootIdsFromEnv()` reads `SHIELD_ROOT_IDS` (comma-separated platform-root user uuids; unset
|
|
72
|
+
* means no roots), so both calls get the same list. `npm run bootstrap:org` creates your
|
|
73
|
+
* organisation from a file and needs `IdentityFeatureModule` wired (see the README).
|
|
62
74
|
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
63
75
|
* (no separate engine setup) and builds the record authorization `ShieldModule` decides
|
|
64
76
|
* with, so the guard renders REAL decisions from your roles' grants. Do NOT also pass
|
|
@@ -89,8 +101,9 @@ import { DemoController } from './health/demo.controller';
|
|
|
89
101
|
// admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
|
|
90
102
|
// blocks above for the full form + the extraObjects note for your OWN owned types):
|
|
91
103
|
// imports: [
|
|
92
|
-
// ShieldModule.forRoot({ auth: true,
|
|
93
|
-
//
|
|
104
|
+
// ShieldModule.forRoot({ auth: true, rootIds: rootIdsFromEnv(), identityActionKeys,
|
|
105
|
+
// identityControlPlaneKeys }),
|
|
106
|
+
// IdentityFeatureModule.forRoot({ rootIds: rootIdsFromEnv() /*, extraObjects: [YourType] */ }),
|
|
94
107
|
// ],
|
|
95
108
|
imports: [ShieldModule.forRoot()],
|
|
96
109
|
controllers: [HealthController, DemoController],
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line flags of `npm run bootstrap:org` (see `bootstrap-org.ts`).
|
|
3
|
+
*
|
|
4
|
+
* A pure module (no framework imports) so the flag parser is unit-testable. Unknown flags
|
|
5
|
+
* and stray arguments are refused: a typo such as `--aply` must never silently fall back
|
|
6
|
+
* to a dry run, and error text never echoes the offending value (it could be a secret).
|
|
7
|
+
*/
|
|
8
|
+
export interface BootstrapArgs {
|
|
9
|
+
/** Path of the org JSON file. */
|
|
10
|
+
file: string;
|
|
11
|
+
/** Write. Without it the run is a dry run. */
|
|
12
|
+
apply: boolean;
|
|
13
|
+
claimUnownedRoles: boolean;
|
|
14
|
+
reissueLinks: boolean;
|
|
15
|
+
/** Link lifetime in whole hours; the runner validates the range. */
|
|
16
|
+
linkTtlHours: number | undefined;
|
|
17
|
+
linksToStdout: boolean;
|
|
18
|
+
/** Email the set-password links (needs a configured emailSender) instead of printing them. */
|
|
19
|
+
emailLinks: boolean;
|
|
20
|
+
/** Prompt for the root passwords (no echo) instead of reading the environment. */
|
|
21
|
+
interactive: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class BootstrapArgsError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'BootstrapArgsError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const BOOTSTRAP_USAGE =
|
|
32
|
+
'usage: npm run bootstrap:org -- --file <path> [--apply] [--claim-unowned-roles] ' +
|
|
33
|
+
'[--reissue-links] [--link-ttl-hours <n>] [--links-to-stdout | --email-links] [--interactive]';
|
|
34
|
+
|
|
35
|
+
export function parseBootstrapArgs(argv: readonly string[]): BootstrapArgs {
|
|
36
|
+
const args: BootstrapArgs = {
|
|
37
|
+
file: '',
|
|
38
|
+
apply: false,
|
|
39
|
+
claimUnownedRoles: false,
|
|
40
|
+
reissueLinks: false,
|
|
41
|
+
linkTtlHours: undefined,
|
|
42
|
+
linksToStdout: false,
|
|
43
|
+
emailLinks: false,
|
|
44
|
+
interactive: false,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const flag = argv[i] as string;
|
|
48
|
+
switch (flag) {
|
|
49
|
+
case '--file': {
|
|
50
|
+
const value = argv[i + 1];
|
|
51
|
+
if (value === undefined || value === '' || value.startsWith('--')) {
|
|
52
|
+
throw new BootstrapArgsError('--file needs a path');
|
|
53
|
+
}
|
|
54
|
+
args.file = value;
|
|
55
|
+
i += 1;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case '--apply':
|
|
59
|
+
args.apply = true;
|
|
60
|
+
break;
|
|
61
|
+
case '--claim-unowned-roles':
|
|
62
|
+
args.claimUnownedRoles = true;
|
|
63
|
+
break;
|
|
64
|
+
case '--reissue-links':
|
|
65
|
+
args.reissueLinks = true;
|
|
66
|
+
break;
|
|
67
|
+
case '--links-to-stdout':
|
|
68
|
+
args.linksToStdout = true;
|
|
69
|
+
break;
|
|
70
|
+
case '--email-links':
|
|
71
|
+
args.emailLinks = true;
|
|
72
|
+
break;
|
|
73
|
+
case '--interactive':
|
|
74
|
+
args.interactive = true;
|
|
75
|
+
break;
|
|
76
|
+
case '--link-ttl-hours': {
|
|
77
|
+
const value = argv[i + 1];
|
|
78
|
+
if (value === undefined || !/^\d+$/.test(value)) {
|
|
79
|
+
throw new BootstrapArgsError('--link-ttl-hours needs a whole number of hours');
|
|
80
|
+
}
|
|
81
|
+
args.linkTtlHours = Number(value);
|
|
82
|
+
i += 1;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
throw new BootstrapArgsError(
|
|
87
|
+
flag.startsWith('--') ? 'unknown option' : 'unexpected argument',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
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
|
+
}
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
4
|
+
import { NestFactory } from '@nestjs/core';
|
|
5
|
+
import { resolveBootSecrets } from '@astram/guard';
|
|
6
|
+
import { OrgBootstrapError, runOrgBootstrap } from '@astram/identity';
|
|
7
|
+
import {
|
|
8
|
+
BOOTSTRAP_USAGE,
|
|
9
|
+
BootstrapArgsError,
|
|
10
|
+
parseBootstrapArgs,
|
|
11
|
+
type BootstrapArgs,
|
|
12
|
+
} from './bootstrap-org.args';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `npm run bootstrap:org -- --file org.json [--apply] [--email-links]`: creates your organisation (tenants,
|
|
16
|
+
* companies, roles, users, grants) from a declarative file, through the framework's own
|
|
17
|
+
* services, against THIS app's `AppModule` (so grants can name your own object types).
|
|
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`).
|
|
22
|
+
*
|
|
23
|
+
* No security logic lives here: `runOrgBootstrap` (from `@astram/identity`) does the work.
|
|
24
|
+
* Same boot order as `main.ts`: `resolveBootSecrets()` runs BEFORE `AppModule` is loaded,
|
|
25
|
+
* so `AppModule` is imported dynamically. Exit code 0 on success, non-zero otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Needs `IdentityFeatureModule` wired in `AppModule` (see the header of `app.module.ts`).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Prompt without echo (TTY only): the typed characters never reach the terminal. */
|
|
31
|
+
async function promptHidden(question: string): Promise<string> {
|
|
32
|
+
let muted = false;
|
|
33
|
+
const output = new Writable({
|
|
34
|
+
write(chunk, _encoding, done) {
|
|
35
|
+
if (!muted) process.stdout.write(chunk);
|
|
36
|
+
done();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
40
|
+
return new Promise<string>((resolve) => {
|
|
41
|
+
rl.question(question, (answer) => {
|
|
42
|
+
rl.close();
|
|
43
|
+
process.stdout.write('\n');
|
|
44
|
+
resolve(answer);
|
|
45
|
+
});
|
|
46
|
+
muted = true;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function run(args: BootstrapArgs): Promise<number> {
|
|
51
|
+
if (args.interactive && !process.stdin.isTTY) {
|
|
52
|
+
console.error('--interactive needs a terminal');
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
await resolveBootSecrets();
|
|
56
|
+
const { AppModule } = await import('./app.module');
|
|
57
|
+
// Warn level only: the Nest boot chatter is noise, and nothing here may log a secret.
|
|
58
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
59
|
+
logger: ['warn', 'error'],
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const report = await runOrgBootstrap(app, {
|
|
63
|
+
file: args.file,
|
|
64
|
+
apply: args.apply,
|
|
65
|
+
claimUnownedRoles: args.claimUnownedRoles,
|
|
66
|
+
reissueLinks: args.reissueLinks,
|
|
67
|
+
...(args.linkTtlHours !== undefined ? { linkTtlHours: args.linkTtlHours } : {}),
|
|
68
|
+
linksToStdout: args.linksToStdout,
|
|
69
|
+
emailLinks: args.emailLinks,
|
|
70
|
+
...(process.env.ASTRAM_BOOTSTRAP_OPERATOR !== undefined
|
|
71
|
+
? { operator: process.env.ASTRAM_BOOTSTRAP_OPERATOR }
|
|
72
|
+
: {}),
|
|
73
|
+
isTTY: process.stdout.isTTY === true,
|
|
74
|
+
out: console.log,
|
|
75
|
+
...(args.interactive
|
|
76
|
+
? {
|
|
77
|
+
promptRootPassword: (username: string) =>
|
|
78
|
+
promptHidden(`Password for root "${username}": `),
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
});
|
|
82
|
+
// Counts only: links and passwords were already shown through `out`, never repeated.
|
|
83
|
+
console.log(
|
|
84
|
+
`${report.applied ? 'applied' : 'dry run (nothing written; pass --apply to write)'}: ` +
|
|
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
|
+
', ' +
|
|
91
|
+
`${report.rootIds.length} root(s)`,
|
|
92
|
+
);
|
|
93
|
+
return 0;
|
|
94
|
+
} finally {
|
|
95
|
+
await app.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function main(): Promise<void> {
|
|
100
|
+
let args: BootstrapArgs;
|
|
101
|
+
try {
|
|
102
|
+
args = parseBootstrapArgs(process.argv.slice(2));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof BootstrapArgsError) {
|
|
105
|
+
console.error(`${err.message}\n${BOOTSTRAP_USAGE}`);
|
|
106
|
+
process.exitCode = 2;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
process.exitCode = await run(args);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
if (err instanceof OrgBootstrapError) {
|
|
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')) {
|
|
118
|
+
console.error(
|
|
119
|
+
'wire IdentityFeatureModule in AppModule before running bootstrap:org (see the header of src/app.module.ts)',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
console.error(`bootstrap:org failed [${err.code}]: ${err.message}`);
|
|
123
|
+
} else {
|
|
124
|
+
// Generic on purpose: an unexpected error can carry values from the file or the database.
|
|
125
|
+
console.error(
|
|
126
|
+
'bootstrap:org failed: unexpected error (no details printed; see the app logs)',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
void main();
|