portal-mojo 0.2.4 → 0.2.5
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/docs/README.md +2 -0
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/domains/identity.ts +7 -1
- package/src/admin/domains/operations.ts +8 -1
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
- package/src/admin/fleet-configuration/data.ts +89 -0
- package/src/admin/index.ts +3 -0
- package/src/admin/messaging/push/PushPage.tsx +61 -13
- package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
- package/src/admin/messaging/push/api.ts +25 -1
- package/src/admin/messaging/push/models.ts +7 -6
- package/src/admin/public/identity.ts +1 -0
- package/src/admin/public/operations.ts +1 -0
- package/src/admin/registry.ts +4 -2
- package/src/admin/settings/SettingDetail.tsx +2 -1
- package/src/admin/settings/SettingValue.tsx +37 -0
- package/src/admin/settings/SettingsPage.tsx +2 -1
- package/src/admin/signin/SigninPage.tsx +397 -0
- package/src/admin/signin/api.ts +83 -0
- package/src/admin/signin/index.ts +4 -0
- package/src/client/fleet-mock.ts +61 -0
- package/src/client/mock.ts +239 -10
- package/src/client/realtime.ts +12 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SettingModel, settingGroupLabel, type SettingRow,
|
|
7
7
|
} from './model';
|
|
8
8
|
import { SettingDetail } from './SettingDetail';
|
|
9
|
+
import { SettingValuePreview } from './SettingValue';
|
|
9
10
|
import { showSettingEditor } from './SettingEditor';
|
|
10
11
|
|
|
11
12
|
const COLUMNS: Column<SettingRow>[] = [
|
|
@@ -19,7 +20,7 @@ const COLUMNS: Column<SettingRow>[] = [
|
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
key: 'display_value', label: 'Value', render: (row) => (
|
|
22
|
-
<
|
|
23
|
+
<SettingValuePreview row={row} />
|
|
23
24
|
),
|
|
24
25
|
},
|
|
25
26
|
{ key: 'group', label: 'Scope', render: (row) => settingGroupLabel(row.group) },
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// System Sign-in admin (#5547): the SYSTEM login page look & feel plus the
|
|
2
|
+
// Google / Apple / GitHub OAuth provider credentials, over one endpoint
|
|
3
|
+
// (GET/POST /api/account/admin/signin, global manage_settings | admin).
|
|
4
|
+
// Deliberately simple: an explicit Save for look & feel that sends only the
|
|
5
|
+
// changed dotted paths, one Save per provider card, and an immediate Enabled
|
|
6
|
+
// switch. The group "Configure Auth" dialog stays the per-group override.
|
|
7
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
8
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
9
|
+
import { Badge, MultiSelectDropdown, toast } from '../../ui';
|
|
10
|
+
import {
|
|
11
|
+
SIGNIN_QUERY_KEY, fetchSigninSettings, getSigninPath, saveSigninAuth, saveSigninProvider,
|
|
12
|
+
type SigninOptions, type SigninProvider, type SigninProviderField, type SigninSettings,
|
|
13
|
+
} from './api';
|
|
14
|
+
|
|
15
|
+
type FieldKind = 'text' | 'url' | 'textarea' | 'css' | 'select' | 'color' | 'methods' | 'bool';
|
|
16
|
+
interface FieldSpec {
|
|
17
|
+
path: string;
|
|
18
|
+
label: string;
|
|
19
|
+
kind: FieldKind;
|
|
20
|
+
help?: string;
|
|
21
|
+
options?: keyof SigninOptions;
|
|
22
|
+
wide?: boolean;
|
|
23
|
+
}
|
|
24
|
+
interface FieldGroup { key: string; title: string; icon: string; blurb: string; fields: FieldSpec[] }
|
|
25
|
+
|
|
26
|
+
const FIELD_GROUPS: FieldGroup[] = [
|
|
27
|
+
{
|
|
28
|
+
key: 'branding', title: 'Branding', icon: 'bi-badge-tm', blurb: 'Names and images shown around the sign-in form.',
|
|
29
|
+
fields: [
|
|
30
|
+
{ path: 'theme.app_title', label: 'App title', kind: 'text', help: 'Brand name in the page header and browser tab.' },
|
|
31
|
+
{ path: 'theme.auth_provider_name', label: 'Sign-in provider name', kind: 'text', help: 'Who is signing the user in, e.g. "Acme Account".' },
|
|
32
|
+
{ path: 'theme.logo_url', label: 'Logo URL', kind: 'url' },
|
|
33
|
+
{ path: 'theme.favicon_url', label: 'Favicon URL', kind: 'url' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: 'layout', title: 'Layout & color', icon: 'bi-palette', blurb: 'Overall arrangement, light/dark behavior and accent color.',
|
|
38
|
+
fields: [
|
|
39
|
+
{ path: 'theme.layout', label: 'Layout', kind: 'select', options: 'layouts' },
|
|
40
|
+
{ path: 'theme.appearance', label: 'Appearance', kind: 'select', options: 'appearances', help: 'System follows the visitor’s device setting.' },
|
|
41
|
+
{ path: 'theme.accent_color', label: 'Accent color', kind: 'color', help: 'Buttons and links on the sign-in page.' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'hero', title: 'Hero panel', icon: 'bi-image', blurb: 'The large image and headline beside the form (panel layouts).',
|
|
46
|
+
fields: [
|
|
47
|
+
{ path: 'theme.hero_headline', label: 'Headline', kind: 'text' },
|
|
48
|
+
{ path: 'theme.hero_subheadline', label: 'Subheadline', kind: 'text' },
|
|
49
|
+
{ path: 'theme.hero_image_url', label: 'Image URL', kind: 'url', help: 'Used when no light/dark variant is set.', wide: true },
|
|
50
|
+
{ path: 'theme.hero_image_url_light', label: 'Image URL (light)', kind: 'url' },
|
|
51
|
+
{ path: 'theme.hero_image_url_dark', label: 'Image URL (dark)', kind: 'url' },
|
|
52
|
+
{ path: 'theme.hero_image_position', label: 'Image focus', kind: 'select', options: 'hero_image_positions' },
|
|
53
|
+
{ path: 'theme.back_to_website_label', label: 'Back-to-website label', kind: 'text' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'login', title: 'Sign-in form', icon: 'bi-box-arrow-in-right', blurb: 'Heading, supporting text and the ways people can sign in.',
|
|
58
|
+
fields: [
|
|
59
|
+
{ path: 'login.heading', label: 'Heading', kind: 'text' },
|
|
60
|
+
{ path: 'login.methods', label: 'Sign-in methods', kind: 'methods', options: 'login_methods', help: 'Password always stays on so admins can get in.' },
|
|
61
|
+
{ path: 'login.supporting_copy', label: 'Supporting text', kind: 'textarea', wide: true },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: 'registration', title: 'Registration', icon: 'bi-person-plus', blurb: 'Whether new people can sign up, and how.',
|
|
66
|
+
fields: [
|
|
67
|
+
{ path: 'registration.enabled', label: 'Allow sign-up', kind: 'bool', help: 'When off, the registration page is hidden.', wide: true },
|
|
68
|
+
{ path: 'registration.methods', label: 'Sign-up methods', kind: 'methods', options: 'registration_methods' },
|
|
69
|
+
{ path: 'registration.passkey_prompt', label: 'Passkey prompt after sign-up', kind: 'select', options: 'passkey_prompts' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'css', title: 'Custom CSS', icon: 'bi-braces', blurb: 'Inline CSS added after the theme stylesheet.',
|
|
74
|
+
fields: [
|
|
75
|
+
{ path: 'theme.custom_css', label: 'Custom CSS', kind: 'css', help: 'No external URLs, @import or HTML.', wide: true },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const TOKEN_LABELS: Record<string, string> = {
|
|
81
|
+
password: 'Password', sms: 'SMS code', passkey: 'Passkey', magic: 'Magic link',
|
|
82
|
+
google: 'Google', apple: 'Apple', github: 'GitHub',
|
|
83
|
+
'branded-panel': 'Branded panel',
|
|
84
|
+
};
|
|
85
|
+
const tokenLabel = (token: string) => TOKEN_LABELS[token] ?? token.charAt(0).toUpperCase() + token.slice(1).replace(/-/g, ' ');
|
|
86
|
+
const LAYOUT_ALIASES: Record<string, string> = { card: 'compact', fullscreen: 'branded-panel' };
|
|
87
|
+
const PROVIDER_ICONS: Record<string, string> = { google: 'bi-google', apple: 'bi-apple', github: 'bi-github' };
|
|
88
|
+
const SOURCE_LABELS: Record<SigninProviderField['source'], string> = {
|
|
89
|
+
admin: 'Set in Admin', deployment: 'Set in server config', none: 'Not set',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function errorMessage(reason: unknown, fallback: string): string {
|
|
93
|
+
return reason instanceof Error && reason.message ? reason.message : fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The value the form shows for a path when it has no local edit. */
|
|
97
|
+
function baselineValue(settings: SigninSettings, spec: FieldSpec): unknown {
|
|
98
|
+
const raw = getSigninPath(settings.auth, spec.path);
|
|
99
|
+
if (spec.kind === 'bool') return raw === true;
|
|
100
|
+
if (spec.kind === 'methods') {
|
|
101
|
+
const list = Array.isArray(raw) ? raw.map(String) : [];
|
|
102
|
+
return spec.path === 'login.methods' && !list.includes('password') ? ['password', ...list] : list;
|
|
103
|
+
}
|
|
104
|
+
if (spec.kind === 'select') {
|
|
105
|
+
const options = settings.options[spec.options!] ?? [];
|
|
106
|
+
const value = spec.path === 'theme.layout' ? LAYOUT_ALIASES[String(raw)] ?? String(raw ?? '') : String(raw ?? '');
|
|
107
|
+
if (options.includes(value)) return value;
|
|
108
|
+
console.warn(`[signin] ${spec.path} value "${String(raw)}" is not one of ${options.join(', ')}; showing "${options[0] ?? ''}"`);
|
|
109
|
+
return options[0] ?? '';
|
|
110
|
+
}
|
|
111
|
+
return raw == null ? '' : String(raw);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
115
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
116
|
+
const left = [...(Array.isArray(a) ? a : [])].map(String).sort();
|
|
117
|
+
const right = [...(Array.isArray(b) ? b : [])].map(String).sort();
|
|
118
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
119
|
+
}
|
|
120
|
+
return a === b;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function LookAndFeel({ settings, onSaved }: { settings: SigninSettings; onSaved: (next: SigninSettings) => void }) {
|
|
124
|
+
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
|
125
|
+
const [busy, setBusy] = useState(false);
|
|
126
|
+
const [error, setError] = useState('');
|
|
127
|
+
const editable = new Set(settings.editable);
|
|
128
|
+
const groups = FIELD_GROUPS
|
|
129
|
+
.map((group) => ({ ...group, fields: group.fields.filter((field) => editable.has(field.path)) }))
|
|
130
|
+
.filter((group) => group.fields.length > 0);
|
|
131
|
+
const specs = groups.flatMap((group) => group.fields);
|
|
132
|
+
const valueOf = (spec: FieldSpec) => spec.path in edits ? edits[spec.path] : baselineValue(settings, spec);
|
|
133
|
+
const changed = specs.filter((spec) => spec.path in edits && !sameValue(edits[spec.path], baselineValue(settings, spec)));
|
|
134
|
+
const set = (path: string, value: unknown) => { setEdits((current) => ({ ...current, [path]: value })); setError(''); };
|
|
135
|
+
|
|
136
|
+
const save = async () => {
|
|
137
|
+
if (changed.length === 0) return;
|
|
138
|
+
const payload = Object.fromEntries(changed.map((spec) => [spec.path, valueOf(spec)]));
|
|
139
|
+
setBusy(true);
|
|
140
|
+
setError('');
|
|
141
|
+
try {
|
|
142
|
+
const next = await saveSigninAuth(payload);
|
|
143
|
+
setEdits({});
|
|
144
|
+
onSaved(next);
|
|
145
|
+
toast.success('Sign-in page saved');
|
|
146
|
+
} catch (reason) {
|
|
147
|
+
const message = errorMessage(reason, 'Sign-in page could not be saved');
|
|
148
|
+
setError(message);
|
|
149
|
+
toast.error(message);
|
|
150
|
+
} finally {
|
|
151
|
+
setBusy(false);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const control = (spec: FieldSpec): ReactNode => {
|
|
156
|
+
const id = `signin-${spec.path.replace('.', '-')}`;
|
|
157
|
+
const value = valueOf(spec);
|
|
158
|
+
const help = spec.help ? <span className="field-help">{spec.help}</span> : null;
|
|
159
|
+
const className = `field${spec.wide ? ' signin-field-span' : ''}`;
|
|
160
|
+
if (spec.kind === 'methods') {
|
|
161
|
+
const options = (settings.options[spec.options!] ?? []).map((token) => ({
|
|
162
|
+
value: token,
|
|
163
|
+
label: spec.path === 'login.methods' && token === 'password' ? 'Password (always on)' : tokenLabel(token),
|
|
164
|
+
disabled: spec.path === 'login.methods' && token === 'password',
|
|
165
|
+
}));
|
|
166
|
+
return <div className={className} key={spec.path}>
|
|
167
|
+
<MultiSelectDropdown label={spec.label} help={spec.help} options={options} value={value as string[]}
|
|
168
|
+
onChange={(next) => set(spec.path, next.map(String))} maxLabelsToShow={4} />
|
|
169
|
+
</div>;
|
|
170
|
+
}
|
|
171
|
+
if (spec.kind === 'bool') {
|
|
172
|
+
return <label className={`signin-toggle${spec.wide ? ' signin-field-span' : ''}`} key={spec.path}>
|
|
173
|
+
<span><strong>{spec.label}</strong>{spec.help && <small>{spec.help}</small>}</span>
|
|
174
|
+
<input type="checkbox" role="switch" className="switch" checked={value === true} onChange={(event) => set(spec.path, event.target.checked)} />
|
|
175
|
+
</label>;
|
|
176
|
+
}
|
|
177
|
+
if (spec.kind === 'select') {
|
|
178
|
+
return <label className={className} key={spec.path}>
|
|
179
|
+
<span className="field-label">{spec.label}</span>
|
|
180
|
+
<select id={id} className="input" value={String(value)} onChange={(event) => set(spec.path, event.target.value)}>
|
|
181
|
+
{(settings.options[spec.options!] ?? []).map((token) => <option key={token} value={token}>{tokenLabel(token)}</option>)}
|
|
182
|
+
</select>
|
|
183
|
+
{help}
|
|
184
|
+
</label>;
|
|
185
|
+
}
|
|
186
|
+
if (spec.kind === 'color') {
|
|
187
|
+
const text = String(value);
|
|
188
|
+
const valid = /^#[0-9a-fA-F]{6}$/.test(text);
|
|
189
|
+
return <div className={className} key={spec.path}>
|
|
190
|
+
<label className="field-label" htmlFor={id}>{spec.label}</label>
|
|
191
|
+
<div className="signin-color-row">
|
|
192
|
+
<input type="color" className="signin-color-swatch" aria-label={`${spec.label} picker`} value={valid ? text : '#6384ff'} onChange={(event) => set(spec.path, event.target.value)} />
|
|
193
|
+
<input id={id} className="input" value={text} spellCheck={false} onChange={(event) => set(spec.path, event.target.value.trim())} placeholder="#6384ff" />
|
|
194
|
+
</div>
|
|
195
|
+
{valid ? help : <span className="field-error">Use a #rrggbb color.</span>}
|
|
196
|
+
</div>;
|
|
197
|
+
}
|
|
198
|
+
if (spec.kind === 'textarea' || spec.kind === 'css') {
|
|
199
|
+
return <label className={className} key={spec.path}>
|
|
200
|
+
<span className="field-label">{spec.label}</span>
|
|
201
|
+
<textarea id={id} className={`input${spec.kind === 'css' ? ' signin-code' : ''}`} rows={spec.kind === 'css' ? 6 : 3}
|
|
202
|
+
spellCheck={spec.kind !== 'css'} value={String(value)} onChange={(event) => set(spec.path, event.target.value)} />
|
|
203
|
+
{help}
|
|
204
|
+
</label>;
|
|
205
|
+
}
|
|
206
|
+
return <label className={className} key={spec.path}>
|
|
207
|
+
<span className="field-label">{spec.label}</span>
|
|
208
|
+
<input id={id} className="input" type={spec.kind === 'url' ? 'url' : 'text'} value={String(value)}
|
|
209
|
+
placeholder={spec.kind === 'url' ? 'https://…' : undefined} onChange={(event) => set(spec.path, event.target.value)} />
|
|
210
|
+
{help}
|
|
211
|
+
</label>;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const colorInvalid = changed.some((spec) => spec.kind === 'color' && !/^#[0-9a-fA-F]{6}$/.test(String(valueOf(spec))));
|
|
215
|
+
return <section className="panel signin-card" aria-labelledby="signin-look-heading">
|
|
216
|
+
<header className="signin-card-header">
|
|
217
|
+
<div>
|
|
218
|
+
<h2 id="signin-look-heading" className="panel-title">Look & feel</h2>
|
|
219
|
+
<p className="dim">The system sign-in page. Groups with their own “Configure Auth” settings override these.</p>
|
|
220
|
+
</div>
|
|
221
|
+
</header>
|
|
222
|
+
<div className="signin-groups">
|
|
223
|
+
{groups.map((group) => <section className="signin-group" key={group.key} aria-labelledby={`signin-group-${group.key}`}>
|
|
224
|
+
<div className="signin-group-heading">
|
|
225
|
+
<span className="signin-group-icon"><i className={`bi ${group.icon}`} /></span>
|
|
226
|
+
<div><h3 id={`signin-group-${group.key}`}>{group.title}</h3><p>{group.blurb}</p></div>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="signin-field-grid">{group.fields.map(control)}</div>
|
|
229
|
+
</section>)}
|
|
230
|
+
</div>
|
|
231
|
+
{error && <div className="form-alert signin-save-error" role="alert">{error}</div>}
|
|
232
|
+
<footer className="signin-save-bar">
|
|
233
|
+
<span className={changed.length ? 'signin-dirty' : 'dim'}>
|
|
234
|
+
{changed.length ? `${changed.length} unsaved change${changed.length === 1 ? '' : 's'}` : 'All changes saved'}
|
|
235
|
+
</span>
|
|
236
|
+
<button type="button" className="btn" disabled={busy || changed.length === 0} onClick={() => { setEdits({}); setError(''); }}>Discard</button>
|
|
237
|
+
<button type="button" className="btn btn-primary" disabled={busy || changed.length === 0 || colorInvalid} onClick={() => void save()}>
|
|
238
|
+
{busy ? 'Saving…' : 'Save'}
|
|
239
|
+
</button>
|
|
240
|
+
</footer>
|
|
241
|
+
</section>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Clipboard API first; where it is denied, select the field and fall back to execCommand. */
|
|
245
|
+
function CopyButton({ text, label, inputId }: { text: string; label: string; inputId: string }) {
|
|
246
|
+
const [copied, setCopied] = useState(false);
|
|
247
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
248
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
249
|
+
const legacyCopy = () => {
|
|
250
|
+
const input = document.getElementById(inputId) as HTMLInputElement | null;
|
|
251
|
+
if (!input) return false;
|
|
252
|
+
input.focus();
|
|
253
|
+
input.select();
|
|
254
|
+
try { return document.execCommand('copy'); } catch { return false; }
|
|
255
|
+
};
|
|
256
|
+
const copy = async () => {
|
|
257
|
+
let ok = false;
|
|
258
|
+
try {
|
|
259
|
+
if (!navigator.clipboard) throw new Error('Clipboard unavailable');
|
|
260
|
+
await navigator.clipboard.writeText(text);
|
|
261
|
+
ok = true;
|
|
262
|
+
} catch {
|
|
263
|
+
ok = legacyCopy();
|
|
264
|
+
}
|
|
265
|
+
if (!ok) { toast.error('Copy was blocked — the URL is selected, press Ctrl/⌘+C'); return; }
|
|
266
|
+
setCopied(true);
|
|
267
|
+
toast.success(`${label} copied`);
|
|
268
|
+
if (timer.current) clearTimeout(timer.current);
|
|
269
|
+
timer.current = setTimeout(() => setCopied(false), 1500);
|
|
270
|
+
};
|
|
271
|
+
return <button type="button" className="btn" onClick={() => void copy()} aria-label={`Copy ${label}`}>
|
|
272
|
+
<i className={`bi ${copied ? 'bi-check2' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
|
|
273
|
+
</button>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function ProviderCard({ provider, onSaved }: { provider: SigninProvider; onSaved: (next: SigninSettings) => void }) {
|
|
277
|
+
const [edits, setEdits] = useState<Record<string, string>>({});
|
|
278
|
+
const [busy, setBusy] = useState(false);
|
|
279
|
+
const [error, setError] = useState('');
|
|
280
|
+
const displayed = (field: SigninProviderField) => field.key in edits ? edits[field.key]! : field.secret ? '' : field.value ?? '';
|
|
281
|
+
const dirty = provider.fields.filter((field) => field.key in edits && edits[field.key] !== (field.secret ? '' : field.value ?? '') && edits[field.key]!.trim() !== '');
|
|
282
|
+
|
|
283
|
+
const run = async (change: Parameters<typeof saveSigninProvider>[0], success: string) => {
|
|
284
|
+
setBusy(true);
|
|
285
|
+
setError('');
|
|
286
|
+
try {
|
|
287
|
+
const next = await saveSigninProvider(change);
|
|
288
|
+
onSaved(next);
|
|
289
|
+
toast.success(success);
|
|
290
|
+
return true;
|
|
291
|
+
} catch (reason) {
|
|
292
|
+
const message = errorMessage(reason, `${provider.label} settings could not be saved`);
|
|
293
|
+
setError(message);
|
|
294
|
+
toast.error(message);
|
|
295
|
+
return false;
|
|
296
|
+
} finally {
|
|
297
|
+
setBusy(false);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const save = async () => {
|
|
301
|
+
const values = Object.fromEntries(dirty.map((field) => [field.key, edits[field.key]!]));
|
|
302
|
+
if (await run({ provider: provider.name, values }, `${provider.label} credentials saved`)) setEdits({});
|
|
303
|
+
};
|
|
304
|
+
const clear = async (field: SigninProviderField) => {
|
|
305
|
+
if (!window.confirm(`Clear the stored ${provider.label} ${field.label}?`)) return;
|
|
306
|
+
if (await run({ provider: provider.name, values: { [field.key]: null } }, `${provider.label} ${field.label} cleared`)) {
|
|
307
|
+
setEdits(({ [field.key]: _removed, ...rest }) => rest);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return <section className="panel signin-provider" aria-labelledby={`signin-provider-${provider.name}`}>
|
|
312
|
+
<header className="signin-provider-header">
|
|
313
|
+
<span className="signin-provider-icon"><i className={`bi ${PROVIDER_ICONS[provider.name] ?? 'bi-key'}`} /></span>
|
|
314
|
+
<div className="signin-provider-title">
|
|
315
|
+
<h3 id={`signin-provider-${provider.name}`}>{provider.label}</h3>
|
|
316
|
+
{provider.ready ? <Badge tone="success">Ready</Badge> : <Badge tone="warning">Needs setup</Badge>}
|
|
317
|
+
</div>
|
|
318
|
+
<label className="signin-provider-switch">
|
|
319
|
+
<span>{provider.enabled ? 'Enabled' : 'Off'}</span>
|
|
320
|
+
<input type="checkbox" role="switch" className="switch" aria-label={`Enable ${provider.label} sign-in`} checked={provider.enabled} disabled={busy}
|
|
321
|
+
onChange={(event) => void run({ provider: provider.name, enabled: event.target.checked }, `${provider.label} sign-in ${event.target.checked ? 'enabled' : 'turned off'}`)} />
|
|
322
|
+
</label>
|
|
323
|
+
</header>
|
|
324
|
+
<p className="signin-provider-help">{provider.help} {provider.console_url && <a href={provider.console_url} target="_blank" rel="noreferrer">Open {provider.label} console <i className="bi bi-box-arrow-up-right" /></a>}</p>
|
|
325
|
+
{provider.enabled && !provider.ready && <div className="signin-note is-warning" role="status">
|
|
326
|
+
<i className="bi bi-exclamation-triangle" />
|
|
327
|
+
<span>Shown on the sign-in page, but it will not work until these are set: {provider.missing.join(', ')}.</span>
|
|
328
|
+
</div>}
|
|
329
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
330
|
+
<div className="field">
|
|
331
|
+
<label className="field-label" htmlFor={`signin-${provider.name}-callback`}>Callback URL</label>
|
|
332
|
+
<div className="signin-inline-row">
|
|
333
|
+
<input id={`signin-${provider.name}-callback`} className="input signin-code" readOnly value={provider.callback_url} onFocus={(event) => event.target.select()} />
|
|
334
|
+
<CopyButton text={provider.callback_url} label={`${provider.label} callback URL`} inputId={`signin-${provider.name}-callback`} />
|
|
335
|
+
</div>
|
|
336
|
+
<span className="field-help">Paste this into the {provider.label} console as the redirect / callback URL.</span>
|
|
337
|
+
</div>
|
|
338
|
+
{provider.fields.map((field) => {
|
|
339
|
+
const id = `signin-${provider.name}-${field.key}`;
|
|
340
|
+
const placeholder = field.secret
|
|
341
|
+
? field.configured ? 'Leave blank to keep the stored value' : field.multiline ? 'Paste the full key, including the BEGIN and END lines' : 'Enter value'
|
|
342
|
+
: field.source === 'deployment' ? 'Set in server config' : 'Enter value';
|
|
343
|
+
const status = field.secret && field.configured
|
|
344
|
+
? `${SOURCE_LABELS[field.source]}${field.hint ? ` · ends ${field.hint}` : ''}`
|
|
345
|
+
: SOURCE_LABELS[field.source];
|
|
346
|
+
return <div className="field" key={field.key}>
|
|
347
|
+
<div className="signin-field-label-row">
|
|
348
|
+
<label className="field-label" htmlFor={id}>{field.label}</label>
|
|
349
|
+
<span className={`signin-source signin-source-${field.source}`}>{status}</span>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="signin-inline-row">
|
|
352
|
+
{field.multiline
|
|
353
|
+
? <textarea id={id} className="input signin-code" rows={4} spellCheck={false} autoComplete="off" value={displayed(field)}
|
|
354
|
+
placeholder={placeholder} disabled={busy} onChange={(event) => setEdits({ ...edits, [field.key]: event.target.value })} />
|
|
355
|
+
: <input id={id} className="input" type={field.secret ? 'password' : 'text'} autoComplete={field.secret ? 'new-password' : 'off'}
|
|
356
|
+
spellCheck={false} value={displayed(field)} placeholder={placeholder} disabled={busy}
|
|
357
|
+
onChange={(event) => setEdits({ ...edits, [field.key]: event.target.value })} />}
|
|
358
|
+
{field.source === 'admin' && field.configured && <button type="button" className="btn" disabled={busy} onClick={() => void clear(field)}>Clear…</button>}
|
|
359
|
+
</div>
|
|
360
|
+
</div>;
|
|
361
|
+
})}
|
|
362
|
+
<footer className="signin-provider-actions">
|
|
363
|
+
<button type="button" className="btn" disabled={busy || Object.keys(edits).length === 0} onClick={() => { setEdits({}); setError(''); }}>Discard</button>
|
|
364
|
+
<button type="button" className="btn btn-primary" disabled={busy || dirty.length === 0} onClick={() => void save()}>{busy ? 'Saving…' : `Save ${provider.label}`}</button>
|
|
365
|
+
</footer>
|
|
366
|
+
</section>;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function SigninPage() {
|
|
370
|
+
const qc = useQueryClient();
|
|
371
|
+
const query = useQuery({ queryKey: SIGNIN_QUERY_KEY, queryFn: fetchSigninSettings });
|
|
372
|
+
const onSaved = (next: SigninSettings) => qc.setQueryData(SIGNIN_QUERY_KEY, next);
|
|
373
|
+
return <div className="signin-page">
|
|
374
|
+
<div className="signin-heading">
|
|
375
|
+
<p className="eyebrow">Identity & Access</p>
|
|
376
|
+
<h1>Sign-in</h1>
|
|
377
|
+
<p className="dim">How the system login page looks, and which social sign-in providers it offers.</p>
|
|
378
|
+
</div>
|
|
379
|
+
{query.isLoading && <div className="panel panel-pad" role="status"><span className="spinner" aria-hidden /> Loading sign-in settings…</div>}
|
|
380
|
+
{query.isError && <div className="panel panel-pad">
|
|
381
|
+
<div className="form-alert" role="alert">{errorMessage(query.error, 'Sign-in settings could not be loaded')}</div>
|
|
382
|
+
<button type="button" className="btn" onClick={() => void query.refetch()}>Retry</button>
|
|
383
|
+
</div>}
|
|
384
|
+
{query.data && <>
|
|
385
|
+
<LookAndFeel settings={query.data} onSaved={onSaved} />
|
|
386
|
+
<section aria-labelledby="signin-providers-heading" className="signin-providers-section">
|
|
387
|
+
<div className="signin-section-heading">
|
|
388
|
+
<h2 id="signin-providers-heading" className="panel-title">Providers</h2>
|
|
389
|
+
<p className="dim">Social sign-in buttons. Blank secret fields keep what is stored.</p>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="signin-provider-grid">
|
|
392
|
+
{query.data.providers.map((provider) => <ProviderCard key={provider.name} provider={provider} onSaved={onSaved} />)}
|
|
393
|
+
</div>
|
|
394
|
+
</section>
|
|
395
|
+
</>}
|
|
396
|
+
</div>;
|
|
397
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mojoCall } from '../../client/runtime';
|
|
2
|
+
|
|
3
|
+
/** Global Admin gate for `/api/account/admin/signin` (manage_settings | admin). */
|
|
4
|
+
export const SIGNIN_ADMIN_PERMISSIONS = ['sys.manage_settings', 'sys.admin'];
|
|
5
|
+
export const SIGNIN_ENDPOINT = '/api/account/admin/signin';
|
|
6
|
+
export const SIGNIN_QUERY_KEY = ['admin', 'signin'] as const;
|
|
7
|
+
|
|
8
|
+
export type SigninFieldSource = 'admin' | 'deployment' | 'none';
|
|
9
|
+
|
|
10
|
+
export interface SigninProviderField {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
secret: boolean;
|
|
14
|
+
multiline: boolean;
|
|
15
|
+
configured: boolean;
|
|
16
|
+
source: SigninFieldSource;
|
|
17
|
+
/** Always null for secret fields. */
|
|
18
|
+
value: string | null;
|
|
19
|
+
/** e.g. "…a1b2" for a stored secret. */
|
|
20
|
+
hint: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SigninProvider {
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
ready: boolean;
|
|
28
|
+
missing: string[];
|
|
29
|
+
callback_url: string;
|
|
30
|
+
console_url: string;
|
|
31
|
+
help: string;
|
|
32
|
+
fields: SigninProviderField[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SigninOptions {
|
|
36
|
+
login_methods: string[];
|
|
37
|
+
registration_methods: string[];
|
|
38
|
+
layouts: string[];
|
|
39
|
+
appearances: string[];
|
|
40
|
+
hero_image_positions: string[];
|
|
41
|
+
passkey_prompts: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SigninSettings {
|
|
45
|
+
schema_version: number;
|
|
46
|
+
/** public_auth_config of the resolved SYSTEM config: {theme, login, registration}. */
|
|
47
|
+
auth: Record<string, Record<string, unknown>>;
|
|
48
|
+
/** Flat dotted paths the POST accepts under `auth`. */
|
|
49
|
+
editable: string[];
|
|
50
|
+
options: SigninOptions;
|
|
51
|
+
providers: SigninProvider[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Provider credential edit: omitted or "" keeps the stored value, null clears it.
|
|
56
|
+
* `enabled` adds/removes the provider from login + registration methods.
|
|
57
|
+
*/
|
|
58
|
+
export interface SigninProviderChange {
|
|
59
|
+
provider: string;
|
|
60
|
+
values?: Record<string, string | null>;
|
|
61
|
+
enabled?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fetchSigninSettings(): Promise<SigninSettings> {
|
|
65
|
+
const body = await mojoCall(SIGNIN_ENDPOINT);
|
|
66
|
+
return body.data as SigninSettings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Save changed look-and-feel paths; returns the full refreshed payload. */
|
|
70
|
+
export async function saveSigninAuth(changes: Record<string, unknown>): Promise<SigninSettings> {
|
|
71
|
+
const body = await mojoCall(SIGNIN_ENDPOINT, { method: 'POST', body: { auth: changes } });
|
|
72
|
+
return body.data as SigninSettings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function saveSigninProvider(change: SigninProviderChange): Promise<SigninSettings> {
|
|
76
|
+
const body = await mojoCall(SIGNIN_ENDPOINT, { method: 'POST', body: { ...change } });
|
|
77
|
+
return body.data as SigninSettings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getSigninPath(auth: SigninSettings['auth'], path: string): unknown {
|
|
81
|
+
const [section, key] = path.split('.') as [string, string];
|
|
82
|
+
return auth[section]?.[key];
|
|
83
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Mock-only executable Fleet Configuration contract. Never imported by packaged runtime.
|
|
2
|
+
import type { FleetEntry, FleetState, FleetOperation, FleetChanges } from '../admin/fleet-configuration/data';
|
|
3
|
+
const health_scope = 'request_service_jobs_and_dependencies';
|
|
4
|
+
const endpoint = '/api/account/admin/fleet';
|
|
5
|
+
const entries: FleetEntry[] = [
|
|
6
|
+
{ key: 'GEOIP_SMART_ENABLED', label: 'Smart IP lookup', section: 'Location', description: 'Enable registered IP lookup providers.', value_type: 'boolean', default: true, current: true, sensitive: false, restart_required: true, overridden: true },
|
|
7
|
+
{ key: 'EXAMPLE_WORKER_LIMIT', label: 'Worker limit', section: 'Application', description: 'Example application-owned setting.', value_type: 'integer', default: 4, current: 4, min_value: 1, max_value: 32, sensitive: false, restart_required: true, overridden: true },
|
|
8
|
+
{ key: 'EXAMPLE_SERVICE_TOKEN', label: 'Service token', section: 'Application', description: 'Write-only credential for the example service.', value_type: 'string', sensitive: true, configured: true, restart_required: true, overridden: true },
|
|
9
|
+
];
|
|
10
|
+
let sequence = 1;
|
|
11
|
+
let revision = sequence.toString(16).padStart(32, '0');
|
|
12
|
+
let values: Record<string, unknown> = { GEOIP_SMART_ENABLED: true, EXAMPLE_WORKER_LIMIT: 4, EXAMPLE_SERVICE_TOKEN: 'mock-private-value' };
|
|
13
|
+
const versions = [{ version_id: 'mock-version-1', revision, values: { ...values }, published_at: new Date().toISOString() }];
|
|
14
|
+
let operation: FleetOperation | null = null;
|
|
15
|
+
const nodes = (target: string, healthy = false) => ['node-a', 'node-b'].map((hostname, index) => ({ hostname, revision: target, published: true,
|
|
16
|
+
installed: healthy || index === 0, restart_requested: healthy || index === 0, restarted: healthy,
|
|
17
|
+
healthy, status: healthy ? 'healthy' : index === 0 ? 'pending' : 'unknown', error_code: healthy || index === 0 ? null : 'node_did_not_reply' }));
|
|
18
|
+
const ok = (data: unknown) => ({ status: true, data });
|
|
19
|
+
const fail = (error: string, error_code = 400) => ({ status: false, error, error_code });
|
|
20
|
+
export function fleetMock(path: string, method: string, body: Record<string, unknown> | undefined, superuser: boolean): unknown {
|
|
21
|
+
if (!superuser) return fail('Superuser required', 403);
|
|
22
|
+
if (method === 'GET' && path === endpoint) {
|
|
23
|
+
const state: FleetState = { schema_version: 1, revision, version_id: versions[0].version_id, entries: entries.map(entry => ({ ...entry,
|
|
24
|
+
overridden: entry.key in values, ...(entry.sensitive ? { configured: Boolean(values[entry.key]) } : { current: values[entry.key] ?? entry.default }) })),
|
|
25
|
+
published: true, loaded_revision: null, pending_restart: true, publish_configured: true,
|
|
26
|
+
fleet: { status: 'pending', health_scope, nodes: nodes(revision), healthy_everywhere: false, observed_at: new Date().toISOString() } };
|
|
27
|
+
return ok(state);
|
|
28
|
+
}
|
|
29
|
+
if (method === 'GET' && path === `${endpoint}/history`) return ok({ versions: versions.map((version, index) => ({ version_id: version.version_id, current: index === 0, published_at: version.published_at })), truncated: false });
|
|
30
|
+
if (method === 'GET' && path === `${endpoint}/operation/${operation?.operation_id}`) return ok(operation);
|
|
31
|
+
if (method !== 'POST' || path !== endpoint || !body) return fail('Not found', 404);
|
|
32
|
+
if (body.expected_revision !== revision) return fail('Fleet configuration changed; reload before publishing', 409);
|
|
33
|
+
if (body.action === 'apply') {
|
|
34
|
+
operation = { health_scope, operation_id: 'a'.repeat(32), revision, status: 'timed_out', job_status: 'completed', nodes: nodes(revision), healthy_everywhere: false };
|
|
35
|
+
return ok(operation);
|
|
36
|
+
}
|
|
37
|
+
if (body.action === 'restore') {
|
|
38
|
+
const prior = versions.find(version => version.version_id === body.version_id);
|
|
39
|
+
if (!prior) return fail('Version not found');
|
|
40
|
+
values = { ...prior.values };
|
|
41
|
+
} else if (body.action === 'publish') {
|
|
42
|
+
const changes = body.changes as FleetChanges;
|
|
43
|
+
if (!changes || !Object.keys(changes).length) return fail('Changes are required');
|
|
44
|
+
const next = { ...values };
|
|
45
|
+
for (const [key, change] of Object.entries(changes)) {
|
|
46
|
+
const entry = entries.find(item => item.key === key);
|
|
47
|
+
if (!entry) return fail('Setting is not delegated');
|
|
48
|
+
if (change.action === 'clear') delete next[key];
|
|
49
|
+
else if (change.action === 'set') {
|
|
50
|
+
if (entry.sensitive && change.value === '') continue;
|
|
51
|
+
if (entry.value_type === 'integer' && (!Number.isSafeInteger(change.value) || Number(change.value) < 1 || Number(change.value) > 32)) return fail('Invalid setting value');
|
|
52
|
+
if (entry.value_type === 'boolean' && typeof change.value !== 'boolean') return fail('Invalid setting value');
|
|
53
|
+
next[key] = change.value;
|
|
54
|
+
} else return fail('Invalid change');
|
|
55
|
+
}
|
|
56
|
+
values = next;
|
|
57
|
+
} else return fail('Unsupported action');
|
|
58
|
+
revision = (++sequence).toString(16).padStart(32, '0');
|
|
59
|
+
versions.unshift({ version_id: `mock-version-${sequence}`, revision, values: { ...values }, published_at: new Date().toISOString() });
|
|
60
|
+
return ok({ published: true, revision, version_id: versions[0].version_id, pending_restart: true, applied: false });
|
|
61
|
+
}
|