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
|
@@ -3,16 +3,19 @@ import { useQueryClient } from '@tanstack/react-query';
|
|
|
3
3
|
import { useCan, type PermSpec } from '../../../client/runtime';
|
|
4
4
|
import {
|
|
5
5
|
Badge, DetailView, Eyebrow, FlatRow, FormView, JsonBlock, MetricCard,
|
|
6
|
-
ModelTable, confirmGuardrail, fmt, formModal, toast,
|
|
6
|
+
ModelTable, confirmGuardrail, fmt, formModal, toast, getFormTabs, formTabsVersion, subscribeFormTabs,
|
|
7
|
+
type Column, type DetailMenuEntry,
|
|
7
8
|
} from '../../../ui';
|
|
8
9
|
import { LOGS_ADMIN_PERMISSIONS, LogModel, type LogRow } from '../../monitoring';
|
|
9
10
|
import {
|
|
10
11
|
MEMBER_READ_PERMISSIONS,
|
|
12
|
+
MEMBER_APP_PERMS_TABSET,
|
|
11
13
|
MEMBER_SAVE_PERMISSIONS,
|
|
12
14
|
MemberModel,
|
|
13
15
|
effectiveMemberGrants,
|
|
14
16
|
getMemberPermissions,
|
|
15
17
|
ignoredMemberGrants,
|
|
18
|
+
isEditableMemberPermission,
|
|
16
19
|
memberPermissionFields,
|
|
17
20
|
memberPermissionsVersion,
|
|
18
21
|
rawMemberGrants,
|
|
@@ -123,6 +126,7 @@ export function MemberDetail({
|
|
|
123
126
|
const resend = MemberModel.useAction('resend_invite');
|
|
124
127
|
const { can: canSave } = useCan(MEMBER_SAVE_PERMISSIONS);
|
|
125
128
|
useSyncExternalStore(subscribeMemberPermissions, memberPermissionsVersion, memberPermissionsVersion);
|
|
129
|
+
useSyncExternalStore(subscribeFormTabs, formTabsVersion, formTabsVersion);
|
|
126
130
|
|
|
127
131
|
if (readLoading) return <div className="detail-loading"><span className="skel skel-block" /></div>;
|
|
128
132
|
if (!canRead) return <div className="modal-pad text-bad">Access denied</div>;
|
|
@@ -141,6 +145,18 @@ export function MemberDetail({
|
|
|
141
145
|
const raw = rawMemberGrants(member.permissions);
|
|
142
146
|
const catalog = getMemberPermissions();
|
|
143
147
|
const catalogNames = new Set(catalog.map((permission) => permission.name));
|
|
148
|
+
// Product editors may only write grants accepted by the member catalog.
|
|
149
|
+
// Tab registration must not expose system/derived grants or unrelated fields.
|
|
150
|
+
const productTabs = getFormTabs(MEMBER_APP_PERMS_TABSET).map((tab) => ({
|
|
151
|
+
...tab,
|
|
152
|
+
fields: tab.fields.filter((field) => {
|
|
153
|
+
if (!field.name.startsWith('permissions.')) return false;
|
|
154
|
+
const name = field.name.slice('permissions.'.length);
|
|
155
|
+
return catalogNames.has(name) && isEditableMemberPermission(name);
|
|
156
|
+
}),
|
|
157
|
+
})).filter((tab) => tab.fields.length > 0);
|
|
158
|
+
const productFieldNames = new Set(productTabs.flatMap((tab) => tab.fields.map((field) => field.name)));
|
|
159
|
+
const groupFields = memberPermissionFields().filter((field) => !productFieldNames.has(field.name));
|
|
144
160
|
const deploymentOnly = raw.filter((name) => !catalogNames.has(name) && !ignoredMemberGrants(member.permissions).includes(name));
|
|
145
161
|
|
|
146
162
|
const editRole = async () => {
|
|
@@ -236,13 +252,13 @@ export function MemberDetail({
|
|
|
236
252
|
key: 'Permissions', label: 'Permissions', icon: 'bi-shield-lock', render: () => (
|
|
237
253
|
<>
|
|
238
254
|
<Eyebrow>Per-group authorization</Eyebrow>
|
|
239
|
-
<p className="dim">
|
|
255
|
+
<p className="dim">Access for this group. Changes save automatically; a rejected change is restored with an explanation.</p>
|
|
240
256
|
{canSave
|
|
241
257
|
? (
|
|
242
258
|
<FormView
|
|
243
259
|
model={MemberModel}
|
|
244
260
|
row={member}
|
|
245
|
-
fields={
|
|
261
|
+
fields={groupFields}
|
|
246
262
|
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
247
263
|
onSaved={() => { void MemberModel.invalidate(qc); }}
|
|
248
264
|
/>
|
|
@@ -254,6 +270,19 @@ export function MemberDetail({
|
|
|
254
270
|
</>
|
|
255
271
|
),
|
|
256
272
|
},
|
|
273
|
+
...(productTabs.length > 0 ? [{
|
|
274
|
+
key: 'ProductPermissions', label: 'Product permissions', icon: 'bi-puzzle', render: () => (
|
|
275
|
+
<>
|
|
276
|
+
<Eyebrow>Product permissions</Eyebrow>
|
|
277
|
+
<p className="dim">These grants apply only to this membership’s group. Changes save automatically.</p>
|
|
278
|
+
{canSave ? (
|
|
279
|
+
<FormView model={MemberModel} row={member} tabs={productTabs}
|
|
280
|
+
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
281
|
+
onSaved={() => { void MemberModel.invalidate(qc); }} />
|
|
282
|
+
) : <GrantSummary member={member} />}
|
|
283
|
+
</>
|
|
284
|
+
),
|
|
285
|
+
}] : []),
|
|
257
286
|
{ divider: 'Activity' },
|
|
258
287
|
{
|
|
259
288
|
key: 'Audit', label: 'Audit', icon: 'bi-clock-history',
|
|
@@ -113,6 +113,9 @@ export interface MemberPermissionDef {
|
|
|
113
113
|
tooltip?: string;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** Optional product tabs installed by the consuming app through registerFormTabs. */
|
|
117
|
+
export const MEMBER_APP_PERMS_TABSET = 'member-app-perms';
|
|
118
|
+
|
|
116
119
|
const permissionRegistry = new Map<string, MemberPermissionDef>();
|
|
117
120
|
let permissionVersion = 0;
|
|
118
121
|
const permissionListeners = new Set<() => void>();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// API Keys — the /api/account/api_keys (UserAPIKey) screen, columns and
|
|
2
|
+
// flows measured against the LIVE backend (mverify @9009, 2026-08-05):
|
|
3
|
+
// · rows {id, label, allowed_ips, expires, is_active, last_used, created}
|
|
4
|
+
// · create is POST /api/auth/generate_api_key (label / allowed_ips /
|
|
5
|
+
// expire_days ≤ 360) → {id, jti, expires, token} — the token's ONE
|
|
6
|
+
// appearance, so the reveal dialog is show-once (GroupView
|
|
7
|
+
// _showApiKeyTokenDialog recipe)
|
|
8
|
+
// · `revoke` POST_SAVE_ACTION rotates the signing secret + deactivates —
|
|
9
|
+
// irreversible, hence the ARMED-BUTTON idiom, not a confirm dialog
|
|
10
|
+
// · disable/enable is a plain is_active save — reversible, hence
|
|
11
|
+
// act-immediately + grace-window UNDO TOAST (no confirm)
|
|
12
|
+
// · no DELETE (CAN_DELETE false server-side)
|
|
13
|
+
// NOTE: the group-scoped /api/group/apikey surface 500s on the live dev
|
|
14
|
+
// backend — this user-key surface is the one that works today.
|
|
15
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
16
|
+
import {
|
|
17
|
+
Badge, DetailView, fmt, formModal, modal, toast, ModelTable,
|
|
18
|
+
ArmedButton,
|
|
19
|
+
type Column, type FilterDef,
|
|
20
|
+
} from 'portal-mojo/ui';
|
|
21
|
+
import { showSecretDialog } from '../../credentials';
|
|
22
|
+
import { ApiKeyModel, useGenerateUserApiKey, type ApiKeyRow } from './models';
|
|
23
|
+
|
|
24
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
25
|
+
|
|
26
|
+
const COLUMNS: Column<ApiKeyRow>[] = [
|
|
27
|
+
{
|
|
28
|
+
key: 'label', label: 'Key', sortable: true, hideable: false, render: (k) => (
|
|
29
|
+
<div className="cell-user">
|
|
30
|
+
<span className="cell-avatar" style={{ fontSize: 13 }}><i className="bi bi-key" /></span>
|
|
31
|
+
<span>
|
|
32
|
+
<span className="cell-name">{k.label || <span className="dim-italic">Unlabeled key</span>}</span>
|
|
33
|
+
<span className="cell-sub">#{k.id}</span>
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'is_active', label: 'Status', render: (k) =>
|
|
40
|
+
!k.is_active ? <Badge tone="muted">Inactive</Badge>
|
|
41
|
+
: k.expires <= nowSec() ? <Badge tone="danger">Expired</Badge>
|
|
42
|
+
: <Badge tone="success">Active</Badge>,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'allowed_ips', label: 'Allowed IPs', render: (k) =>
|
|
46
|
+
k.allowed_ips.length > 0
|
|
47
|
+
? <code className="dim">{k.allowed_ips.join(', ')}</code>
|
|
48
|
+
: <span className="dim">Any</span>,
|
|
49
|
+
},
|
|
50
|
+
{ key: 'created', label: 'Created', sortable: true, render: (k) => fmt.date(k.created) },
|
|
51
|
+
{
|
|
52
|
+
key: 'expires', label: 'Expires', sortable: true, render: (k) => (
|
|
53
|
+
<span style={k.expires <= nowSec() ? { color: 'var(--bad)' } : undefined}>{fmt.date(k.expires)}</span>
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
{ key: 'last_used', label: 'Last used', sortable: true, render: (k) => fmt.relative(k.last_used, 'never') },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const FILTERS: FilterDef[] = [
|
|
60
|
+
{ key: 'label', label: 'Label', type: 'text', placeholder: 'Contains…' },
|
|
61
|
+
{ key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
|
|
62
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
63
|
+
{ key: 'expires', label: 'Expires', type: 'daterange' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Full facts and write flows in an explicit native detail modal. */
|
|
67
|
+
function KeyDetail({ id, onToggle, onRevoke, onClose }: {
|
|
68
|
+
id: number;
|
|
69
|
+
onToggle: (k: ApiKeyRow, next: boolean) => void;
|
|
70
|
+
onRevoke: (k: ApiKeyRow) => Promise<void>;
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
const query = ApiKeyModel.useOne(id);
|
|
74
|
+
if (query.isPending) return <div className="detail-loading"><span className="skel skel-block" /></div>;
|
|
75
|
+
if (!query.data || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'API key not found'}</div>;
|
|
76
|
+
const k = query.data;
|
|
77
|
+
return (
|
|
78
|
+
<DetailView<ApiKeyRow>
|
|
79
|
+
icon="bi-key"
|
|
80
|
+
title={k.label || `Key #${k.id}`}
|
|
81
|
+
subtitle={`Personal API key #${k.id}`}
|
|
82
|
+
onClose={onClose}
|
|
83
|
+
sections={[{
|
|
84
|
+
key: 'details', label: 'Details', icon: 'bi-info-circle',
|
|
85
|
+
render: () => (
|
|
86
|
+
<div className="expand-grid">
|
|
87
|
+
<div>
|
|
88
|
+
<div className="eyebrow">Details</div>
|
|
89
|
+
<div>Created {fmt.datetime(k.created)}</div>
|
|
90
|
+
<div>Expires {fmt.datetime(k.expires)}</div>
|
|
91
|
+
<div className="dim">Last used {fmt.relative(k.last_used, 'never')}</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<div className="eyebrow">Allowed IPs</div>
|
|
95
|
+
{k.allowed_ips.length > 0
|
|
96
|
+
? k.allowed_ips.map((ip) => <div key={ip}><code>{ip}</code></div>)
|
|
97
|
+
: <div className="dim">Any address</div>}
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<div className="eyebrow">Actions</div>
|
|
101
|
+
<div className="chip-row" style={{ gap: 8 }}>
|
|
102
|
+
<button type="button" className="btn btn-compact" onClick={() => onToggle(k, !k.is_active)}>
|
|
103
|
+
{k.is_active ? 'Disable' : 'Enable'}
|
|
104
|
+
</button>
|
|
105
|
+
<ArmedButton
|
|
106
|
+
label="Revoke"
|
|
107
|
+
armedLabel="Click again — token dies now"
|
|
108
|
+
className="btn-compact"
|
|
109
|
+
title="Rotate the secret and deactivate — cannot be undone"
|
|
110
|
+
onConfirm={() => onRevoke(k)}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="dim" style={{ marginTop: 6 }}>
|
|
114
|
+
Disable is reversible (undo offered). Revoke rotates the secret — permanent.
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
}]}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function PersonalApiKeysPage() {
|
|
125
|
+
const qc = useQueryClient();
|
|
126
|
+
const save = ApiKeyModel.useSave();
|
|
127
|
+
const revoke = ApiKeyModel.useAction('revoke');
|
|
128
|
+
const generateKey = useGenerateUserApiKey();
|
|
129
|
+
|
|
130
|
+
const generate = async () => {
|
|
131
|
+
const data = await formModal(ApiKeyModel.forms.generate!);
|
|
132
|
+
if (!data) return;
|
|
133
|
+
const body: Record<string, unknown> = {
|
|
134
|
+
label: data.label,
|
|
135
|
+
expire_days: Number(data.expire_days || 360),
|
|
136
|
+
};
|
|
137
|
+
const ips = String(data.allowed_ips ?? '').trim();
|
|
138
|
+
if (ips) body.allowed_ips = ips.split(',').map((s) => s.trim()).filter(Boolean);
|
|
139
|
+
try {
|
|
140
|
+
await generateKey.mutateAsync({
|
|
141
|
+
changes: body,
|
|
142
|
+
onToken: async (token) => {
|
|
143
|
+
await showSecretDialog({
|
|
144
|
+
title: 'API key created',
|
|
145
|
+
intro: <><b>{String(data.label || 'Unlabeled key')}</b> is ready.</>,
|
|
146
|
+
warning: 'Save this token now. It will not be shown again.',
|
|
147
|
+
secret: token,
|
|
148
|
+
ariaLabel: 'Generated API key',
|
|
149
|
+
footer: <p className="dim">Treat this token like a password. Anyone holding it can call the API as you.</p>,
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
toast.success('API key generated');
|
|
154
|
+
} catch (err) {
|
|
155
|
+
toast.error(err instanceof Error ? err.message : 'Failed to generate key');
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Disable/enable — reversible, so it acts IMMEDIATELY and offers Undo
|
|
161
|
+
* for the grace window instead of asking permission first.
|
|
162
|
+
*/
|
|
163
|
+
const toggleKey = (k: ApiKeyRow, next: boolean) => {
|
|
164
|
+
void (async () => {
|
|
165
|
+
try {
|
|
166
|
+
await save.mutateAsync({ id: k.id, changes: { is_active: next } });
|
|
167
|
+
toast.undo(
|
|
168
|
+
`${k.label || `Key #${k.id}`} ${next ? 'enabled' : 'disabled'}`,
|
|
169
|
+
() => {
|
|
170
|
+
void save.mutateAsync({ id: k.id, changes: { is_active: !next } })
|
|
171
|
+
.then(() => toast.info(`${k.label || `Key #${k.id}`} restored`))
|
|
172
|
+
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Undo failed'));
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
toast.error(err instanceof Error ? err.message : 'Save failed');
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Revoke — the armed button already collected intent; just fire. */
|
|
182
|
+
const revokeKey = async (k: ApiKeyRow) => {
|
|
183
|
+
try {
|
|
184
|
+
await revoke.mutateAsync({ id: k.id, payload: true });
|
|
185
|
+
toast.success(`${k.label || `Key #${k.id}`} revoked — the token is dead`);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
toast.error(err instanceof Error ? err.message : 'Revoke failed');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const openKey = (k: ApiKeyRow) => {
|
|
192
|
+
void ApiKeyModel.fetchOne(qc, k.id).catch(() => undefined);
|
|
193
|
+
void modal.detail((close) => (
|
|
194
|
+
<KeyDetail id={k.id} onToggle={toggleKey} onRevoke={revokeKey} onClose={() => close(null)} />
|
|
195
|
+
));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<ModelTable<ApiKeyRow>
|
|
200
|
+
model={ApiKeyModel}
|
|
201
|
+
eyebrow="Account"
|
|
202
|
+
title="API Keys"
|
|
203
|
+
searchPlaceholder="Search key labels…"
|
|
204
|
+
columns={COLUMNS}
|
|
205
|
+
filters={FILTERS}
|
|
206
|
+
presets={[
|
|
207
|
+
{ key: 'all', label: 'All', params: {} },
|
|
208
|
+
{ key: 'active', label: 'Active', params: { is_active: 'true' } },
|
|
209
|
+
{ key: 'inactive', label: 'Inactive', params: { is_active: 'false' } },
|
|
210
|
+
]}
|
|
211
|
+
defaultSort="-created"
|
|
212
|
+
columnChooser
|
|
213
|
+
persistState
|
|
214
|
+
exportFormats={['csv', 'json']}
|
|
215
|
+
onRowClick={openKey}
|
|
216
|
+
addLabel="Generate Key"
|
|
217
|
+
onAdd={generate}
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -237,12 +237,12 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
|
|
|
237
237
|
{ divider: 'Access' },
|
|
238
238
|
...(canMembers ? [{ key: 'Groups', label: 'Groups', icon: 'bi-people', render: () => <GroupsSection user={user} onOpenGroup={onOpenGroup} /> }] : []),
|
|
239
239
|
{
|
|
240
|
-
key: 'SysPerms', label: '
|
|
240
|
+
key: 'SysPerms', label: 'System permissions', icon: 'bi-shield-check',
|
|
241
241
|
permissions: ADMIN,
|
|
242
242
|
render: () => <SysPermsSection user={user} />,
|
|
243
243
|
},
|
|
244
244
|
...(hasAppPerms ? [{
|
|
245
|
-
key: 'AppPerms', label: '
|
|
245
|
+
key: 'AppPerms', label: 'Product permissions', icon: 'bi-puzzle',
|
|
246
246
|
permissions: ADMIN,
|
|
247
247
|
render: () => <AppPermsSection user={user} />,
|
|
248
248
|
}] : []),
|
|
@@ -4,9 +4,11 @@ import { UsersPage } from './UsersPage';
|
|
|
4
4
|
export * from './models';
|
|
5
5
|
export * from './UsersPage';
|
|
6
6
|
export * from './UserDetail';
|
|
7
|
+
export * from './PersonalApiKeysPage';
|
|
7
8
|
export * from './sections/permission-catalog';
|
|
8
9
|
|
|
9
10
|
export { USERS_ADMIN_SECTION } from '../../domains/identity';
|
|
11
|
+
export const loadPersonalApiKeysPage = () => import('./PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage }));
|
|
10
12
|
|
|
11
13
|
/** Useful to custom routers that open a User detail outside ModelTable. */
|
|
12
14
|
export const USER_ADMIN_SURFACES = { UsersPage, UserDetail } as const;
|
|
@@ -26,8 +26,8 @@ export function SysPermsSection({ user }: { user: UserRow }) {
|
|
|
26
26
|
export function AppPermsSection({ user }: { user: UserRow }) {
|
|
27
27
|
return (
|
|
28
28
|
<>
|
|
29
|
-
<Eyebrow>
|
|
30
|
-
<p className="dim" style={{ margin: '0 0 12px' }}>
|
|
29
|
+
<Eyebrow>Product permissions</Eyebrow>
|
|
30
|
+
<p className="dim" style={{ margin: '0 0 12px' }}>User grants can apply across groups. To give access to just one group, edit that membership instead. Changes save automatically.</p>
|
|
31
31
|
{/* Registry NAME (not a snapshot) so late registrations re-render. */}
|
|
32
32
|
<FormView model={UserModel} row={user} tabs={USER_APP_PERMS_TABSET} />
|
|
33
33
|
</>
|
package/src/admin/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './security';
|
|
|
12
12
|
export * from './incidents';
|
|
13
13
|
export * from './rules';
|
|
14
14
|
export * from './identity';
|
|
15
|
+
export * from './signin';
|
|
15
16
|
export * from './jobs';
|
|
16
17
|
export * from './network';
|
|
17
18
|
export * from './dns';
|
|
@@ -22,3 +23,5 @@ export * from './phonehub';
|
|
|
22
23
|
export * from './dashboard';
|
|
23
24
|
export * from './assistant';
|
|
24
25
|
export { ADMIN_SECTIONS } from './registry';
|
|
26
|
+
export { FleetConfigurationPage, FleetNodeStatus } from './fleet-configuration/FleetConfigurationPage';
|
|
27
|
+
export { FLEET_CONFIGURATION_ADMIN_SECTION } from './domains/operations';
|
|
@@ -4,13 +4,14 @@ import { useSearchParams } from 'react-router-dom';
|
|
|
4
4
|
import { KPITile, MetricsChart } from '../../../charts';
|
|
5
5
|
import { useCan } from '../../../client/runtime';
|
|
6
6
|
import { Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../../ui';
|
|
7
|
-
import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit } from './api';
|
|
7
|
+
import { fetchPushGroupChoices, fetchPushStats, parseFcmServiceAccount, savePushConfigImperative, testPushConfigConnection, type FcmCredentialEdit, type PushTestResult } from './api';
|
|
8
8
|
import {
|
|
9
9
|
PUSH_CONFIG_MANAGE_PERMISSIONS,
|
|
10
10
|
PUSH_CONFIG_TEST_PERMISSIONS,
|
|
11
11
|
PUSH_CONFIG_VIEW_PERMISSIONS,
|
|
12
12
|
PUSH_DELIVERY_VIEW_PERMISSIONS,
|
|
13
13
|
PUSH_DEVICE_VIEW_PERMISSIONS,
|
|
14
|
+
PUSH_DEVICE_TEST_PERMISSIONS,
|
|
14
15
|
PUSH_GROUP_DIRECTORY_PERMISSIONS,
|
|
15
16
|
PUSH_METRICS_PERMISSIONS,
|
|
16
17
|
PUSH_TEMPLATE_MANAGE_PERMISSIONS,
|
|
@@ -25,32 +26,79 @@ import {
|
|
|
25
26
|
type PushTemplateRow,
|
|
26
27
|
} from './models';
|
|
27
28
|
|
|
29
|
+
import { openPushDeviceTest, PushTestResultView, usePushRequestOwner } from './PushTestDialog';
|
|
30
|
+
|
|
28
31
|
const relationLabel=(value:{id:number;name?:string;display_name?:string}|null,fallback='—')=>value?.name??value?.display_name??(value?`#${value.id}`:fallback);
|
|
29
|
-
const statusTone=(status:string)=>status==='
|
|
32
|
+
const statusTone=(status:string)=>status==='accepted'||status==='delivered'?'success':status==='failed'||status==='rejected'||status==='blocked'?'danger':'warning';
|
|
33
|
+
const outcomeLabel=(row:PushDeliveryRow)=>row.push_outcome??row.status;
|
|
34
|
+
const outcomeHelp=(row:PushDeliveryRow)=>({accepted:'FCM accepted the message. Device receipt has not been confirmed.',delivered:'The device reported receipt of this notification.',simulated:'Simulation only. No notification was sent to FCM.',unknown:'Provider acceptance is unknown. Check the device before sending again.',sent:'Recorded as sent. This older record does not establish provider acceptance or device receipt.',pending:'No confirmed provider acceptance is recorded.',rejected:'FCM rejected this message.',blocked:'The send was blocked before provider acceptance.',failed:'The send attempt failed.'})[outcomeLabel(row)]??'No delivery confirmation is available.';
|
|
30
35
|
|
|
31
|
-
export function PushStatsPanel(){const query=useQuery({queryKey:['/api/account/devices/push/stats','caller'],queryFn:fetchPushStats,staleTime:30_000});const row=query.data;return <div className="push-stats"><section><h2>Your activity</h2><p className="dim">
|
|
36
|
+
export function PushStatsPanel(){const query=useQuery({queryKey:['/api/account/devices/push/stats','caller'],queryFn:fetchPushStats,staleTime:30_000});const row=query.data;return <div className="push-stats"><section><h2>Your activity</h2><p className="dim">Activity for your account. Recorded sent counts may include simulated sends.</p><div className="push-kpi-grid"><KPITile label="Recorded sent" value={row?.total_sent} loading={query.isPending}/><KPITile label="Failed" value={row?.total_failed} tone="bad" loading={query.isPending}/><KPITile label="Pending" value={row?.total_pending} loading={query.isPending}/></div></section><section><h2>Your devices</h2><div className="push-kpi-grid push-kpi-grid-two"><KPITile label="Registered devices" value={row?.registered_devices} loading={query.isPending}/><KPITile label="Enabled devices" value={row?.enabled_devices} loading={query.isPending}/></div></section>{query.error&&<div className="form-alert">Caller push statistics are unavailable.</div>}</div>;}
|
|
32
37
|
export function PushMetricsPanel(){return <div className="panel panel-pad"><p className="eyebrow">Global metrics account</p><MetricsChart title="Push delivery activity" slugs={['push_sent','push_failed']} seriesLabels={{push_sent:'Sent',push_failed:'Failed'}} account="global" defaultRange="7d" defaultGranularity="hours" defaultType="line"/></div>;}
|
|
33
38
|
|
|
34
39
|
const deviceColumns:Column<PushDeviceRow>[]=[{key:'last_seen',label:'Last seen',sortable:true,hideable:false,render:r=>fmt.datetime(r.last_seen)},{key:'device_name',label:'Device',sortable:true,render:r=>r.device_name||r.device_id},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'platform',label:'Platform',sortable:true,render:r=><Badge tone="info">{r.platform}</Badge>},{key:'app_version',label:'App version',render:r=>r.app_version||'—'},{key:'push_enabled',label:'Push',sortable:true,render:r=><Badge tone={r.push_enabled?'success':'muted'}>{r.push_enabled?'enabled':'disabled'}</Badge>}];
|
|
35
40
|
const deviceFilters:FilterDef[]=[{key:'platform',label:'Platform',type:'select',options:['ios','android','web'].map(value=>({value,label:value}))},{key:'push_enabled',label:'Push enabled',type:'boolean'},{key:'is_active',label:'Active',type:'boolean'}];
|
|
36
|
-
function DeviceDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
41
|
+
function DeviceDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
42
|
+
const query=PushDeviceModel.useOne(id);const canSend=useCan(PUSH_DEVICE_TEST_PERMISSIONS).can;const canView=useCan(PUSH_DEVICE_VIEW_PERMISSIONS).can;
|
|
43
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading registered device…':'Registered device unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
44
|
+
const row=query.data;
|
|
45
|
+
return <DetailView title={row.device_name||row.device_id} subtitle={`${row.platform} · ${relationLabel(row.user)}`} icon="bi-phone" onClose={onClose}
|
|
46
|
+
chips={[{text:row.is_active===null?'Activity unknown':row.is_active?'Active':'Inactive',tone:row.is_active?'success':'muted'},{text:row.push_enabled?'Push enabled':'Push disabled',tone:row.push_enabled?'info':'warning'}]}
|
|
47
|
+
contextMenu={canSend&&canView?[{label:'Send test push',icon:'bi-send',onSelect:()=>void openPushDeviceTest(row)}]:[]}
|
|
48
|
+
sections={[{key:'device',label:'Overview',icon:'bi-device-ssd',render:()=><div className="detail-section"><FlatRow label="User">{relationLabel(row.user)}</FlatRow><FlatRow label="Registration">#{row.id}</FlatRow><FlatRow label="Device ID"><code>{row.device_id}</code></FlatRow><FlatRow label="Platform">{row.platform}</FlatRow><FlatRow label="OS version">{row.os_version||'—'}</FlatRow><FlatRow label="App version">{row.app_version||'—'}</FlatRow><FlatRow label="Last seen">{fmt.datetime(row.last_seen)}</FlatRow></div>},
|
|
49
|
+
{key:'preferences',label:'Preferences',icon:'bi-sliders',render:()=><div className="detail-section"><FlatRow label="Push notifications">{row.push_enabled?'Enabled':'Disabled'}</FlatRow>{Object.keys(row.push_preferences).length?Object.entries(row.push_preferences).map(([key,value])=><FlatRow key={key} label={key}>{value?'Enabled':'Disabled'}</FlatRow>):<p className="dim">All notification categories use their default preference.</p>}<p className="dim">Test pushes respect the device’s test category preference.</p></div>}]} />;
|
|
50
|
+
}
|
|
37
51
|
export function PushDevicesPage(){return <ModelTable model={PushDeviceModel} eyebrow="Communications · Push" title="Registered Devices" columns={deviceColumns} filters={deviceFilters} defaultSort="-last_seen" searchable searchPlaceholder="Search device name or device id" columnChooser persistState persistKey="admin:push:devices" onRowClick={row=>void modal.detail(close=><DeviceDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
38
52
|
|
|
39
|
-
const deliveryColumns:Column<PushDeliveryRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'title',label:'Title',render:r=>r.title||`[${r.category} data]`},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'device',label:'Device',render:r=>relationLabel(r.device)},{key:'category',label:'Category',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={statusTone(r
|
|
53
|
+
const deliveryColumns:Column<PushDeliveryRow>[]=[{key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},{key:'title',label:'Title',render:r=>r.title||`[${r.category} data]`},{key:'user',label:'User',render:r=>relationLabel(r.user)},{key:'device',label:'Device',render:r=>relationLabel(r.device)},{key:'category',label:'Category',sortable:true},{key:'status',label:'Status',sortable:true,render:r=><Badge tone={statusTone(outcomeLabel(r))}>{outcomeLabel(r)}</Badge>}];
|
|
40
54
|
const deliveryFilters:FilterDef[]=[{key:'status',label:'Status',type:'select',options:['pending','sent','delivered','failed'].map(value=>({value,label:value}))},{key:'category',label:'Category',type:'text'},{key:'created',label:'Created',type:'daterange'}];
|
|
41
|
-
function DeliveryDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeliveryModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading delivery…':'Delivery unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.title||`[${row.category} data]`} subtitle={`${row
|
|
55
|
+
function DeliveryDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PushDeliveryModel.useOne(id);if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading delivery…':'Delivery unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;return <DetailView title={row.title||`[${row.category} data]`} subtitle={`${outcomeLabel(row)} · ${relationLabel(row.device)}`} icon="bi-bell" chips={[{text:outcomeLabel(row),tone:statusTone(outcomeLabel(row))}]} onClose={onClose} sections={[{key:'preview',label:'Notification',icon:'bi-app-indicator',render:()=><div className="detail-section"><div className="push-preview"><strong>{row.title||'Data-only notification'}</strong><p>{row.body||'No visible message body.'}</p></div><FlatRow label="Category">{row.category}</FlatRow><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow></div>},{key:'delivery',label:'Delivery',icon:'bi-truck',render:()=><div className="detail-section"><p className="push-test-result">{outcomeHelp(row)}</p><FlatRow label="User">{relationLabel(row.user)}</FlatRow><FlatRow label="Device">{relationLabel(row.device)}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Sent">{row.sent_at?fmt.datetime(row.sent_at):'—'}</FlatRow><FlatRow label="Delivered">{row.delivered_at?fmt.datetime(row.delivered_at):'—'}</FlatRow><FlatRow label="Error">{row.error_message||'—'}</FlatRow><p className="dim">A sent timestamp records a send attempt; receipt is confirmed only by a device delivery report.</p></div>}]} />;}
|
|
42
56
|
export function PushDeliveriesPage(){return <ModelTable model={PushDeliveryModel} eyebrow="Communications · Push" title="Deliveries" columns={deliveryColumns} filters={deliveryFilters} defaultSort="-created" searchable searchPlaceholder="Search title or category" autoRefresh={20} columnChooser persistState persistKey="admin:push:deliveries" onRowClick={row=>void modal.detail(close=><DeliveryDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
43
57
|
|
|
44
58
|
function jsonText(value:Record<string,unknown>):string{return Object.keys(value).length?JSON.stringify(value,null,2):'';}
|
|
45
59
|
function parseJsonObject(value:string,label:string):Record<string,unknown>{if(!value.trim())return {};let parsed:unknown;try{parsed=JSON.parse(value);}catch{throw new Error(`${label} must be valid JSON.`);}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error(`${label} must be a JSON object.`);return parsed as Record<string,unknown>;}
|
|
46
|
-
function TemplateEditor({row,close}:{row?:PushTemplateRow;close:(value:PushTemplateRow|null)=>void}){const save=PushTemplateModel.useSave();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [category,setCategory]=useState(row?.category??'general');const [priority,setPriority]=useState<PushTemplateRow['priority']>(row?.priority??'normal');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [title,setTitle]=useState(row?.title_template??'');const [body,setBody]=useState(row?.body_template??'');const [actionUrl,setActionUrl]=useState(row?.action_url??'');const [data,setData]=useState(jsonText(row?.data_template??{}));const [variables,setVariables]=useState(jsonText(row?.variables??{}));const [active,setActive]=useState(row?.is_active??true);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{setBusy(true);setError('');try{const dataTemplate=parseJsonObject(data,'Data template');const variableDocs=parseJsonObject(variables,'Variables');if(!title.trim()&&!body.trim()&&!Object.keys(dataTemplate).length)throw new Error('Provide a title, body, or data template.');const saved=await save.mutateAsync({id:row?.id??null,changes:{name:name.trim(),category:category.trim()||'general',priority,group:group?Number(group):null,title_template:title||null,body_template:body||null,action_url:actionUrl||null,data_template:dataTemplate,variables:variableDocs,is_active:active}});toast.success(row?'Push template updated':'Push template created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Template could not be saved.');}finally{setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<
|
|
60
|
+
function TemplateEditor({row,close,pending}:{row?:PushTemplateRow;pending:{current:boolean};close:(value:PushTemplateRow|null)=>void}){const save=PushTemplateModel.useSave();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [category,setCategory]=useState(row?.category??'general');const [priority,setPriority]=useState<PushTemplateRow['priority']>(row?.priority??'normal');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [title,setTitle]=useState(row?.title_template??'');const [body,setBody]=useState(row?.body_template??'');const [actionUrl,setActionUrl]=useState(row?.action_url??'');const [data,setData]=useState(jsonText(row?.data_template??{}));const [variables,setVariables]=useState(jsonText(row?.variables??{}));const [active,setActive]=useState(row?.is_active??true);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{if(pending.current)return;pending.current=true;setBusy(true);setError('');try{const dataTemplate=parseJsonObject(data,'Data template');const variableDocs=parseJsonObject(variables,'Variables');if(!title.trim()&&!body.trim()&&!Object.keys(dataTemplate).length)throw new Error('Provide a title, body, or data template.');const saved=await save.mutateAsync({id:row?.id??null,changes:{name:name.trim(),category:category.trim()||'general',priority,group:group?Number(group):null,title_template:title||null,body_template:body||null,action_url:actionUrl||null,data_template:dataTemplate,variables:variableDocs,is_active:active}});toast.success(row?'Push template updated':'Push template created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Template could not be saved.');}finally{pending.current=false;setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push template</h2>{error&&<div className="form-alert" role="alert">{error}</div>}<fieldset disabled={busy} className="form-grid push-fields"><label className="field col-6"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-3"><span className="field-label">Category</span><input className="input" value={category} onChange={e=>setCategory(e.target.value)}/></label><label className="field col-3"><span className="field-label">Priority</span><select className="input" value={priority} onChange={e=>setPriority(e.target.value as PushTemplateRow['priority'])}><option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option></select></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System template</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System template')}</div></div>}<label className="field col-12"><span className="field-label">Title template</span><input className="input" value={title} onChange={e=>setTitle(e.target.value)}/></label><label className="field col-12"><span className="field-label">Body template</span><textarea className="input" rows={4} value={body} onChange={e=>setBody(e.target.value)}/></label><label className="field col-12"><span className="field-label">Action URL template</span><input className="input" value={actionUrl} onChange={e=>setActionUrl(e.target.value)}/></label><label className="field col-6"><span className="field-label">Data template JSON</span><textarea className="input" rows={6} value={data} onChange={e=>setData(e.target.value)}/></label><label className="field col-6"><span className="field-label">Variable documentation JSON</span><textarea className="input" rows={6} value={variables} onChange={e=>setVariables(e.target.value)}/></label><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label></fieldset><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save template'}</button></div></div>;}
|
|
47
61
|
const templateColumns:Column<PushTemplateRow>[]=[{key:'name',label:'Template',sortable:true,hideable:false},{key:'category',label:'Category',sortable:true},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System')},{key:'priority',label:'Priority',sortable:true,render:r=><Badge tone={r.priority==='high'?'warning':'info'}>{r.priority}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
|
|
48
|
-
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
49
|
-
|
|
62
|
+
function TemplateDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
63
|
+
const query=PushTemplateModel.useOne(id);const save=PushTemplateModel.useSave();const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;
|
|
64
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading template…':'Template unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
65
|
+
const row=query.data;const edit=()=>void openTemplateEditor(row,()=>query.refetch());
|
|
66
|
+
const toggle=async(next:boolean)=>{if(!canManage||save.isPending)return;try{await save.mutateAsync({id,changes:{is_active:next}});await query.refetch();}catch(error){toast.error(error instanceof Error?error.message:'State change failed');}};
|
|
67
|
+
return <DetailView title={row.name} subtitle={`${row.category} · ${relationLabel(row.group,'System template')}`} icon="bi-file-earmark-richtext" onClose={onClose}
|
|
68
|
+
active={canManage?{value:row.is_active,disabled:save.isPending,onChange:next=>void toggle(next)}:undefined}
|
|
69
|
+
contextMenu={canManage?[{label:'Edit template',disabled:save.isPending,onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:save.isPending,onSelect:()=>void toggle(!row.is_active)}]:[]}
|
|
70
|
+
sections={[{key:'overview',label:'Overview',icon:'bi-info-circle',render:()=><div className="detail-section"><FlatRow label="Template ID">#{row.id}</FlatRow><FlatRow label="Scope">{relationLabel(row.group,'System template')}</FlatRow><FlatRow label="Category">{row.category}</FlatRow><FlatRow label="Priority">{row.priority}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow></div>},
|
|
71
|
+
{key:'template',label:'Notification',icon:'bi-card-text',render:()=><div className="detail-section"><div className="push-preview"><strong>{row.title_template||'Data-only notification'}</strong><p>{row.body_template||'No visible message body.'}</p></div><FlatRow label="Action URL"><code>{row.action_url||'—'}</code></FlatRow><FlatRow label="Data keys">{Object.keys(row.data_template).join(', ')||'—'}</FlatRow></div>},
|
|
72
|
+
{key:'variables',label:'Variables',icon:'bi-braces',render:()=><div className="detail-section">{Object.keys(row.variables).length?Object.entries(row.variables).map(([key,value])=><FlatRow key={key} label={key}>{String(value??'—')}</FlatRow>):<p className="dim">No variables documented.</p>}</div>}]} />;
|
|
73
|
+
}
|
|
74
|
+
export function PushTemplatesPage(){const canManage=useCan(PUSH_TEMPLATE_MANAGE_PERMISSIONS).can;return <ModelTable model={PushTemplateModel} eyebrow="Communications · Push" title="Templates" columns={templateColumns} defaultSort="name" searchable searchPlaceholder="Search name or category" columnChooser persistState persistKey="admin:push:templates" addLabel="Create template" onAdd={canManage?()=>void openTemplateEditor():undefined} onRowClick={row=>void modal.detail(close=><TemplateDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
50
75
|
|
|
51
|
-
function ConfigEditor({row,close}:{row?:PushConfigRow;close:(value:PushConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const credentialRef=useRef<HTMLTextAreaElement>(null);const [clearCredential,setClearCredential]=useState(false);const [name,setName]=useState(row?.name??'');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [sound,setSound]=useState(row?.default_sound??'default');const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=()=>{if(clearCredential){setClearCredential(false);return;}if(window.confirm('Clear the stored FCM service account on save?'))setClearCredential(true);};const submit=async()=>{setBusy(true);setError('');let credential:FcmCredentialEdit={mode:'untouched'};try{const source=credentialRef.current?.value??'';if(clearCredential)credential={mode:'clear',confirmed:true};else if(source.trim())credential={mode:'replace',value:parseFcmServiceAccount(source)};const saved=await savePushConfigImperative(qc,row?.id??null,{name:name.trim(),group:group?Number(group):null,default_sound:sound.trim()||'default',is_active:active,test_mode:testMode},credential);toast.success(row?'Push configuration updated':'Push configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved.');}finally{if(credentialRef.current)credentialRef.current.value='';credential={mode:'untouched'};setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push configuration</h2><p className="dim">The FCM service account is write-only and never prefills. Leave blank to keep it, enter a validated service-account JSON object to replace it, or explicitly confirm clear.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<
|
|
76
|
+
function ConfigEditor({row,close,pending}:{row?:PushConfigRow;pending:{current:boolean};close:(value:PushConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PUSH_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['push','group-choices'],queryFn:fetchPushGroupChoices,enabled:canGroups,staleTime:60_000});const credentialRef=useRef<HTMLTextAreaElement>(null);const [clearCredential,setClearCredential]=useState(false);const [name,setName]=useState(row?.name??'');const [group,setGroup]=useState(row?.group?String(row.group.id):'');const [sound,setSound]=useState(row?.default_sound??'default');const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=()=>{if(clearCredential){setClearCredential(false);return;}if(window.confirm('Clear the stored FCM service account on save?'))setClearCredential(true);};const submit=async()=>{if(pending.current)return;pending.current=true;setBusy(true);setError('');let credential:FcmCredentialEdit={mode:'untouched'};try{const source=credentialRef.current?.value??'';if(clearCredential)credential={mode:'clear',confirmed:true};else if(source.trim())credential={mode:'replace',value:parseFcmServiceAccount(source)};const saved=await savePushConfigImperative(qc,row?.id??null,{name:name.trim(),group:group?Number(group):null,default_sound:sound.trim()||'default',is_active:active,test_mode:testMode},credential);toast.success(row?'Push configuration updated':'Push configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved.');}finally{if(credentialRef.current)credentialRef.current.value='';credential={mode:'untouched'};pending.current=false;setBusy(false);}};return <div className="modal-pad push-editor"><h2 className="modal-title">{row?'Edit':'Create'} push configuration</h2><p className="dim">The FCM service account is write-only and never prefills. Leave blank to keep it, enter a validated service-account JSON object to replace it, or explicitly confirm clear.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<fieldset disabled={busy} className="form-grid push-fields"><label className="field col-8"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-4"><span className="field-label">Default sound</span><input className="input" value={sound} onChange={e=>setSound(e.target.value)}/></label>{canGroups?<label className="field col-12"><span className="field-label">Group</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System default</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>:<div className="field col-12"><span className="field-label">Group</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null,'System default')}</div></div>}<label className="field col-12"><span className="field-label">FCM service account JSON</span><textarea ref={credentialRef} className="input" rows={9} autoComplete="off" spellCheck={false} disabled={clearCredential}/><span className="field-help">Required fields: type=service_account, project_id, client_email, and a PEM private_key.</span></label><div className="col-12"><button type="button" className={`btn${clearCredential?' btn-danger':''}`} onClick={toggleClear}>{clearCredential?'Keep stored credential':'Clear stored credential…'}</button></div><label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label><label className="check col-4"><input type="checkbox" checked={testMode} onChange={e=>setTestMode(e.target.checked)}/><span>Simulation mode (no real sends)</span></label></fieldset><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save configuration'}</button></div></div>;}
|
|
52
77
|
const configColumns:Column<PushConfigRow>[]=[{key:'name',label:'Configuration',sortable:true,hideable:false},{key:'group',label:'Scope',render:r=>relationLabel(r.group,'System default')},{key:'fcm_project_id',label:'FCM project',render:r=>r.fcm_project_id||'Not reported'},{key:'test_mode',label:'Test mode',sortable:true,render:r=><Badge tone={r.test_mode?'warning':'muted'}>{r.test_mode?'yes':'no'}</Badge>},{key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>}];
|
|
53
|
-
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void})
|
|
54
|
-
|
|
78
|
+
function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}) {
|
|
79
|
+
const query=PushConfigModel.useOne(id);const qc=useQueryClient();const [saving,setSaving]=useState(false);const [testing,setTesting]=useState(false);const testPending=useRef(false);
|
|
80
|
+
const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;const tester=useCan(PUSH_CONFIG_TEST_PERMISSIONS);const canTest=tester.can;
|
|
81
|
+
const revision=`${id}:${query.data?.modified}:${canTest}:${tester.me?.id}`;const own=usePushRequestOwner(revision);
|
|
82
|
+
const [result,setResult]=useState<{value:PushTestResult;at:number;revision:string}|null>(null);
|
|
83
|
+
if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
|
|
84
|
+
const row=query.data;const busy=saving||testing;
|
|
85
|
+
const edit=()=>{if(!canManage||testPending.current||saving)return;setResult(null);void openConfigEditor(row,()=>query.refetch());};
|
|
86
|
+
const test=async()=>{if(!canTest||testPending.current||saving)return;testPending.current=true;setTesting(true);setResult(null);const current=own();try{const value=await testPushConfigConnection(id);if(current())setResult({value,at:Date.now()/1000,revision});}finally{testPending.current=false;setTesting(false);}};
|
|
87
|
+
const toggle=async(next:boolean)=>{if(!canManage||busy||testPending.current)return;setResult(null);setSaving(true);try{await savePushConfigImperative(qc,id,{is_active:next},{mode:'untouched'});await query.refetch();}catch(error){toast.error(error instanceof Error?error.message:'State change failed');}finally{setSaving(false);}};
|
|
88
|
+
const shown=result?.revision===revision?result:null;
|
|
89
|
+
return <DetailView title={row.name} subtitle={`${relationLabel(row.group,'System default')} · ${row.fcm_project_id||'FCM project unavailable'}`} icon="bi-broadcast-pin" onClose={onClose}
|
|
90
|
+
chips={[{text:row.test_mode?'Simulation mode':'Real sends',tone:row.test_mode?'warning':'info'}]}
|
|
91
|
+
active={canManage?{value:row.is_active,disabled:busy,onChange:next=>void toggle(next)}:undefined}
|
|
92
|
+
contextMenu={[...(canManage?[{label:'Edit configuration',disabled:busy,onSelect:edit},{label:row.is_active?'Deactivate':'Reactivate',disabled:busy,onSelect:()=>void toggle(!row.is_active)}]:[]),...(canTest?[{label:testing?'Checking FCM…':'Check FCM connection',disabled:busy,onSelect:()=>void test()}]:[])]}
|
|
93
|
+
sections={[{key:'overview',label:'Overview',icon:'bi-sliders',render:()=><div className="detail-section"><FlatRow label="Scope">{relationLabel(row.group,'System default')}{row.group?` · #${row.group.id}`:''}</FlatRow><FlatRow label="Configuration ID">#{row.id}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Send mode">{row.test_mode?'Simulation — device sends are skipped':'Real notifications'}</FlatRow><FlatRow label="Default sound">{row.default_sound}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Updated">{fmt.datetime(row.modified)}</FlatRow><p className="dim">A user’s active organization configuration takes precedence over the system default. The device test shows which configuration applies.</p>{shown&&<PushTestResultView result={shown.value} at={shown.at}/>}</div>},
|
|
94
|
+
{key:'connection',label:'Connection',icon:'bi-plug',render:()=><div className="detail-section"><FlatRow label="Provider">Firebase Cloud Messaging (HTTP v1)</FlatRow><FlatRow label="FCM project">{row.fcm_project_id||'Not reported'}</FlatRow><FlatRow label="Service account">{row.fcm_client_email||'Not reported'}</FlatRow><FlatRow label="Credentials">{row.has_fcm_credentials===null?'Presence not reported':row.has_fcm_credentials?'Stored':'Missing'}</FlatRow>
|
|
95
|
+
<p className="dim">Check FCM connection verifies the stored service account’s permission to send to this project. It contacts FCM even in simulation mode and does not deliver a notification.</p>
|
|
96
|
+
{testing?<p role="status">Checking FCM connection…</p>:shown?<PushTestResultView result={shown.value} at={shown.at}/>:<p className="dim">No connection check for this configuration in this view.</p>}
|
|
97
|
+
</div>}]} />;
|
|
98
|
+
}
|
|
99
|
+
export function PushConfigsPage(){const canManage=useCan(PUSH_CONFIG_MANAGE_PERMISSIONS).can;return <ModelTable model={PushConfigModel} eyebrow="Communications · Push" title="Configuration" columns={configColumns} defaultSort="name" searchable searchPlaceholder="Search configuration names" columnChooser persistState persistKey="admin:push:configs" addLabel="Create configuration" onAdd={canManage?()=>void openConfigEditor():undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
|
|
100
|
+
|
|
101
|
+
export function PushPage(){const [searchParams,setSearchParams]=useSearchParams();const devices=useCan(PUSH_DEVICE_VIEW_PERMISSIONS);const deliveries=useCan(PUSH_DELIVERY_VIEW_PERMISSIONS);const templates=useCan(PUSH_TEMPLATE_VIEW_PERMISSIONS);const config=useCan(PUSH_CONFIG_VIEW_PERMISSIONS);const metrics=useCan(PUSH_METRICS_PERMISSIONS);if(devices.isLoading||deliveries.isLoading||templates.isLoading||config.isLoading||metrics.isLoading)return <div className="panel panel-pad dim">Loading Push permissions…</div>;const items=[{key:'stats',label:'Your stats',panel:<PushStatsPanel/>},...(metrics.can?[{key:'metrics',label:'Global metrics',panel:<PushMetricsPanel/>}]:[]),...(devices.can?[{key:'devices',label:'Devices',panel:<PushDevicesPage/>}]:[]),...(deliveries.can?[{key:'deliveries',label:'Deliveries',panel:<PushDeliveriesPage/>}]:[]),...(templates.can?[{key:'templates',label:'Templates',panel:<PushTemplatesPage/>}]:[]),...(config.can?[{key:'config',label:'Config',panel:<PushConfigsPage/>}]:[])];const requested=searchParams.get('push_surface')??'stats';const select=(key:string)=>setSearchParams({...Object.fromEntries([...searchParams].filter(([name])=>name==='demo')),push_surface:key},{replace:true});return <div className="push-page"><div className="push-heading"><p className="eyebrow">Communications</p><h1>Push Notifications</h1><p className="dim">Inspect registered devices, send test notifications, and verify your push configuration.</p></div><Tabs ariaLabel="Push administration areas" variant="underline-all" items={items} activeKey={requested} onActiveKeyChange={select}/></div>;}
|
|
55
102
|
|
|
56
|
-
|
|
103
|
+
function openTemplateEditor(row?:PushTemplateRow,onSaved?:()=>unknown){const pending={current:false};return modal.open(close=><TemplateEditor row={row} pending={pending} close={saved=>{close(saved);if(saved)void onSaved?.();}}/>,{size:'lg',canDismiss:()=>!pending.current});}
|
|
104
|
+
function openConfigEditor(row?:PushConfigRow,onSaved?:()=>unknown){const pending={current:false};return modal.open(close=><ConfigEditor row={row} pending={pending} close={saved=>{close(saved);if(saved)void onSaved?.();}}/>,{size:'lg',canDismiss:()=>!pending.current});}
|