portal-mojo 0.2.1 → 0.2.2
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.
|
@@ -67,10 +67,14 @@ All ordinary API-key lists force `graph=default`, discard URL/persisted
|
|
|
67
67
|
`graph` and unsupported search params before creating a query key, and scrub
|
|
68
68
|
token fields from every list/detail/save result before any cache write.
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
The guided forms include `send_sms` and the broader `comms` permission. Create
|
|
71
|
+
and edit also expose **Additional permission names** as freeform tags, so a
|
|
72
|
+
new backend permission does not require a portal release before a key can use
|
|
73
|
+
it. Create sends only true guided controls and named tags. Edit diffs only
|
|
74
|
+
controls that were actually rendered, adds newly named tags, and sends `false`
|
|
75
|
+
for removed custom tags. Hidden protected grants are preserved unless an
|
|
76
|
+
operator deliberately enters their name; django-mojo remains authoritative
|
|
77
|
+
for every grant and revocation.
|
|
74
78
|
|
|
75
79
|
The permission catalog is live and injectable:
|
|
76
80
|
|
|
@@ -89,6 +93,11 @@ Registration replaces the same permission name and appends new names. Mounted
|
|
|
89
93
|
editors subscribe to changes. `grantPermissions` gates the control itself; the
|
|
90
94
|
backend remains authoritative on every save.
|
|
91
95
|
|
|
96
|
+
`normalizeApiKeyPermissionNames()` trims and deduplicates TagInput CSV (or an
|
|
97
|
+
array) and rejects the reserved `__replace` JSON update signal.
|
|
98
|
+
`buildApiKeyPermissionChanges()` constructs the narrow merge patch used by the
|
|
99
|
+
edit form without resending unchanged custom grants.
|
|
100
|
+
|
|
92
101
|
### Rate-limit overrides
|
|
93
102
|
|
|
94
103
|
Group API keys expose the raw django-mojo JSONField shape without normalizing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
} from '../../ui';
|
|
12
12
|
import {
|
|
13
13
|
GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
|
|
14
|
-
GroupApiKeyModel,
|
|
14
|
+
GroupApiKeyModel, buildApiKeyPermissionChanges, customApiKeyPermissionNames,
|
|
15
|
+
fetchApiKeyToken,
|
|
15
16
|
getGroupApiKeyPermissions, grantedPermissions,
|
|
16
17
|
groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
|
|
17
|
-
readApiKeyRateLimits, useCreateGroupApiKey,
|
|
18
|
+
normalizeApiKeyPermissionNames, readApiKeyRateLimits, useCreateGroupApiKey,
|
|
18
19
|
type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
|
|
19
20
|
} from './models';
|
|
20
21
|
import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
|
|
@@ -60,6 +61,14 @@ function permissionSeed(
|
|
|
60
61
|
]));
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
const CUSTOM_PERMISSION_FIELD: Field = {
|
|
65
|
+
name: 'custom_permissions',
|
|
66
|
+
type: 'tags',
|
|
67
|
+
label: 'Additional permission names',
|
|
68
|
+
placeholder: 'Type a permission and press Enter',
|
|
69
|
+
help: 'Add permission strings that are not listed above. Remove a tag to revoke it when editing; the server authorizes every change.',
|
|
70
|
+
};
|
|
71
|
+
|
|
63
72
|
function TokenFooter({ permissions }: { permissions: string[] }) {
|
|
64
73
|
return (
|
|
65
74
|
<div className="ga-secret-footnote">
|
|
@@ -106,6 +115,7 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
106
115
|
placeholder: 'Search groups…',
|
|
107
116
|
} satisfies Field] : []),
|
|
108
117
|
...permissionFields(renderedPermissions),
|
|
118
|
+
CUSTOM_PERMISSION_FIELD,
|
|
109
119
|
]}
|
|
110
120
|
submitText="Create key"
|
|
111
121
|
onCancel={() => close(null)}
|
|
@@ -116,8 +126,16 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
116
126
|
if (!result) return;
|
|
117
127
|
|
|
118
128
|
const permissions: Record<string, true> = {};
|
|
119
|
-
|
|
120
|
-
|
|
129
|
+
try {
|
|
130
|
+
for (const def of renderedPermissions) {
|
|
131
|
+
if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
|
|
132
|
+
}
|
|
133
|
+
for (const name of normalizeApiKeyPermissionNames(result.custom_permissions)) {
|
|
134
|
+
permissions[name] = true;
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
toast.error(error instanceof Error ? error.message : 'Invalid permission name');
|
|
138
|
+
return;
|
|
121
139
|
}
|
|
122
140
|
const group = fixedGroup?.id ?? Number(result.group);
|
|
123
141
|
if (!Number.isFinite(group) || group <= 0) {
|
|
@@ -162,8 +180,14 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
162
180
|
{ name: 'name', type: 'text', label: 'Name', required: true, columns: 6 },
|
|
163
181
|
{ name: 'is_active', type: 'switch', label: 'Active', columns: 6 },
|
|
164
182
|
...renderedFields,
|
|
183
|
+
CUSTOM_PERMISSION_FIELD,
|
|
165
184
|
]}
|
|
166
|
-
initial={{
|
|
185
|
+
initial={{
|
|
186
|
+
name: row.name,
|
|
187
|
+
is_active: row.is_active,
|
|
188
|
+
...before,
|
|
189
|
+
custom_permissions: customApiKeyPermissionNames(row.permissions).join(','),
|
|
190
|
+
}}
|
|
167
191
|
submitText="Save changes"
|
|
168
192
|
onCancel={() => close(null)}
|
|
169
193
|
onSubmit={(form) => close(form)}
|
|
@@ -178,14 +202,20 @@ function useGroupApiKeyActions(permission: PermSpec) {
|
|
|
178
202
|
const nextActive = result.is_active === true;
|
|
179
203
|
if (nextActive !== row.is_active) changes.is_active = nextActive;
|
|
180
204
|
|
|
181
|
-
// Diff only controls that were actually rendered
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
// Diff only guided controls that were actually rendered, plus the
|
|
206
|
+
// explicit freeform set. Protected hidden grants remain absent unless
|
|
207
|
+
// the operator deliberately types one (the server is authoritative).
|
|
208
|
+
let permissionChanges: Record<string, boolean>;
|
|
209
|
+
try {
|
|
210
|
+
permissionChanges = buildApiKeyPermissionChanges(
|
|
211
|
+
row.permissions,
|
|
212
|
+
renderedPermissions,
|
|
213
|
+
result,
|
|
214
|
+
result.custom_permissions,
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
toast.error(error instanceof Error ? error.message : 'Invalid permission name');
|
|
218
|
+
return;
|
|
189
219
|
}
|
|
190
220
|
if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
|
|
191
221
|
if (!Object.keys(changes).length) return;
|
|
@@ -378,7 +408,9 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
|
|
|
378
408
|
? permissions.map((permission) => <Badge key={permission} tone="info">{permission}</Badge>)
|
|
379
409
|
: <span className="dim-italic">No permissions granted</span>}
|
|
380
410
|
</div>
|
|
381
|
-
<p className="dim">
|
|
411
|
+
<p className="dim">
|
|
412
|
+
Use Edit to add or remove arbitrary permission names. Hidden protected controls are never changed implicitly.
|
|
413
|
+
</p>
|
|
382
414
|
</>
|
|
383
415
|
),
|
|
384
416
|
},
|
|
@@ -329,6 +329,16 @@ registerGroupApiKeyPermissions([
|
|
|
329
329
|
{ name: 'view_members', label: 'View Members' },
|
|
330
330
|
{ name: 'manage_members', label: 'Manage Members' },
|
|
331
331
|
{ name: 'view_billing', label: 'View Billing' },
|
|
332
|
+
{
|
|
333
|
+
name: 'send_sms',
|
|
334
|
+
label: 'Send SMS',
|
|
335
|
+
tooltip: 'Send outbound SMS messages for this key\'s group.',
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'comms',
|
|
339
|
+
label: 'Communications',
|
|
340
|
+
tooltip: 'Broad communications access, including SMS. Prefer Send SMS when that is all the integration needs.',
|
|
341
|
+
},
|
|
332
342
|
{
|
|
333
343
|
name: 'geoip_sync',
|
|
334
344
|
label: 'GeoIP Federation Sync',
|
|
@@ -344,6 +354,60 @@ export function grantedPermissions(dict: Record<string, unknown> | null | undefi
|
|
|
344
354
|
.map(([name]) => name);
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
/** TagInput CSV or array to unique permission names. */
|
|
358
|
+
export function normalizeApiKeyPermissionNames(raw: unknown): string[] {
|
|
359
|
+
const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
|
|
360
|
+
const names: string[] = [];
|
|
361
|
+
for (const value of values) {
|
|
362
|
+
let name = String(value ?? '').trim();
|
|
363
|
+
if (name.startsWith('permissions.')) name = name.slice('permissions.'.length).trim();
|
|
364
|
+
if (!name) continue;
|
|
365
|
+
if (name === '__replace') {
|
|
366
|
+
throw new Error('Permission "__replace" is reserved and cannot be granted.');
|
|
367
|
+
}
|
|
368
|
+
if (!names.includes(name)) names.push(name);
|
|
369
|
+
}
|
|
370
|
+
return names;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Truthy stored grants that have no registered guided control. */
|
|
374
|
+
export function customApiKeyPermissionNames(
|
|
375
|
+
dict: Record<string, unknown> | null | undefined,
|
|
376
|
+
): string[] {
|
|
377
|
+
const registered = new Set(getGroupApiKeyPermissions().map((permission) => permission.name));
|
|
378
|
+
return grantedPermissions(dict).filter((name) => !registered.has(name)).sort();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build a merge-safe permissions patch from rendered switches and freeform
|
|
383
|
+
* tags. Only rendered switches are diffed, so protected controls hidden from
|
|
384
|
+
* this operator remain untouched. Missing previously-custom tags are revoked.
|
|
385
|
+
*/
|
|
386
|
+
export function buildApiKeyPermissionChanges(
|
|
387
|
+
current: Record<string, unknown> | null | undefined,
|
|
388
|
+
rendered: ApiKeyPermissionDef[],
|
|
389
|
+
form: Record<string, unknown>,
|
|
390
|
+
customRaw: unknown,
|
|
391
|
+
): Record<string, boolean> {
|
|
392
|
+
const currentGranted = new Set(grantedPermissions(current));
|
|
393
|
+
const changes: Record<string, boolean> = {};
|
|
394
|
+
|
|
395
|
+
for (const permission of rendered) {
|
|
396
|
+
const next = form[`permissions.${permission.name}`] === true;
|
|
397
|
+
if (currentGranted.has(permission.name) !== next) changes[permission.name] = next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const beforeCustom = new Set(customApiKeyPermissionNames(current));
|
|
401
|
+
const afterCustom = normalizeApiKeyPermissionNames(customRaw);
|
|
402
|
+
for (const name of afterCustom) {
|
|
403
|
+
if (!currentGranted.has(name)) changes[name] = true;
|
|
404
|
+
}
|
|
405
|
+
for (const name of beforeCustom) {
|
|
406
|
+
if (!afterCustom.includes(name)) changes[name] = false;
|
|
407
|
+
}
|
|
408
|
+
return changes;
|
|
409
|
+
}
|
|
410
|
+
|
|
347
411
|
export interface WebhookSubscriptionRow {
|
|
348
412
|
id: number;
|
|
349
413
|
created: number;
|