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,102 @@
|
|
|
1
|
+
// group-sections/SubGroupsSection.tsx — the Sub-Groups TableView port
|
|
2
|
+
// (GroupView.js:1203-1225): a compact table of the group's children —
|
|
3
|
+
// Name / Kind badge / Status / Created — with row click-through to that
|
|
4
|
+
// group's stacked GroupDetail, and the "Add Group" flow (GroupForms.create
|
|
5
|
+
// minus the redundant parent field; the parent IS this group —
|
|
6
|
+
// GroupView._addSubGroup:1670-1689).
|
|
7
|
+
import {
|
|
8
|
+
Badge, Eyebrow,
|
|
9
|
+
fmt, formModal, toast,
|
|
10
|
+
} from 'portal-mojo/ui';
|
|
11
|
+
import { useCan } from 'portal-mojo/client/runtime';
|
|
12
|
+
import { GROUP_MANAGE_PERMS, GroupModel, type GroupRow } from '../models';
|
|
13
|
+
import { GROUP_KIND_COMBO_OPTIONS, kindLabel } from './models';
|
|
14
|
+
|
|
15
|
+
/** GroupModel.useSave().mutateAsync — what the add-sub-group flow needs. */
|
|
16
|
+
type GroupSaveFn = (vars: { id: number | string | null; changes: Record<string, unknown> }) => Promise<GroupRow>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The "Add Sub-Group" flow — shared by the section toolbar and the header
|
|
20
|
+
* kebab's "Add Sub-{Noun}" (source: both delegate to _addSubGroup). The
|
|
21
|
+
* parent field is dropped from the create form: the parent IS this group.
|
|
22
|
+
*/
|
|
23
|
+
export async function runAddSubGroupFlow(group: GroupRow, save: GroupSaveFn): Promise<void> {
|
|
24
|
+
const noun = kindLabel(group.kind) || 'Group';
|
|
25
|
+
const form = await formModal({
|
|
26
|
+
title: `Add sub-${noun.toLowerCase()} to ${group.name}`,
|
|
27
|
+
submitText: 'Create',
|
|
28
|
+
fields: [
|
|
29
|
+
{ name: 'name', type: 'text', label: 'Name', required: true, placeholder: 'Enter group name' },
|
|
30
|
+
{
|
|
31
|
+
name: 'kind', type: 'combo', label: 'Kind', required: true,
|
|
32
|
+
options: GROUP_KIND_COMBO_OPTIONS, placeholder: 'Type or pick a kind…',
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
if (!form) return;
|
|
37
|
+
try {
|
|
38
|
+
await save({ id: null, changes: { ...form, parent: group.id } });
|
|
39
|
+
toast.success(`Sub-${noun.toLowerCase()} created`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error(err instanceof Error ? err.message : `Failed to create sub-${noun.toLowerCase()}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SubGroupsSection({ group, openGroup }: {
|
|
46
|
+
group: GroupRow;
|
|
47
|
+
openGroup: (id: number) => void;
|
|
48
|
+
}) {
|
|
49
|
+
const { data, isPending } = GroupModel.useList({ parent: group.id, size: 25, sort: 'name' });
|
|
50
|
+
const save = GroupModel.useSave();
|
|
51
|
+
const { can: canManage } = useCan(GROUP_MANAGE_PERMS);
|
|
52
|
+
const subs = data?.rows ?? [];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<Eyebrow>Sub-groups</Eyebrow>
|
|
57
|
+
{canManage && (
|
|
58
|
+
<div className="ga-toolbar">
|
|
59
|
+
<button className="btn btn-primary btn-compact" onClick={() => void runAddSubGroupFlow(group, save.mutateAsync)}>
|
|
60
|
+
<i className="bi bi-diagram-3" /> Add Group
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
{!isPending && subs.length === 0 && <p className="dim-italic">No sub-groups.</p>}
|
|
65
|
+
{subs.length > 0 && (
|
|
66
|
+
<div className="tbl-scroll">
|
|
67
|
+
<table className="tbl ga-subgroups">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr>
|
|
70
|
+
<th>Name</th>
|
|
71
|
+
<th>Kind</th>
|
|
72
|
+
<th>Status</th>
|
|
73
|
+
<th>Created</th>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
{subs.map((sg) => (
|
|
78
|
+
<tr key={sg.id} className="ga-row-link" onClick={() => openGroup(sg.id)}>
|
|
79
|
+
<td><b>{sg.name}</b></td>
|
|
80
|
+
<td>
|
|
81
|
+
{sg.kind
|
|
82
|
+
? <Badge tone="primary">{kindLabel(sg.kind)}</Badge>
|
|
83
|
+
: <span className="dim">—</span>}
|
|
84
|
+
</td>
|
|
85
|
+
<td>
|
|
86
|
+
<Badge tone={sg.is_active ? 'success' : 'muted'}>
|
|
87
|
+
{sg.is_active ? 'Active' : 'Inactive'}
|
|
88
|
+
</Badge>
|
|
89
|
+
</td>
|
|
90
|
+
<td className="dim">{fmt.date(sg.created)}</td>
|
|
91
|
+
</tr>
|
|
92
|
+
))}
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
{(data?.count ?? 0) > subs.length && (
|
|
98
|
+
<p className="dim" style={{ marginTop: 8 }}>{data!.count - subs.length} more sub-groups not shown.</p>
|
|
99
|
+
)}
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
export interface RegistrationField {
|
|
2
|
+
name: string;
|
|
3
|
+
required: boolean;
|
|
4
|
+
verify: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type AuthFormState = Record<string, unknown>;
|
|
8
|
+
export type AuthConfig = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export const LOGIN_METHOD_OPTS = [
|
|
11
|
+
{ value: 'password', label: 'Password' },
|
|
12
|
+
{ value: 'sms', label: 'SMS code' },
|
|
13
|
+
{ value: 'passkey', label: 'Passkey' },
|
|
14
|
+
{ value: 'magic', label: 'Magic link' },
|
|
15
|
+
{ value: 'google', label: 'Google' },
|
|
16
|
+
{ value: 'apple', label: 'Apple' },
|
|
17
|
+
{ value: 'github', label: 'GitHub' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const REGISTRATION_METHOD_OPTS = [
|
|
21
|
+
{ value: 'password', label: 'Password' },
|
|
22
|
+
{ value: 'google', label: 'Google' },
|
|
23
|
+
{ value: 'apple', label: 'Apple' },
|
|
24
|
+
{ value: 'github', label: 'GitHub' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const LAYOUT_OPTS = [
|
|
28
|
+
{ value: 'card', label: 'Card' },
|
|
29
|
+
{ value: 'fullscreen', label: 'Full screen' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const PASSKEY_PROMPT_OPTS = [
|
|
33
|
+
{ value: 'off', label: 'Off' },
|
|
34
|
+
{ value: 'optional', label: 'Optional' },
|
|
35
|
+
{ value: 'required', label: 'Required' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export const IDENTITY_FIELD_OPTS = [
|
|
39
|
+
{ value: '', label: 'Auto (email, then phone)' },
|
|
40
|
+
{ value: 'email', label: 'Email' },
|
|
41
|
+
{ value: 'phone', label: 'Phone' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const VERIFY_OPTS = [
|
|
45
|
+
{ value: '', label: 'None' },
|
|
46
|
+
{ value: 'email', label: 'Email' },
|
|
47
|
+
{ value: 'sms', label: 'SMS' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const CANONICAL_REG_FIELDS = [
|
|
51
|
+
{ name: 'first_name', label: 'First name' },
|
|
52
|
+
{ name: 'last_name', label: 'Last name' },
|
|
53
|
+
{ name: 'email', label: 'Email' },
|
|
54
|
+
{ name: 'phone', label: 'Phone' },
|
|
55
|
+
{ name: 'dob', label: 'Date of birth' },
|
|
56
|
+
{ name: 'password', label: 'Password' },
|
|
57
|
+
];
|
|
58
|
+
const CANONICAL_REG_NAMES = new Set(CANONICAL_REG_FIELDS.map((field) => field.name));
|
|
59
|
+
const EXTRA_FIELD_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_REG_FIELDS: RegistrationField[] = [
|
|
62
|
+
{ name: 'first_name', required: false, verify: null },
|
|
63
|
+
{ name: 'last_name', required: false, verify: null },
|
|
64
|
+
{ name: 'email', required: true, verify: 'email' },
|
|
65
|
+
{ name: 'password', required: true, verify: null },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const STATIC_DEFAULTS: AuthConfig = {
|
|
69
|
+
theme: {
|
|
70
|
+
app_title: 'DJANGO MOJO', logo_url: '', favicon_url: '', hero_image_url: '',
|
|
71
|
+
hero_headline: 'Welcome back', hero_subheadline: 'Admin Portal',
|
|
72
|
+
back_to_website_url: '', terms_url: '', layout: 'card', api_base: '',
|
|
73
|
+
success_redirect: '/', custom_css: '', custom_css_url: '',
|
|
74
|
+
},
|
|
75
|
+
registration: {
|
|
76
|
+
enabled: true, fields: null, extra_fields: [], identity_field: '', min_age: null,
|
|
77
|
+
methods: ['password', 'google', 'apple', 'github'], passkey_prompt: 'off',
|
|
78
|
+
},
|
|
79
|
+
login: { methods: ['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github'] },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const THEME_TEXT_FIELDS = [
|
|
83
|
+
{ name: 'app_title', path: 'theme.app_title', label: 'App title', help: 'Brand name shown in the login card header.' },
|
|
84
|
+
{ name: 'logo_url', path: 'theme.logo_url', label: 'Logo URL', help: 'Logo image URL — appears in the header and hero panel.' },
|
|
85
|
+
{ name: 'favicon_url', path: 'theme.favicon_url', label: 'Favicon URL', help: 'Favicon URL for the auth pages.' },
|
|
86
|
+
{ name: 'hero_image_url', path: 'theme.hero_image_url', label: 'Hero image URL', help: 'Background image for the left hero panel.' },
|
|
87
|
+
{ name: 'hero_headline', path: 'theme.hero_headline', label: 'Hero headline', help: 'Headline text shown over the hero image.' },
|
|
88
|
+
{ name: 'hero_subheadline', path: 'theme.hero_subheadline', label: 'Hero subheadline', help: 'Supporting text below the hero headline.' },
|
|
89
|
+
{ 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.' },
|
|
90
|
+
{ name: 'terms_url', path: 'theme.terms_url', label: 'Terms URL', help: 'Terms & Conditions link shown on the register page.' },
|
|
91
|
+
{ name: 'api_base', path: 'theme.api_base', label: 'API base', help: 'API host for the auth pages — leave blank for same origin.' },
|
|
92
|
+
{ name: 'success_redirect', path: 'theme.success_redirect', label: 'Success redirect', help: 'Where to send the user after a successful login.' },
|
|
93
|
+
{ name: 'custom_css_url', path: 'theme.custom_css_url', label: 'Custom CSS URL', help: 'URL to an external stylesheet — must start with https://.' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export const BASE_THEME_FIELDS = [
|
|
97
|
+
'app_title', 'logo_url', 'favicon_url', 'hero_image_url',
|
|
98
|
+
'hero_headline', 'hero_subheadline', 'back_to_website_url', 'terms_url',
|
|
99
|
+
];
|
|
100
|
+
export const ADVANCED_THEME_FIELDS = ['api_base', 'success_redirect', 'custom_css_url'];
|
|
101
|
+
|
|
102
|
+
type DescriptorKind = 'text' | 'select' | 'array' | 'bool' | 'int';
|
|
103
|
+
interface Descriptor { form: string; path: string; kind: DescriptorKind }
|
|
104
|
+
|
|
105
|
+
const FIELD_DESCRIPTORS: Descriptor[] = [
|
|
106
|
+
...THEME_TEXT_FIELDS.map((field): Descriptor => ({ form: field.name, path: field.path, kind: 'text' })),
|
|
107
|
+
{ form: 'custom_css', path: 'theme.custom_css', kind: 'text' },
|
|
108
|
+
{ form: 'layout', path: 'theme.layout', kind: 'select' },
|
|
109
|
+
{ form: 'login_methods', path: 'login.methods', kind: 'array' },
|
|
110
|
+
{ form: 'reg_enabled', path: 'registration.enabled', kind: 'bool' },
|
|
111
|
+
{ form: 'reg_passkey_prompt', path: 'registration.passkey_prompt', kind: 'select' },
|
|
112
|
+
{ form: 'reg_identity_field', path: 'registration.identity_field', kind: 'select' },
|
|
113
|
+
{ form: 'reg_min_age', path: 'registration.min_age', kind: 'int' },
|
|
114
|
+
{ form: 'reg_methods', path: 'registration.methods', kind: 'array' },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export function getAuthPath(obj: unknown, path: string): unknown {
|
|
118
|
+
let current: unknown = obj;
|
|
119
|
+
for (const key of path.split('.')) {
|
|
120
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
121
|
+
current = (current as Record<string, unknown>)[key];
|
|
122
|
+
}
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setAuthPath(obj: AuthConfig, path: string, value: unknown): void {
|
|
127
|
+
const keys = path.split('.');
|
|
128
|
+
let current = obj;
|
|
129
|
+
for (let index = 0; index < keys.length - 1; index += 1) {
|
|
130
|
+
const key = keys[index]!;
|
|
131
|
+
if (current[key] == null || typeof current[key] !== 'object') current[key] = {};
|
|
132
|
+
current = current[key] as AuthConfig;
|
|
133
|
+
}
|
|
134
|
+
current[keys[keys.length - 1]!] = value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function deepMergeAuthConfig(base: AuthConfig, override: unknown): AuthConfig {
|
|
138
|
+
const output: AuthConfig = structuredClone(base);
|
|
139
|
+
if (override == null || typeof override !== 'object' || Array.isArray(override)) return output;
|
|
140
|
+
for (const [key, value] of Object.entries(override as AuthConfig)) {
|
|
141
|
+
const previous = output[key];
|
|
142
|
+
output[key] = previous != null && typeof previous === 'object' && !Array.isArray(previous)
|
|
143
|
+
&& value != null && typeof value === 'object' && !Array.isArray(value)
|
|
144
|
+
? deepMergeAuthConfig(previous as AuthConfig, value)
|
|
145
|
+
: structuredClone(value);
|
|
146
|
+
}
|
|
147
|
+
return output;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Merge deployment defaults then root-to-leaf group overrides. */
|
|
151
|
+
export function resolveAuthConfigChain(deployment: AuthConfig, chain: Array<{ metadata?: Record<string, unknown> }>): AuthConfig {
|
|
152
|
+
return chain.reduce((config, group) => {
|
|
153
|
+
const override = group.metadata?.auth_config;
|
|
154
|
+
return deepMergeAuthConfig(config, override);
|
|
155
|
+
}, deepMergeAuthConfig({}, deployment));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sameSet(a: unknown, b: unknown): boolean {
|
|
159
|
+
const left = [...(Array.isArray(a) ? a : [])].map(String).sort();
|
|
160
|
+
const right = [...(Array.isArray(b) ? b : [])].map(String).sort();
|
|
161
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function gridValuesFromArray(fields: RegistrationField[]): AuthFormState {
|
|
165
|
+
const byName = Object.fromEntries(fields.filter((field) => field?.name).map((field) => [field.name, field]));
|
|
166
|
+
const output: AuthFormState = {};
|
|
167
|
+
for (const field of CANONICAL_REG_FIELDS) {
|
|
168
|
+
const entry = byName[field.name];
|
|
169
|
+
const isPassword = field.name === 'password';
|
|
170
|
+
output[`regf_${field.name}_inc`] = !!entry;
|
|
171
|
+
output[`regf_${field.name}_req`] = isPassword ? true : !!entry?.required;
|
|
172
|
+
output[`regf_${field.name}_vfy`] = entry?.verify ? String(entry.verify) : '';
|
|
173
|
+
}
|
|
174
|
+
return output;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function extraNamesFromArray(value: unknown): string {
|
|
178
|
+
return (Array.isArray(value) ? value : [])
|
|
179
|
+
.map((entry) => entry && typeof entry === 'object' ? (entry as { name?: unknown }).name : entry)
|
|
180
|
+
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
|
|
181
|
+
.map((name) => name.trim())
|
|
182
|
+
.join(',');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function assembleRegistrationFields(values: AuthFormState): RegistrationField[] {
|
|
186
|
+
const output: RegistrationField[] = [];
|
|
187
|
+
for (const field of CANONICAL_REG_FIELDS) {
|
|
188
|
+
if (values[`regf_${field.name}_inc`] !== true) continue;
|
|
189
|
+
output.push(field.name === 'password'
|
|
190
|
+
? { name: 'password', required: true, verify: null }
|
|
191
|
+
: {
|
|
192
|
+
name: field.name,
|
|
193
|
+
required: values[`regf_${field.name}_req`] === true,
|
|
194
|
+
verify: values[`regf_${field.name}_vfy`] ? String(values[`regf_${field.name}_vfy`]) : null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return output;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assembleExtraFields(values: AuthFormState): { name: string }[] {
|
|
201
|
+
const raw = values.reg_extra_fields;
|
|
202
|
+
const parts = Array.isArray(raw) ? raw.map(String) : String(raw ?? '').split(',');
|
|
203
|
+
const seen = new Set<string>();
|
|
204
|
+
const output: { name: string }[] = [];
|
|
205
|
+
for (const part of parts) {
|
|
206
|
+
const name = part.trim();
|
|
207
|
+
if (!name || seen.has(name) || CANONICAL_REG_NAMES.has(name) || !EXTRA_FIELD_NAME_RE.test(name)) continue;
|
|
208
|
+
seen.add(name);
|
|
209
|
+
output.push({ name });
|
|
210
|
+
}
|
|
211
|
+
return output;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildAuthBaseline(own: unknown, resolved: unknown): AuthFormState {
|
|
215
|
+
const baseline: AuthFormState = {};
|
|
216
|
+
for (const descriptor of FIELD_DESCRIPTORS) {
|
|
217
|
+
const ownValue = getAuthPath(own, descriptor.path);
|
|
218
|
+
const resolvedValue = getAuthPath(resolved, descriptor.path);
|
|
219
|
+
if (descriptor.kind === 'text') baseline[descriptor.form] = ownValue == null ? '' : String(ownValue);
|
|
220
|
+
else if (descriptor.kind === 'int') baseline[descriptor.form] = ownValue == null || ownValue === '' ? '' : String(ownValue);
|
|
221
|
+
else if (descriptor.kind === 'array') {
|
|
222
|
+
const value = ownValue != null ? ownValue : resolvedValue;
|
|
223
|
+
baseline[descriptor.form] = Array.isArray(value) ? value.map(String) : [];
|
|
224
|
+
} else if (descriptor.kind === 'bool') baseline[descriptor.form] = !!(ownValue != null ? ownValue : resolvedValue);
|
|
225
|
+
else baseline[descriptor.form] = String(ownValue != null ? ownValue : resolvedValue ?? '');
|
|
226
|
+
}
|
|
227
|
+
const ownFields = getAuthPath(own, 'registration.fields');
|
|
228
|
+
const resolvedFields = getAuthPath(resolved, 'registration.fields');
|
|
229
|
+
const fields = Array.isArray(ownFields) ? ownFields as RegistrationField[]
|
|
230
|
+
: Array.isArray(resolvedFields) ? resolvedFields as RegistrationField[] : DEFAULT_REG_FIELDS;
|
|
231
|
+
Object.assign(baseline, gridValuesFromArray(fields));
|
|
232
|
+
const ownExtra = getAuthPath(own, 'registration.extra_fields');
|
|
233
|
+
const resolvedExtra = getAuthPath(resolved, 'registration.extra_fields');
|
|
234
|
+
baseline.reg_extra_fields = extraNamesFromArray(Array.isArray(ownExtra) ? ownExtra : resolvedExtra);
|
|
235
|
+
return baseline;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function buildAuthPlaceholders(resolved: unknown): Record<string, string> {
|
|
239
|
+
const placeholders: Record<string, string> = {};
|
|
240
|
+
for (const descriptor of FIELD_DESCRIPTORS) {
|
|
241
|
+
if (descriptor.kind !== 'text' && descriptor.kind !== 'int') continue;
|
|
242
|
+
const value = getAuthPath(resolved, descriptor.path);
|
|
243
|
+
placeholders[descriptor.form] = value == null ? '' : String(value);
|
|
244
|
+
}
|
|
245
|
+
return placeholders;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isDifferent(current: unknown, baseline: unknown, kind: DescriptorKind): boolean {
|
|
249
|
+
if (kind === 'array') return !sameSet(current, baseline);
|
|
250
|
+
if (kind === 'bool') return !!current !== !!baseline;
|
|
251
|
+
if (kind === 'int') {
|
|
252
|
+
const left = current === '' || current == null ? null : Number(current);
|
|
253
|
+
const right = baseline === '' || baseline == null ? null : Number(baseline);
|
|
254
|
+
return left !== right;
|
|
255
|
+
}
|
|
256
|
+
return String(current ?? '').trim() !== String(baseline ?? '').trim();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeForSave(current: unknown, kind: DescriptorKind): unknown {
|
|
260
|
+
if (kind === 'array') return [...(Array.isArray(current) ? current : [])];
|
|
261
|
+
if (kind === 'bool') return !!current;
|
|
262
|
+
if (kind === 'int') return current === '' || current == null ? null : Number(current);
|
|
263
|
+
return current == null ? '' : String(current);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function buildAuthConfigDiff(values: AuthFormState, baseline: AuthFormState): AuthConfig | null {
|
|
267
|
+
const payload: AuthConfig = {};
|
|
268
|
+
let changed = false;
|
|
269
|
+
for (const descriptor of FIELD_DESCRIPTORS) {
|
|
270
|
+
if (!isDifferent(values[descriptor.form], baseline[descriptor.form], descriptor.kind)) continue;
|
|
271
|
+
setAuthPath(payload, descriptor.path, normalizeForSave(values[descriptor.form], descriptor.kind));
|
|
272
|
+
changed = true;
|
|
273
|
+
}
|
|
274
|
+
const fields = assembleRegistrationFields(values);
|
|
275
|
+
if (JSON.stringify(fields) !== JSON.stringify(assembleRegistrationFields(baseline))) {
|
|
276
|
+
setAuthPath(payload, 'registration.fields', fields);
|
|
277
|
+
changed = true;
|
|
278
|
+
}
|
|
279
|
+
const extraFields = assembleExtraFields(values);
|
|
280
|
+
if (JSON.stringify(extraFields) !== JSON.stringify(assembleExtraFields(baseline))) {
|
|
281
|
+
setAuthPath(payload, 'registration.extra_fields', extraFields);
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
return changed ? payload : null;
|
|
285
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// group-sections/geofence-data.ts — a RE-EXPORT SHIM.
|
|
2
|
+
//
|
|
3
|
+
// Wave 4 lifted ~13 exports of web-mojo
|
|
4
|
+
// `@ext/admin/security/geofence/geofenceData.js` into this file for the
|
|
5
|
+
// group-scoped panel. Board #1287 promoted that module into the package
|
|
6
|
+
// (`portal-mojo/admin` → `admin/network/geofence/geofence-data.ts`) and added
|
|
7
|
+
// the exports the platform surfaces need, so the lossy rule↔form projection is
|
|
8
|
+
// defined exactly ONCE — which is what the source file's own docstring asks
|
|
9
|
+
// for. The inline 249-entry country table that lived here is gone: names come
|
|
10
|
+
// from #1426's `COUNTRY_CENTROIDS`, the framework's only country-name source.
|
|
11
|
+
//
|
|
12
|
+
// This file exists so `GeofenceSection.tsx` keeps its short import path. Add
|
|
13
|
+
// nothing here; extend the package module.
|
|
14
|
+
export {
|
|
15
|
+
ABUSE_FLAGS,
|
|
16
|
+
COUNTRY_MODE_OPTS,
|
|
17
|
+
COUNTRY_OPTIONS,
|
|
18
|
+
EMPTY_RULE_FORM,
|
|
19
|
+
GEOFENCE_MANAGE_PERMS,
|
|
20
|
+
GEOFENCE_VIEW_PERMS,
|
|
21
|
+
GROUP_GEOFENCE_EDIT_PERMS,
|
|
22
|
+
SECURITY_EVENTS_PERMS,
|
|
23
|
+
US_STATES,
|
|
24
|
+
buildGroupRulePayload,
|
|
25
|
+
coerceRuleInput,
|
|
26
|
+
countryName,
|
|
27
|
+
describeRule,
|
|
28
|
+
formToRule,
|
|
29
|
+
isAdvancedRule,
|
|
30
|
+
regionName,
|
|
31
|
+
ruleToForm,
|
|
32
|
+
} from 'portal-mojo/admin/security';
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
AbuseFlagKey,
|
|
36
|
+
GeoRulesConfig,
|
|
37
|
+
GeofenceRule,
|
|
38
|
+
RuleClause,
|
|
39
|
+
RuleForm,
|
|
40
|
+
} from 'portal-mojo/admin/security';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// group-sections/models.ts — model definitions + wire helpers for the FULL
|
|
2
|
+
// GroupView port (web-mojo admin/account/groups/GroupView.js, read in full
|
|
3
|
+
// 2026-08-05). These defs live beside the group sections (NOT in the app's
|
|
4
|
+
// models.ts, which another wave owns) and cover the group-scoped surfaces the
|
|
5
|
+
// first GroupDetail pass left out.
|
|
6
|
+
//
|
|
7
|
+
// Wire facts measured live (mverify @9009, portal_test, 2026-08-05):
|
|
8
|
+
// · /api/group/apikey 200 — row {id, created, modified, name, is_active,
|
|
9
|
+
// permissions{}, limits{}, last_used, expires_at, metadata, override_user,
|
|
10
|
+
// group:{…embedded}, user}. NOTE `name` (not the user-key surface's
|
|
11
|
+
// `label`) and `expires_at` (not `expires`).
|
|
12
|
+
// · Create echo carries `token` ONCE (api_key.py on_rest_get attaches
|
|
13
|
+
// _raw_token). Managers can ALSO read it later via ?graph=token — the
|
|
14
|
+
// token is not write-once on this backend (rotate_token docstring).
|
|
15
|
+
// · POST /api/group/apikey/rotate is apikey-auth SELF-SERVICE (401 for
|
|
16
|
+
// JWT callers) — deliberately NOT surfaced in this portal UI.
|
|
17
|
+
// · /api/group/webhook_subscriptions 200 — row {id, created, modified,
|
|
18
|
+
// url, events[], is_active, group:{…}}.
|
|
19
|
+
// · POST /api/group/webhook_secret {group} → {status, data:{secret,
|
|
20
|
+
// created_at, last_rotated_at}} — timestamps are ISO STRINGS
|
|
21
|
+
// (dates.utcnow().isoformat()), NOT epochs. web-mojo formatted them
|
|
22
|
+
// through ['epoch','relative'] (a bug not carried here).
|
|
23
|
+
// · POST /api/group/member/invite {email, group} — backend perms
|
|
24
|
+
// manage_users|manage_members|manage_group|manage_groups on the group.
|
|
25
|
+
// · /api/incident/event 200 (empty on the instance) — columns per the
|
|
26
|
+
// django model: created/category/title (+ level/details/model_*).
|
|
27
|
+
import { defineModel } from 'portal-mojo/client/runtime';
|
|
28
|
+
|
|
29
|
+
// ── Group kinds (Group.js GroupKinds — the FULL known-kind catalog) ───
|
|
30
|
+
export const GROUP_KINDS: Record<string, string> = {
|
|
31
|
+
org: 'Organization',
|
|
32
|
+
platform: 'Platform',
|
|
33
|
+
division: 'Division',
|
|
34
|
+
department: 'Department',
|
|
35
|
+
team: 'Team',
|
|
36
|
+
merchant: 'Merchant',
|
|
37
|
+
partner: 'Partner',
|
|
38
|
+
client: 'Client',
|
|
39
|
+
iso: 'ISO',
|
|
40
|
+
sales: 'Sales',
|
|
41
|
+
reseller: 'Reseller',
|
|
42
|
+
location: 'Location',
|
|
43
|
+
region: 'Region',
|
|
44
|
+
route: 'Route',
|
|
45
|
+
project: 'Project',
|
|
46
|
+
inventory: 'Inventory',
|
|
47
|
+
test: 'Testing',
|
|
48
|
+
misc: 'Miscellaneous',
|
|
49
|
+
qa: 'Quality Assurance',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Combo options — known kinds; free-typed kinds are allowed (combo). */
|
|
53
|
+
export const GROUP_KIND_COMBO_OPTIONS = Object.entries(GROUP_KINDS).map(([value, label]) => ({ value, label }));
|
|
54
|
+
|
|
55
|
+
/** Kind → label: catalog name, else capitalized raw kind (GroupView.kindLabel). */
|
|
56
|
+
export function kindLabel(kind: string | null | undefined): string {
|
|
57
|
+
if (!kind) return '';
|
|
58
|
+
const known = GROUP_KINDS[kind];
|
|
59
|
+
if (known) return known;
|
|
60
|
+
const s = String(kind);
|
|
61
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Kind → header icon (GroupView.iconForKind, ported verbatim). */
|
|
65
|
+
export function iconForKind(kind: string | null | undefined): string {
|
|
66
|
+
const k = String(kind ?? '').toLowerCase();
|
|
67
|
+
if (k === 'org' || k === 'organization') return 'bi-buildings';
|
|
68
|
+
if (k === 'division' || k === 'department') return 'bi-buildings';
|
|
69
|
+
if (k === 'region' || k === 'location') return 'bi-geo-alt';
|
|
70
|
+
if (k === 'project') return 'bi-kanban';
|
|
71
|
+
if (k === 'merchant' || k === 'partner' || k === 'client' || k === 'reseller') return 'bi-shop';
|
|
72
|
+
if (k === 'iso' || k === 'sales') return 'bi-briefcase';
|
|
73
|
+
if (k === 'route') return 'bi-signpost-2';
|
|
74
|
+
if (k === 'inventory') return 'bi-box-seam';
|
|
75
|
+
if (k === 'qa' || k === 'test' || k === 'testing') return 'bi-clipboard-check';
|
|
76
|
+
return 'bi-people-fill';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Canonical option lists (Group.js) ─────────────────────────────────
|
|
80
|
+
/** End-of-day hour options, 0–23 with 12-hour labels (EodHourOptions port). */
|
|
81
|
+
export const EOD_HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => {
|
|
82
|
+
let label: string;
|
|
83
|
+
if (h === 0) label = 'Midnight';
|
|
84
|
+
else if (h === 12) label = 'Noon';
|
|
85
|
+
else label = `${h % 12 === 0 ? 12 : h % 12} ${h < 12 ? 'AM' : 'PM'}`;
|
|
86
|
+
return { value: h, label };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Incident events — /api/incident/event ─────────────────────────────
|
|
90
|
+
/** Row typed from django-mojo incident/models/event.py (live table empty). */
|
|
91
|
+
export interface IncidentEventRow {
|
|
92
|
+
id: number;
|
|
93
|
+
created: number;
|
|
94
|
+
level: number;
|
|
95
|
+
scope?: string;
|
|
96
|
+
category: string;
|
|
97
|
+
title: string | null;
|
|
98
|
+
details?: string | null;
|
|
99
|
+
source_ip?: string | null;
|
|
100
|
+
hostname?: string | null;
|
|
101
|
+
country_code?: string | null;
|
|
102
|
+
model_name?: string | null;
|
|
103
|
+
model_id?: number | null;
|
|
104
|
+
metadata?: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const IncidentEventModel = defineModel<IncidentEventRow>({
|
|
108
|
+
name: 'incident_event',
|
|
109
|
+
endpoint: '/api/incident/event',
|
|
110
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// group-sections/shared.ts — tiny helpers shared across the group sections.
|
|
2
|
+
import type { Params } from 'portal-mojo/client/runtime';
|
|
3
|
+
import type { Tone } from 'portal-mojo/ui';
|
|
4
|
+
|
|
5
|
+
/** Audit log level → Timeline tone (GroupView.js LOG_LEVEL_TONE). */
|
|
6
|
+
export const LOG_TONE: Record<string, Tone> = {
|
|
7
|
+
error: 'danger',
|
|
8
|
+
critical: 'danger',
|
|
9
|
+
warning: 'warning',
|
|
10
|
+
warn: 'warning',
|
|
11
|
+
info: 'info',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The group's OBJECT logs — what the source's shared audit collection
|
|
16
|
+
* fetched (LogList {model_name:'account.Group', model_id:<id>}), feeding the
|
|
17
|
+
* Overview activity feed, the Audit timeline, and the Audit rail badge.
|
|
18
|
+
*/
|
|
19
|
+
export function groupAuditParams(groupId: number): Params {
|
|
20
|
+
return { model_name: 'account.Group', model_id: groupId, sort: '-created' };
|
|
21
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Source: NativeMojo portal-mojo v0.2.5 reference app, apps/portal/src/models.ts.
|
|
2
|
+
import { defineModel, type Group } from '../../../client/runtime';
|
|
3
|
+
import { GROUP_DESTRUCTIVE_PERMS, GROUP_MANAGE_PERMS, GROUP_VIEW_PERMS } from './group-permissions';
|
|
4
|
+
|
|
5
|
+
export { MemberModel, type MemberRow } from '../members';
|
|
6
|
+
|
|
7
|
+
export { GROUP_DESTRUCTIVE_PERMS, GROUP_MANAGE_PERMS, GROUP_VIEW_PERMS } from './group-permissions';
|
|
8
|
+
|
|
9
|
+
export const GROUP_KIND_OPTIONS = [
|
|
10
|
+
{ value: 'org', label: 'Org' },
|
|
11
|
+
{ value: 'organization', label: 'Organization' },
|
|
12
|
+
{ value: 'team', label: 'Team' },
|
|
13
|
+
{ value: 'project', label: 'Project' },
|
|
14
|
+
{ value: 'group', label: 'Group' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export type GroupRow = Group & {
|
|
18
|
+
id: number;
|
|
19
|
+
uuid: string | null;
|
|
20
|
+
created: number;
|
|
21
|
+
modified: number;
|
|
22
|
+
last_activity: number | null;
|
|
23
|
+
is_active: boolean;
|
|
24
|
+
auth_domain: string | null;
|
|
25
|
+
metadata: Record<string, unknown>;
|
|
26
|
+
member_count: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const GroupModel = defineModel<GroupRow>({
|
|
30
|
+
name: 'group',
|
|
31
|
+
endpoint: '/api/group',
|
|
32
|
+
permissions: { view: GROUP_VIEW_PERMS, manage: GROUP_MANAGE_PERMS },
|
|
33
|
+
forms: {
|
|
34
|
+
create: {
|
|
35
|
+
title: 'Add group',
|
|
36
|
+
submitText: 'Create',
|
|
37
|
+
fields: [
|
|
38
|
+
{ name: 'name', type: 'text', label: 'Name', required: true, placeholder: 'Acme West' },
|
|
39
|
+
{
|
|
40
|
+
name: 'kind', type: 'combo', label: 'Kind', required: true,
|
|
41
|
+
options: GROUP_KIND_OPTIONS, placeholder: 'Type or pick a kind…',
|
|
42
|
+
help: 'Drives the sidebar menu context and the portal look for this group.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'parent', type: 'collection', label: 'Parent group',
|
|
46
|
+
endpoint: '/api/group', labelField: 'name', valueField: 'id',
|
|
47
|
+
placeholder: 'None — top-level', help: 'Optional. Search by name.',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
disable: {
|
|
52
|
+
title: 'Deactivate group',
|
|
53
|
+
submitText: 'Deactivate',
|
|
54
|
+
fields: [
|
|
55
|
+
{
|
|
56
|
+
name: 'reason', type: 'select', label: 'Reason', required: true, options: [
|
|
57
|
+
{ value: 'admin', label: 'Admin — manual block' },
|
|
58
|
+
{ value: 'abuse', label: 'Abuse — banned' },
|
|
59
|
+
{ value: 'archived', label: 'Archived — no longer in use' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{ name: 'note', type: 'textarea', label: 'Note', placeholder: 'Optional note about why this group is being deactivated.' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
actions: {
|
|
67
|
+
disable: { permissions: GROUP_DESTRUCTIVE_PERMS },
|
|
68
|
+
reactivate: { permissions: GROUP_DESTRUCTIVE_PERMS },
|
|
69
|
+
},
|
|
70
|
+
});
|