portal-mojo 0.2.4 → 0.2.6
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-identity-directories-sources.json +130 -0
- package/docs/admin-identity-directories.md +60 -0
- package/docs/admin-identity-users.md +6 -1
- package/docs/admin-members.md +16 -0
- package/docs/admin-network-security.md +1 -1
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/menus-and-access.md +6 -2
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/core/index.ts +4 -4
- package/src/admin/domains/identity.ts +25 -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/identity/groups/GroupDetail.tsx +287 -0
- package/src/admin/identity/groups/GroupsPage.tsx +125 -0
- package/src/admin/identity/groups/group-permissions.ts +5 -0
- package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
- package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
- package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
- package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
- package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
- package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
- package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
- package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
- package/src/admin/identity/groups/group-sections/models.ts +110 -0
- package/src/admin/identity/groups/group-sections/shared.ts +21 -0
- package/src/admin/identity/groups/index.ts +6 -0
- package/src/admin/identity/groups/models.ts +70 -0
- package/src/admin/identity/index.ts +1 -0
- package/src/admin/identity/members/MemberDetail.tsx +32 -3
- package/src/admin/identity/members/models.ts +3 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
- package/src/admin/identity/users/UserDetail.tsx +2 -2
- package/src/admin/identity/users/index.ts +2 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
- 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 +6 -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
- package/src/ui/SidebarNav.tsx +2 -2
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// group-sections/AuthConfigDialog.tsx — GroupAuthConfigSection port
|
|
2
|
+
// (admin/account/groups/GroupAuthConfigSection.js, all 699 lines read
|
|
3
|
+
// 2026-08-05): the form-based editor for a Group's `metadata.auth_config` —
|
|
4
|
+
// the per-group structured config driving the django-mojo–hosted login,
|
|
5
|
+
// registration, and passkey pages. Resolved server-side as: code defaults
|
|
6
|
+
// <- deployment AUTH_CONFIG <- group.metadata.auth_config, deep-merged down
|
|
7
|
+
// the parent chain. This dialog edits the group's OWN override only.
|
|
8
|
+
//
|
|
9
|
+
// Load: each field shows the group's own override if present, else the
|
|
10
|
+
// resolved/inherited value (GET /api/auth/config?group_uuid=). Text/int
|
|
11
|
+
// fields show the resolved value as PLACEHOLDER text instead, so blank =
|
|
12
|
+
// "still inheriting".
|
|
13
|
+
// Save: explicit (not autosave — the server applies cross-field validation:
|
|
14
|
+
// registration fields must include email or phone, login.methods non-empty,
|
|
15
|
+
// so a mid-edit state is routinely invalid). Sends ONLY fields changed from
|
|
16
|
+
// the loaded baseline via {metadata: {auth_config: <diff>}} — django
|
|
17
|
+
// deep-merges the JSONField, so sibling keys survive and untouched fields
|
|
18
|
+
// keep inheriting.
|
|
19
|
+
//
|
|
20
|
+
// Opened from the GroupDetail kebab ("Configure Auth", sys.groups /
|
|
21
|
+
// sys.manage_groups — GROUP_AUTH_PERMS), as a modal per the source ("the
|
|
22
|
+
// multi-tab form is too heavy for the side-nav").
|
|
23
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
24
|
+
import { useQuery } from '@tanstack/react-query';
|
|
25
|
+
import { MultiSelectDropdown, TagInput, modal, toast } from 'portal-mojo/ui';
|
|
26
|
+
import { mojoCall } from 'portal-mojo/client/runtime';
|
|
27
|
+
import { GroupModel, type GroupRow } from '../models';
|
|
28
|
+
import { buildAuthConfigDiff, resolveAuthConfigChain } from './auth-config';
|
|
29
|
+
|
|
30
|
+
// ── Allowed tokens (must match django-mojo auth_config schema) ────────
|
|
31
|
+
|
|
32
|
+
const LOGIN_METHOD_OPTS = [
|
|
33
|
+
{ value: 'password', label: 'Password' },
|
|
34
|
+
{ value: 'sms', label: 'SMS code' },
|
|
35
|
+
{ value: 'passkey', label: 'Passkey' },
|
|
36
|
+
{ value: 'magic', label: 'Magic link' },
|
|
37
|
+
{ value: 'google', label: 'Google' },
|
|
38
|
+
{ value: 'apple', label: 'Apple' },
|
|
39
|
+
{ value: 'github', label: 'GitHub' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const REGISTRATION_METHOD_OPTS = [
|
|
43
|
+
{ value: 'password', label: 'Password' },
|
|
44
|
+
{ value: 'google', label: 'Google' },
|
|
45
|
+
{ value: 'apple', label: 'Apple' },
|
|
46
|
+
{ value: 'github', label: 'GitHub' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const LAYOUT_OPTS = [
|
|
50
|
+
{ value: 'card', label: 'Card' },
|
|
51
|
+
{ value: 'fullscreen', label: 'Full screen' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const PASSKEY_PROMPT_OPTS = [
|
|
55
|
+
{ value: 'off', label: 'Off' },
|
|
56
|
+
{ value: 'optional', label: 'Optional' },
|
|
57
|
+
{ value: 'required', label: 'Required' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const IDENTITY_FIELD_OPTS = [
|
|
61
|
+
{ value: '', label: 'Auto (email, then phone)' },
|
|
62
|
+
{ value: 'email', label: 'Email' },
|
|
63
|
+
{ value: 'phone', label: 'Phone' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const VERIFY_OPTS = [
|
|
67
|
+
{ value: '', label: 'None' },
|
|
68
|
+
{ value: 'email', label: 'Email' },
|
|
69
|
+
{ value: 'sms', label: 'SMS' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Canonical registration-form fields (closed set — register_schema.CANONICAL_FIELDS).
|
|
73
|
+
const CANONICAL_REG_FIELDS = [
|
|
74
|
+
{ name: 'first_name', label: 'First name' },
|
|
75
|
+
{ name: 'last_name', label: 'Last name' },
|
|
76
|
+
{ name: 'email', label: 'Email' },
|
|
77
|
+
{ name: 'phone', label: 'Phone' },
|
|
78
|
+
{ name: 'dob', label: 'Date of birth' },
|
|
79
|
+
{ name: 'password', label: 'Password' },
|
|
80
|
+
];
|
|
81
|
+
// register_schema.DEFAULT_FIELDS — seeds the grid when neither the group's
|
|
82
|
+
// own override nor the resolved config specify `registration.fields`.
|
|
83
|
+
const DEFAULT_REG_FIELDS: RegField[] = [
|
|
84
|
+
{ name: 'first_name', required: false, verify: null },
|
|
85
|
+
{ name: 'last_name', required: false, verify: null },
|
|
86
|
+
{ name: 'email', required: true, verify: 'email' },
|
|
87
|
+
{ name: 'password', required: true, verify: null },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// django-mojo DEFAULT_AUTH_CONFIG — fallback when the resolved-config fetch
|
|
91
|
+
// fails, so the editor still shows sensible inherited values.
|
|
92
|
+
const STATIC_DEFAULTS = {
|
|
93
|
+
theme: {
|
|
94
|
+
app_title: 'DJANGO MOJO', logo_url: '', favicon_url: '', hero_image_url: '',
|
|
95
|
+
hero_headline: 'Welcome back', hero_subheadline: 'Admin Portal',
|
|
96
|
+
back_to_website_url: '', terms_url: '', layout: 'card', api_base: '',
|
|
97
|
+
success_redirect: '/', custom_css: '', custom_css_url: '',
|
|
98
|
+
},
|
|
99
|
+
registration: {
|
|
100
|
+
enabled: true, fields: null, extra_fields: [], identity_field: '', min_age: null,
|
|
101
|
+
methods: ['password', 'google', 'apple', 'github'], passkey_prompt: 'off',
|
|
102
|
+
},
|
|
103
|
+
login: { methods: ['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github'] },
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
// Theme text fields: form name + dotted path under `auth_config` + help copy.
|
|
107
|
+
const THEME_TEXT_FIELDS = [
|
|
108
|
+
{ name: 'app_title', path: 'theme.app_title', label: 'App title', help: 'Brand name shown in the login card header.' },
|
|
109
|
+
{ name: 'logo_url', path: 'theme.logo_url', label: 'Logo URL', help: 'Logo image URL — appears in the header and hero panel.' },
|
|
110
|
+
{ name: 'favicon_url', path: 'theme.favicon_url', label: 'Favicon URL', help: 'Favicon URL for the auth pages.' },
|
|
111
|
+
{ name: 'hero_image_url', path: 'theme.hero_image_url', label: 'Hero image URL', help: 'Background image for the left hero panel.' },
|
|
112
|
+
{ name: 'hero_headline', path: 'theme.hero_headline', label: 'Hero headline', help: 'Headline text shown over the hero image.' },
|
|
113
|
+
{ name: 'hero_subheadline', path: 'theme.hero_subheadline', label: 'Hero subheadline', help: 'Supporting text below the hero headline.' },
|
|
114
|
+
{ name: 'back_to_website_url', path: 'theme.back_to_website_url', label: 'Back-to-website URL', help: '"Back to website" link shown in the hero panel.' },
|
|
115
|
+
{ name: 'terms_url', path: 'theme.terms_url', label: 'Terms URL', help: 'Terms & Conditions link shown on the register page.' },
|
|
116
|
+
{ name: 'api_base', path: 'theme.api_base', label: 'API base', help: 'API host for the auth pages — leave blank for same origin.' },
|
|
117
|
+
{ name: 'success_redirect', path: 'theme.success_redirect', label: 'Success redirect', help: 'Where to send the user after a successful login.' },
|
|
118
|
+
{ name: 'custom_css_url', path: 'theme.custom_css_url', label: 'Custom CSS URL', help: 'URL to an external stylesheet — must start with https://.' },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const BASE_THEME_FIELDS = [
|
|
122
|
+
'app_title', 'logo_url', 'favicon_url', 'hero_image_url',
|
|
123
|
+
'hero_headline', 'hero_subheadline', 'back_to_website_url', 'terms_url',
|
|
124
|
+
];
|
|
125
|
+
const ADVANCED_THEME_FIELDS = ['api_base', 'success_redirect', 'custom_css_url'];
|
|
126
|
+
|
|
127
|
+
// FIELD_DESCRIPTORS — every non-grid form field, its dotted path under
|
|
128
|
+
// `auth_config`, and a kind that drives baseline/diff/serialisation.
|
|
129
|
+
// text — placeholder-capable; baseline = own override only.
|
|
130
|
+
// select — baseline = own override, else resolved (no placeholder).
|
|
131
|
+
// array — multiselect; baseline = own override, else resolved.
|
|
132
|
+
// bool — toggle; baseline = own override, else resolved.
|
|
133
|
+
// int — placeholder-capable number; baseline = own override only.
|
|
134
|
+
type DescriptorKind = 'text' | 'select' | 'array' | 'bool' | 'int';
|
|
135
|
+
interface Descriptor { form: string; path: string; kind: DescriptorKind }
|
|
136
|
+
|
|
137
|
+
const FIELD_DESCRIPTORS: Descriptor[] = [
|
|
138
|
+
...THEME_TEXT_FIELDS.map((f): Descriptor => ({ form: f.name, path: f.path, kind: 'text' })),
|
|
139
|
+
{ form: 'custom_css', path: 'theme.custom_css', kind: 'text' },
|
|
140
|
+
{ form: 'layout', path: 'theme.layout', kind: 'select' },
|
|
141
|
+
{ form: 'login_methods', path: 'login.methods', kind: 'array' },
|
|
142
|
+
{ form: 'reg_enabled', path: 'registration.enabled', kind: 'bool' },
|
|
143
|
+
{ form: 'reg_passkey_prompt', path: 'registration.passkey_prompt', kind: 'select' },
|
|
144
|
+
{ form: 'reg_identity_field', path: 'registration.identity_field', kind: 'select' },
|
|
145
|
+
{ form: 'reg_min_age', path: 'registration.min_age', kind: 'int' },
|
|
146
|
+
{ form: 'reg_methods', path: 'registration.methods', kind: 'array' },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
// ── Path + value helpers ──────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
152
|
+
let cur: unknown = obj;
|
|
153
|
+
for (const key of path.split('.')) {
|
|
154
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
155
|
+
cur = (cur as Record<string, unknown>)[key];
|
|
156
|
+
}
|
|
157
|
+
return cur;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface RegField { name: string; required: boolean; verify: string | null }
|
|
161
|
+
|
|
162
|
+
type FormState = Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
/** Expand a `registration.fields` array into flat grid form values. */
|
|
165
|
+
function gridValuesFromArray(arr: RegField[]): FormState {
|
|
166
|
+
const byName: Record<string, RegField> = {};
|
|
167
|
+
for (const f of arr) if (f?.name) byName[f.name] = f;
|
|
168
|
+
const out: FormState = {};
|
|
169
|
+
for (const cf of CANONICAL_REG_FIELDS) {
|
|
170
|
+
const entry = byName[cf.name];
|
|
171
|
+
const isPassword = cf.name === 'password';
|
|
172
|
+
out[`regf_${cf.name}_inc`] = !!entry;
|
|
173
|
+
// Password is always required when present; its toggle is locked on.
|
|
174
|
+
out[`regf_${cf.name}_req`] = isPassword ? true : !!(entry && entry.required);
|
|
175
|
+
out[`regf_${cf.name}_vfy`] = entry?.verify ? String(entry.verify) : '';
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Comma-joined field names from a `registration.extra_fields` array. */
|
|
181
|
+
function extraNamesFromArray(arr: unknown): string {
|
|
182
|
+
return (Array.isArray(arr) ? arr : [])
|
|
183
|
+
.map((e) => (e && typeof e === 'object' ? (e as { name?: unknown }).name : e))
|
|
184
|
+
.filter((n): n is string => typeof n === 'string' && n.trim() !== '')
|
|
185
|
+
.map((n) => n.trim())
|
|
186
|
+
.join(',');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Collapse the grid form values into a canonical `registration.fields` array. */
|
|
190
|
+
function assembleRegFields(fd: FormState): RegField[] {
|
|
191
|
+
const arr: RegField[] = [];
|
|
192
|
+
for (const cf of CANONICAL_REG_FIELDS) {
|
|
193
|
+
if (fd[`regf_${cf.name}_inc`] !== true) continue;
|
|
194
|
+
if (cf.name === 'password') {
|
|
195
|
+
// Password, when included, is always required and has no verify.
|
|
196
|
+
arr.push({ name: 'password', required: true, verify: null });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
arr.push({
|
|
200
|
+
name: cf.name,
|
|
201
|
+
required: fd[`regf_${cf.name}_req`] === true,
|
|
202
|
+
verify: fd[`regf_${cf.name}_vfy`] ? String(fd[`regf_${cf.name}_vfy`]) : null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return arr;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collapse the tag-input value into `registration.extra_fields` [{name}]:
|
|
210
|
+
* trim, drop blanks, dedupe, drop canonical collisions and names failing
|
|
211
|
+
* the server's identifier rule — the saved config always passes validation.
|
|
212
|
+
*/
|
|
213
|
+
/** The flat form baseline from own override + resolved config. */
|
|
214
|
+
function buildBaseline(own: unknown, resolved: unknown): FormState {
|
|
215
|
+
const base: FormState = {};
|
|
216
|
+
for (const d of FIELD_DESCRIPTORS) {
|
|
217
|
+
const ownVal = getPath(own, d.path);
|
|
218
|
+
const resVal = getPath(resolved, d.path);
|
|
219
|
+
if (d.kind === 'text') {
|
|
220
|
+
base[d.form] = ownVal == null ? '' : String(ownVal);
|
|
221
|
+
} else if (d.kind === 'int') {
|
|
222
|
+
base[d.form] = ownVal == null || ownVal === '' ? '' : String(ownVal as number);
|
|
223
|
+
} else if (d.kind === 'array') {
|
|
224
|
+
const v = ownVal != null ? ownVal : resVal;
|
|
225
|
+
base[d.form] = Array.isArray(v) ? [...(v as unknown[])].map(String) : [];
|
|
226
|
+
} else if (d.kind === 'bool') {
|
|
227
|
+
const v = ownVal != null ? ownVal : resVal;
|
|
228
|
+
base[d.form] = !!v;
|
|
229
|
+
} else {
|
|
230
|
+
const v = ownVal != null ? ownVal : resVal;
|
|
231
|
+
base[d.form] = v == null ? '' : String(v);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const ownFields = getPath(own, 'registration.fields');
|
|
235
|
+
const resFields = getPath(resolved, 'registration.fields');
|
|
236
|
+
const fieldsArr = Array.isArray(ownFields) ? ownFields as RegField[]
|
|
237
|
+
: Array.isArray(resFields) ? resFields as RegField[]
|
|
238
|
+
: DEFAULT_REG_FIELDS;
|
|
239
|
+
Object.assign(base, gridValuesFromArray(fieldsArr));
|
|
240
|
+
|
|
241
|
+
const ownExtra = getPath(own, 'registration.extra_fields');
|
|
242
|
+
const resExtra = getPath(resolved, 'registration.extra_fields');
|
|
243
|
+
base.reg_extra_fields = extraNamesFromArray(Array.isArray(ownExtra) ? ownExtra : Array.isArray(resExtra) ? resExtra : []);
|
|
244
|
+
return base;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Placeholder text (resolved values) for the placeholder-capable fields. */
|
|
248
|
+
function buildPlaceholders(resolved: unknown): Record<string, string> {
|
|
249
|
+
const ph: Record<string, string> = {};
|
|
250
|
+
for (const d of FIELD_DESCRIPTORS) {
|
|
251
|
+
if (d.kind !== 'text' && d.kind !== 'int') continue;
|
|
252
|
+
const v = getPath(resolved, d.path);
|
|
253
|
+
ph[d.form] = v == null ? '' : String(v);
|
|
254
|
+
}
|
|
255
|
+
return ph;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── The dialog ────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
type TabKey = 'base' | 'login' | 'registration' | 'advanced';
|
|
261
|
+
const TABS: { key: TabKey; label: string }[] = [
|
|
262
|
+
{ key: 'base', label: 'Base' },
|
|
263
|
+
{ key: 'login', label: 'Login' },
|
|
264
|
+
{ key: 'registration', label: 'Registration' },
|
|
265
|
+
{ key: 'advanced', label: 'Advanced' },
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
function TextField({ name, label, help, placeholder, value, onChange }: {
|
|
269
|
+
name: string; label: string; help?: string; placeholder?: string;
|
|
270
|
+
value: string; onChange: (name: string, v: string) => void;
|
|
271
|
+
}) {
|
|
272
|
+
return (
|
|
273
|
+
<label className="field col-6">
|
|
274
|
+
<span className="field-label">{label}</span>
|
|
275
|
+
<input
|
|
276
|
+
className="input"
|
|
277
|
+
type="text"
|
|
278
|
+
placeholder={placeholder || undefined}
|
|
279
|
+
value={value}
|
|
280
|
+
onChange={(e) => onChange(name, e.target.value)}
|
|
281
|
+
/>
|
|
282
|
+
{help && <span className="field-help">{help}</span>}
|
|
283
|
+
</label>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ToggleField({ name, label, help, value, onChange, disabled }: {
|
|
288
|
+
name: string; label: string; help?: string; value: boolean;
|
|
289
|
+
onChange: (name: string, v: boolean) => void; disabled?: boolean;
|
|
290
|
+
}) {
|
|
291
|
+
return (
|
|
292
|
+
<div className="col-12">
|
|
293
|
+
<label className="switch-row">
|
|
294
|
+
<span className="field-label">{label}</span>
|
|
295
|
+
<input
|
|
296
|
+
type="checkbox"
|
|
297
|
+
role="switch"
|
|
298
|
+
className="switch"
|
|
299
|
+
checked={value}
|
|
300
|
+
disabled={disabled}
|
|
301
|
+
onChange={(e) => onChange(name, e.target.checked)}
|
|
302
|
+
/>
|
|
303
|
+
</label>
|
|
304
|
+
{help && <span className="field-help">{help}</span>}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function SelectField({ name, label, help, value, options, onChange, disabled, cols = 6 }: {
|
|
310
|
+
name: string; label: string; help?: string; value: string;
|
|
311
|
+
options: { value: string; label: string }[];
|
|
312
|
+
onChange: (name: string, v: string) => void; disabled?: boolean; cols?: 6 | 12;
|
|
313
|
+
}) {
|
|
314
|
+
return (
|
|
315
|
+
<label className={`field col-${cols}`}>
|
|
316
|
+
<span className="field-label">{label}</span>
|
|
317
|
+
<select className="input" value={value} disabled={disabled} onChange={(e) => onChange(name, e.target.value)}>
|
|
318
|
+
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
319
|
+
</select>
|
|
320
|
+
{help && <span className="field-help">{help}</span>}
|
|
321
|
+
</label>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function AuthConfigBody({ group, close }: { group: GroupRow; close: () => void }) {
|
|
326
|
+
const save = GroupModel.useSave();
|
|
327
|
+
|
|
328
|
+
// Reconstruct inheritance from authorized detail reads. The public
|
|
329
|
+
// group_uuid resolver intentionally drops inactive groups, so it cannot
|
|
330
|
+
// be the Admin source of truth for this editor.
|
|
331
|
+
const resolvedQuery = useQuery({
|
|
332
|
+
queryKey: ['auth-config-resolved-admin', group.id, group.modified],
|
|
333
|
+
queryFn: async (): Promise<unknown> => {
|
|
334
|
+
const deployment = await mojoCall('/api/auth/config');
|
|
335
|
+
const deploymentConfig = deployment.data && typeof deployment.data === 'object'
|
|
336
|
+
? deployment.data as Record<string, unknown> : STATIC_DEFAULTS;
|
|
337
|
+
const chain: GroupRow[] = [group];
|
|
338
|
+
const seen = new Set<number>([group.id]);
|
|
339
|
+
let parentId = group.parent?.id ?? null;
|
|
340
|
+
while (parentId != null) {
|
|
341
|
+
if (seen.has(parentId)) throw new Error('The group hierarchy contains a cycle.');
|
|
342
|
+
seen.add(parentId);
|
|
343
|
+
const response = await mojoCall(`${GroupModel.endpoint}/${parentId}`);
|
|
344
|
+
const parent = response.data as GroupRow | undefined;
|
|
345
|
+
if (!parent || parent.id !== parentId) throw new Error(`Parent group #${parentId} was not returned.`);
|
|
346
|
+
chain.unshift(parent);
|
|
347
|
+
parentId = parent.parent?.id ?? null;
|
|
348
|
+
}
|
|
349
|
+
return resolveAuthConfigChain(deploymentConfig, chain);
|
|
350
|
+
},
|
|
351
|
+
retry: false,
|
|
352
|
+
});
|
|
353
|
+
const resolved = resolvedQuery.data;
|
|
354
|
+
|
|
355
|
+
const own = useMemo(() => {
|
|
356
|
+
const v = group.metadata?.auth_config;
|
|
357
|
+
return v && typeof v === 'object' ? v : {};
|
|
358
|
+
}, [group.metadata]);
|
|
359
|
+
|
|
360
|
+
// Baseline + placeholders initialize in an effect keyed to the loaded
|
|
361
|
+
// inheritance snapshot. A background refresh never wipes dirty edits.
|
|
362
|
+
const ready = resolved !== undefined;
|
|
363
|
+
const baseline = useMemo(() => (ready ? buildBaseline(own, resolved) : null), [ready, own, resolved]);
|
|
364
|
+
const placeholders = useMemo(() => (ready ? buildPlaceholders(resolved) : {}), [ready, resolved]);
|
|
365
|
+
|
|
366
|
+
const [values, setValues] = useState<FormState | null>(null);
|
|
367
|
+
const [savedBaseline, setSavedBaseline] = useState<FormState | null>(null);
|
|
368
|
+
const [loadedKey, setLoadedKey] = useState('');
|
|
369
|
+
const baselineKey = ready ? `${group.id}:${group.modified}:${resolvedQuery.dataUpdatedAt}` : '';
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (!baseline || baselineKey === loadedKey) return;
|
|
372
|
+
const dirty = values != null && savedBaseline != null
|
|
373
|
+
&& JSON.stringify(values) !== JSON.stringify(savedBaseline);
|
|
374
|
+
if (dirty) return;
|
|
375
|
+
setValues({ ...baseline });
|
|
376
|
+
setSavedBaseline({ ...baseline });
|
|
377
|
+
setLoadedKey(baselineKey);
|
|
378
|
+
}, [baseline, baselineKey, loadedKey, savedBaseline, values]);
|
|
379
|
+
const [tab, setTab] = useState<TabKey>('base');
|
|
380
|
+
const [status, setStatus] = useState<{ text: string; tone: 'muted' | 'ok' | 'bad' }>({ text: '', tone: 'muted' });
|
|
381
|
+
|
|
382
|
+
if (resolvedQuery.isError) {
|
|
383
|
+
return (
|
|
384
|
+
<div className="modal-pad">
|
|
385
|
+
<h2 className="modal-title">Configure Auth — {group.name}</h2>
|
|
386
|
+
<p className="text-bad">
|
|
387
|
+
Inherited policy is unavailable because the complete parent chain could not be loaded.
|
|
388
|
+
Overrides are shown read-only; Save and Reset remain disabled to avoid deriving a mutation from partial policy.
|
|
389
|
+
</p>
|
|
390
|
+
<div className="field-label">This group's auth overrides</div>
|
|
391
|
+
<pre className="dim" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}><code>{JSON.stringify(own, null, 2)}</code></pre>
|
|
392
|
+
<div className="ga-geo-save-row" style={{ marginTop: 14 }}>
|
|
393
|
+
<button className="btn" onClick={close}>Close</button>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!values || !savedBaseline) {
|
|
400
|
+
return (
|
|
401
|
+
<div className="modal-pad">
|
|
402
|
+
<h2 className="modal-title">Configure Auth — {group.name}</h2>
|
|
403
|
+
<p className="dim">Loading the resolved auth config…</p>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const set = (name: string, v: unknown) => setValues({ ...values, [name]: v });
|
|
409
|
+
|
|
410
|
+
const fail = (msg: string) => {
|
|
411
|
+
setStatus({ text: msg, tone: 'bad' });
|
|
412
|
+
toast.error(msg);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const doSave = async () => {
|
|
416
|
+
// Cross-field rules the server enforces — checked up front so the
|
|
417
|
+
// admin gets a clear inline message instead of a raw 400.
|
|
418
|
+
const loginMethods = Array.isArray(values.login_methods) ? values.login_methods : [];
|
|
419
|
+
if (loginMethods.length === 0) {
|
|
420
|
+
fail('Select at least one login method.');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const regFields = assembleRegFields(values);
|
|
424
|
+
if (!regFields.some((f) => f.name === 'email' || f.name === 'phone')) {
|
|
425
|
+
fail("Registration fields must include 'Email' or 'Phone'.");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Only changed keys ride the payload, so untouched fields continue
|
|
430
|
+
// inheriting. The pure helper is shared with contract verification.
|
|
431
|
+
const payload = buildAuthConfigDiff(values, savedBaseline);
|
|
432
|
+
if (!payload) {
|
|
433
|
+
setStatus({ text: 'No changes to save.', tone: 'muted' });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
setStatus({ text: 'Saving…', tone: 'muted' });
|
|
438
|
+
try {
|
|
439
|
+
await save.mutateAsync({ id: group.id, changes: { metadata: { auth_config: payload } } });
|
|
440
|
+
setSavedBaseline({ ...values });
|
|
441
|
+
setStatus({ text: 'All changes saved.', tone: 'ok' });
|
|
442
|
+
toast.success('Auth config saved');
|
|
443
|
+
} catch (err) {
|
|
444
|
+
fail(err instanceof Error ? err.message : 'Failed to save auth config');
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const resetOverrides = async () => {
|
|
449
|
+
const confirmed = await modal.confirm({
|
|
450
|
+
title: 'Reset auth overrides',
|
|
451
|
+
message: `Remove this group's auth overrides and return ${group.name} to inherited policy?`,
|
|
452
|
+
confirmText: 'Reset overrides',
|
|
453
|
+
danger: true,
|
|
454
|
+
});
|
|
455
|
+
if (!confirmed) return;
|
|
456
|
+
setStatus({ text: 'Resetting…', tone: 'muted' });
|
|
457
|
+
try {
|
|
458
|
+
await save.mutateAsync({ id: group.id, changes: { metadata: { auth_config: null } } });
|
|
459
|
+
toast.success('Auth overrides reset to inherited policy');
|
|
460
|
+
close();
|
|
461
|
+
} catch (err) {
|
|
462
|
+
fail(err instanceof Error ? err.message : 'Failed to reset auth overrides');
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const themeText = (names: string[]) => THEME_TEXT_FIELDS
|
|
467
|
+
.filter((f) => names.includes(f.name))
|
|
468
|
+
.map((f) => (
|
|
469
|
+
<TextField
|
|
470
|
+
key={f.name}
|
|
471
|
+
name={f.name}
|
|
472
|
+
label={f.label}
|
|
473
|
+
help={f.help}
|
|
474
|
+
placeholder={placeholders[f.name]}
|
|
475
|
+
value={String(values[f.name] ?? '')}
|
|
476
|
+
onChange={set}
|
|
477
|
+
/>
|
|
478
|
+
));
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<div className="modal-pad ga-auth-config">
|
|
482
|
+
<h2 className="modal-title">Configure Auth — {group.name}</h2>
|
|
483
|
+
<p className="dim" style={{ marginTop: 0 }}>
|
|
484
|
+
Branding and sign-in behavior for this group's hosted login, registration,
|
|
485
|
+
and passkey pages. Empty fields inherit the deployment defaults (shown as placeholders).
|
|
486
|
+
</p>
|
|
487
|
+
|
|
488
|
+
<div className="fv-tabs" role="tablist">
|
|
489
|
+
{TABS.map((t) => (
|
|
490
|
+
<button
|
|
491
|
+
key={t.key}
|
|
492
|
+
type="button"
|
|
493
|
+
role="tab"
|
|
494
|
+
aria-selected={tab === t.key}
|
|
495
|
+
className={`fv-tab${tab === t.key ? ' fv-tab-active' : ''}`}
|
|
496
|
+
onClick={() => setTab(t.key)}
|
|
497
|
+
>
|
|
498
|
+
{t.label}
|
|
499
|
+
</button>
|
|
500
|
+
))}
|
|
501
|
+
</div>
|
|
502
|
+
|
|
503
|
+
{tab === 'base' && (
|
|
504
|
+
<div className="form-grid" role="tabpanel">
|
|
505
|
+
<SelectField
|
|
506
|
+
name="layout" label="Layout" cols={12}
|
|
507
|
+
help="Card = centered card; Full screen = edge-to-edge split layout."
|
|
508
|
+
value={String(values.layout ?? '')}
|
|
509
|
+
options={LAYOUT_OPTS}
|
|
510
|
+
onChange={set}
|
|
511
|
+
/>
|
|
512
|
+
{themeText(BASE_THEME_FIELDS)}
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
|
|
516
|
+
{tab === 'login' && (
|
|
517
|
+
<div className="form-grid" role="tabpanel">
|
|
518
|
+
<div className="col-12">
|
|
519
|
+
<MultiSelectDropdown
|
|
520
|
+
label="Login methods"
|
|
521
|
+
help="Login methods offered on the sign-in page. At least one is required."
|
|
522
|
+
options={LOGIN_METHOD_OPTS}
|
|
523
|
+
value={Array.isArray(values.login_methods) ? values.login_methods as string[] : []}
|
|
524
|
+
onChange={(v) => set('login_methods', v.map(String))}
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
{tab === 'registration' && (
|
|
531
|
+
<div className="form-grid" role="tabpanel">
|
|
532
|
+
<ToggleField
|
|
533
|
+
name="reg_enabled" label="Registration enabled"
|
|
534
|
+
help="When off, the registration page is hidden."
|
|
535
|
+
value={values.reg_enabled === true}
|
|
536
|
+
onChange={set}
|
|
537
|
+
/>
|
|
538
|
+
<SelectField
|
|
539
|
+
name="reg_passkey_prompt" label="Passkey prompt"
|
|
540
|
+
help="Whether to prompt for passkey enrollment right after signup."
|
|
541
|
+
value={String(values.reg_passkey_prompt ?? '')}
|
|
542
|
+
options={PASSKEY_PROMPT_OPTS}
|
|
543
|
+
onChange={set}
|
|
544
|
+
/>
|
|
545
|
+
<SelectField
|
|
546
|
+
name="reg_identity_field" label="Identity field"
|
|
547
|
+
help="Primary identity collected at signup."
|
|
548
|
+
value={String(values.reg_identity_field ?? '')}
|
|
549
|
+
options={IDENTITY_FIELD_OPTS}
|
|
550
|
+
onChange={set}
|
|
551
|
+
/>
|
|
552
|
+
<label className="field col-6">
|
|
553
|
+
<span className="field-label">Minimum age</span>
|
|
554
|
+
<input
|
|
555
|
+
className="input"
|
|
556
|
+
type="number"
|
|
557
|
+
min={0}
|
|
558
|
+
placeholder={placeholders.reg_min_age || undefined}
|
|
559
|
+
value={String(values.reg_min_age ?? '')}
|
|
560
|
+
onChange={(e) => set('reg_min_age', e.target.value)}
|
|
561
|
+
/>
|
|
562
|
+
<span className="field-help">Minimum age (years) — enforced when 'Date of birth' is a registration field.</span>
|
|
563
|
+
</label>
|
|
564
|
+
<div className="col-6">
|
|
565
|
+
<MultiSelectDropdown
|
|
566
|
+
label="Signup methods"
|
|
567
|
+
help="Signup methods offered on the registration page."
|
|
568
|
+
options={REGISTRATION_METHOD_OPTS}
|
|
569
|
+
value={Array.isArray(values.reg_methods) ? values.reg_methods as string[] : []}
|
|
570
|
+
onChange={(v) => set('reg_methods', v.map(String))}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<div className="col-12">
|
|
575
|
+
<div className="eyebrow" style={{ marginTop: 8 }}>Registration form fields</div>
|
|
576
|
+
<p className="dim" style={{ margin: '4px 0 8px' }}>
|
|
577
|
+
Choose which fields the signup form collects. The schema must include email or phone.
|
|
578
|
+
Password, when included, is always required — omit it only for passwordless (SMS) registration.
|
|
579
|
+
</p>
|
|
580
|
+
<div className="ga-reg-grid">
|
|
581
|
+
<div className="ga-reg-grid-head">
|
|
582
|
+
<span>Field</span><span>Include</span><span>Required</span><span>Verify</span>
|
|
583
|
+
</div>
|
|
584
|
+
{CANONICAL_REG_FIELDS.map((cf) => {
|
|
585
|
+
const isPassword = cf.name === 'password';
|
|
586
|
+
const included = values[`regf_${cf.name}_inc`] === true;
|
|
587
|
+
return (
|
|
588
|
+
<div key={cf.name} className="ga-reg-grid-row">
|
|
589
|
+
<span className="ga-reg-grid-label">{cf.label}</span>
|
|
590
|
+
<input
|
|
591
|
+
type="checkbox" role="switch" className="switch"
|
|
592
|
+
aria-label={`Include ${cf.label}`}
|
|
593
|
+
checked={included}
|
|
594
|
+
onChange={(e) => set(`regf_${cf.name}_inc`, e.target.checked)}
|
|
595
|
+
/>
|
|
596
|
+
<input
|
|
597
|
+
type="checkbox" role="switch" className="switch"
|
|
598
|
+
aria-label={`${cf.label} required`}
|
|
599
|
+
checked={values[`regf_${cf.name}_req`] === true}
|
|
600
|
+
disabled={isPassword}
|
|
601
|
+
onChange={(e) => set(`regf_${cf.name}_req`, e.target.checked)}
|
|
602
|
+
/>
|
|
603
|
+
<select
|
|
604
|
+
className="input input-compact"
|
|
605
|
+
aria-label={`${cf.label} verification`}
|
|
606
|
+
value={String(values[`regf_${cf.name}_vfy`] ?? '')}
|
|
607
|
+
disabled={isPassword}
|
|
608
|
+
onChange={(e) => set(`regf_${cf.name}_vfy`, e.target.value)}
|
|
609
|
+
>
|
|
610
|
+
{VERIFY_OPTS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
611
|
+
</select>
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
})}
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<div className="col-12">
|
|
619
|
+
<div className="eyebrow" style={{ marginTop: 8 }}>Extra fields</div>
|
|
620
|
+
<p className="dim" style={{ margin: '4px 0 8px' }}>
|
|
621
|
+
Extra signup fields to capture for this group (e.g. <code>promo</code>, <code>ref</code>,{' '}
|
|
622
|
+
<code>tracking</code>). Each is captured silently from a matching URL query param or asked
|
|
623
|
+
for as a text input; values land on <code>user.metadata.registration</code>. Names only —
|
|
624
|
+
letters, digits, and underscores.
|
|
625
|
+
</p>
|
|
626
|
+
<TagInput
|
|
627
|
+
value={String(values.reg_extra_fields ?? '')}
|
|
628
|
+
onChange={(csv) => set('reg_extra_fields', csv)}
|
|
629
|
+
placeholder="Add a field name…"
|
|
630
|
+
/>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
)}
|
|
634
|
+
|
|
635
|
+
{tab === 'advanced' && (
|
|
636
|
+
<div className="form-grid" role="tabpanel">
|
|
637
|
+
{themeText(ADVANCED_THEME_FIELDS)}
|
|
638
|
+
<label className="field col-12">
|
|
639
|
+
<span className="field-label">Custom CSS</span>
|
|
640
|
+
<textarea
|
|
641
|
+
className="input"
|
|
642
|
+
rows={5}
|
|
643
|
+
placeholder={placeholders.custom_css || undefined}
|
|
644
|
+
value={String(values.custom_css ?? '')}
|
|
645
|
+
onChange={(e) => set('custom_css', e.target.value)}
|
|
646
|
+
/>
|
|
647
|
+
<span className="field-help">
|
|
648
|
+
Inline CSS injected after the theme stylesheet. Must not contain '<', '@import', or external URLs.
|
|
649
|
+
</span>
|
|
650
|
+
</label>
|
|
651
|
+
</div>
|
|
652
|
+
)}
|
|
653
|
+
|
|
654
|
+
<div className="ga-geo-save-row" style={{ marginTop: 14 }}>
|
|
655
|
+
<span className={`ga-geo-status ${status.tone === 'bad' ? 'ga-status-bad' : status.tone === 'ok' ? 'ga-status-ok' : 'dim'}`}>
|
|
656
|
+
{status.text}
|
|
657
|
+
</span>
|
|
658
|
+
<button
|
|
659
|
+
className="btn btn-danger"
|
|
660
|
+
disabled={save.isPending || Object.keys(own).length === 0}
|
|
661
|
+
onClick={() => void resetOverrides()}
|
|
662
|
+
>
|
|
663
|
+
<i className="bi bi-arrow-counterclockwise" /> Reset Overrides
|
|
664
|
+
</button>
|
|
665
|
+
<button className="btn" onClick={close}>Close</button>
|
|
666
|
+
<button className="btn btn-primary" disabled={save.isPending} onClick={() => void doSave()}>
|
|
667
|
+
<i className="bi bi-check-lg" /> Save Auth Config
|
|
668
|
+
</button>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Open the Configure Auth dialog (kebab entry — GROUP_AUTH_PERMS gated). */
|
|
675
|
+
export function openAuthConfigDialog(group: GroupRow): void {
|
|
676
|
+
void modal.open((close) => <AuthConfigBody group={group} close={() => close(null)} />, { size: 'lg' });
|
|
677
|
+
}
|