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
- Create sends only permission controls whose value is `true`. Edit compares only
71
- controls that were actually rendered and sends a partial permission dictionary;
72
- unknown or protected grants are therefore preserved, while a visible changed
73
- grant may send `false` to revoke it.
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.1",
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, fetchApiKeyToken,
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
- for (const def of renderedPermissions) {
120
- if (result[`permissions.${def.name}`] === true) permissions[def.name] = true;
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={{ name: row.name, is_active: row.is_active, ...before }}
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. Unknown and
182
- // protected grants remain absent from the partial JSONField update.
183
- const permissionChanges: Record<string, boolean> = {};
184
- for (const def of renderedPermissions) {
185
- const key = `permissions.${def.name}`;
186
- const was = before[key] === true;
187
- const now = result[key] === true;
188
- if (was !== now) permissionChanges[def.name] = now;
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">Only controls visible to the operator are diffed when editing; unknown grants are preserved.</p>
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;