portal-mojo 0.2.3 → 0.2.5

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.
Files changed (48) hide show
  1. package/README.md +3 -0
  2. package/docs/ModelTable.md +26 -0
  3. package/docs/README.md +3 -1
  4. package/docs/admin-fleet-configuration.md +50 -0
  5. package/docs/admin-phonehub.md +11 -1
  6. package/docs/admin-push.md +85 -6
  7. package/docs/admin-settings.md +10 -0
  8. package/docs/admin-signin.md +73 -0
  9. package/docs/detail-primitives.md +12 -1
  10. package/docs/feedback.md +60 -1
  11. package/docs/forms.md +41 -0
  12. package/docs/loading.md +3 -2
  13. package/docs/popover.md +3 -3
  14. package/docs/realtime.md +5 -0
  15. package/package.json +1 -1
  16. package/src/admin/credentials/group-api-keys.tsx +20 -0
  17. package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
  18. package/src/admin/domains/identity.ts +7 -1
  19. package/src/admin/domains/operations.ts +8 -1
  20. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
  21. package/src/admin/fleet-configuration/data.ts +89 -0
  22. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  23. package/src/admin/index.ts +3 -0
  24. package/src/admin/messaging/push/PushPage.tsx +61 -13
  25. package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
  26. package/src/admin/messaging/push/api.ts +25 -1
  27. package/src/admin/messaging/push/models.ts +7 -6
  28. package/src/admin/phonehub/PhoneHubPage.tsx +19 -8
  29. package/src/admin/phonehub/api.ts +5 -5
  30. package/src/admin/public/identity.ts +1 -0
  31. package/src/admin/public/operations.ts +1 -0
  32. package/src/admin/registry.ts +4 -2
  33. package/src/admin/settings/SettingDetail.tsx +2 -1
  34. package/src/admin/settings/SettingValue.tsx +37 -0
  35. package/src/admin/settings/SettingsPage.tsx +2 -1
  36. package/src/admin/signin/SigninPage.tsx +397 -0
  37. package/src/admin/signin/api.ts +83 -0
  38. package/src/admin/signin/index.ts +4 -0
  39. package/src/client/fleet-mock.ts +61 -0
  40. package/src/client/mock.ts +240 -11
  41. package/src/client/realtime.ts +12 -0
  42. package/src/ui/FormView.tsx +24 -1
  43. package/src/ui/ModelTable.tsx +12 -4
  44. package/src/ui/detail/MetadataSection.tsx +46 -10
  45. package/src/ui/form-autosave.ts +134 -23
  46. package/src/ui/guardrail.tsx +125 -0
  47. package/src/ui/index.ts +1 -0
  48. package/src/ui/toast.tsx +48 -5
@@ -1,3 +1,4 @@
1
+ import { fleetMock } from './fleet-mock';
1
2
  // In-memory django-mojo mock. Speaks the EXACT wire contract the real client
2
3
  // uses — envelope {status, data|rows..., message}, start/size paging, sort
3
4
  // with '-' prefix, search, and Django-style lookups (field, field__in,
@@ -4287,6 +4288,11 @@ const db = {
4287
4288
  // The scheduler lock, as `control/force-scheduler-lead` sees it: a Redis
4288
4289
  // string key whose VALUE is the holder. Deleting it is the whole control.
4289
4290
  jobsSchedulerLock: 'runner-mojo-web-01-engine' as string | null,
4291
+ // System Sign-in admin (#5547): the SYSTEM auth_config override (nested,
4292
+ // layered after the deployment file) and OAuth credentials stored in the
4293
+ // Admin. Apple is seeded half-configured; Google has nothing stored.
4294
+ systemAuthConfig: {} as Record<string, unknown>,
4295
+ signinCredentials: { APPLE_TEAM_ID: 'ABCDE12345', APPLE_CLIENT_ID: 'com.example.web' } as Record<string, string>,
4290
4296
  // Per-user login throttle counters (auth/manage/throttle shape). u3 is
4291
4297
  // mid-lockout so the header badge + Clear Rate Limit are demoable.
4292
4298
  throttle: new Map<number, { count: number; limit: number; window: number; retry_after_seconds: number }>([
@@ -4652,16 +4658,20 @@ const LOGIN_METHODS = new Set(['password', 'sms', 'passkey', 'magic', 'google',
4652
4658
  const REGISTRATION_METHODS = new Set(['password', 'google', 'apple', 'github']);
4653
4659
  const DEFAULT_AUTH_CONFIG: Record<string, unknown> = {
4654
4660
  theme: {
4655
- app_title: 'DJANGO MOJO', logo_url: '', favicon_url: '', hero_image_url: '',
4656
- hero_headline: 'Welcome back', hero_subheadline: 'Admin Portal',
4657
- back_to_website_url: '', terms_url: '', layout: 'card', api_base: '',
4661
+ app_title: 'DJANGO MOJO', auth_provider_name: 'DJANGO MOJO', logo_url: '', favicon_url: '', hero_image_url: '',
4662
+ hero_image_url_light: '', hero_image_url_dark: '',
4663
+ hero_headline: 'Welcome back', hero_subheadline: 'Admin Portal', hero_image_position: 'center',
4664
+ back_to_website_url: '', back_to_website_label: 'Back to website', terms_url: '',
4665
+ // Legacy token kept for the group dialog's card/fullscreen editor; the
4666
+ // system Sign-in admin reads it through SIGNIN_LAYOUT_ALIASES.
4667
+ layout: 'card', appearance: 'system', accent_color: '#6384ff', api_base: '',
4658
4668
  success_redirect: '/', custom_css: '', custom_css_url: '',
4659
4669
  },
4660
4670
  registration: {
4661
4671
  enabled: true, fields: null, extra_fields: [], identity_field: '', min_age: null,
4662
4672
  methods: ['password', 'google', 'apple', 'github'], passkey_prompt: 'off',
4663
4673
  },
4664
- login: { methods: ['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github'] },
4674
+ login: { methods: ['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github'], heading: 'Sign In', supporting_copy: '' },
4665
4675
  };
4666
4676
  const DEPLOYMENT_AUTH_CONFIG: Record<string, unknown> = {
4667
4677
  theme: { hero_subheadline: 'NativeMojo identity', terms_url: 'https://example.com/terms' },
@@ -4735,7 +4745,9 @@ function mockGroupChoiceInteger(value: unknown, fallback: number, minimum: numbe
4735
4745
  }
4736
4746
 
4737
4747
  function resolveAuthConfig(group?: MockGroup): Record<string, unknown> {
4738
- let config = mergeDicts(DEFAULT_AUTH_CONFIG, DEPLOYMENT_AUTH_CONFIG);
4748
+ // The system Sign-in admin (#5547) stores its overrides between the
4749
+ // deployment file and the group chain, so /api/auth/config reflects saves.
4750
+ let config = mergeDicts(mergeDicts(DEFAULT_AUTH_CONFIG, DEPLOYMENT_AUTH_CONFIG), db.systemAuthConfig);
4739
4751
  if (group) {
4740
4752
  const chain: MockGroup[] = [];
4741
4753
  let current: MockGroup | undefined = group;
@@ -4769,10 +4781,194 @@ function publicAuthConfig(config: Record<string, unknown>): Record<string, unkno
4769
4781
  methods: Array.isArray(registration.methods) ? registration.methods : [],
4770
4782
  passkey_prompt: registration.passkey_prompt ?? 'off',
4771
4783
  },
4772
- login: { methods: Array.isArray(login.methods) ? login.methods : [] },
4784
+ login: {
4785
+ methods: Array.isArray(login.methods) ? login.methods : [],
4786
+ heading: login.heading ?? 'Sign In',
4787
+ supporting_copy: login.supporting_copy ?? '',
4788
+ },
4773
4789
  };
4774
4790
  }
4775
4791
 
4792
+ // ══ System Sign-in admin (#5547) — GET/POST /api/account/admin/signin ══
4793
+
4794
+ const SIGNIN_EDITABLE = [
4795
+ 'login.heading', 'login.methods', 'login.supporting_copy',
4796
+ 'registration.enabled', 'registration.methods', 'registration.passkey_prompt',
4797
+ 'theme.accent_color', 'theme.app_title', 'theme.appearance', 'theme.auth_provider_name',
4798
+ 'theme.back_to_website_label', 'theme.custom_css', 'theme.favicon_url', 'theme.hero_headline',
4799
+ 'theme.hero_image_position', 'theme.hero_image_url', 'theme.hero_image_url_dark',
4800
+ 'theme.hero_image_url_light', 'theme.hero_subheadline', 'theme.layout', 'theme.logo_url',
4801
+ ];
4802
+ const SIGNIN_OPTIONS = {
4803
+ login_methods: ['password', 'sms', 'passkey', 'magic', 'google', 'apple', 'github'],
4804
+ registration_methods: ['password', 'google', 'apple', 'github'],
4805
+ layouts: ['minimal', 'compact', 'branded-panel', 'editorial'],
4806
+ appearances: ['light', 'dark', 'system'],
4807
+ hero_image_positions: ['center', 'top', 'bottom', 'left', 'right'],
4808
+ passkey_prompts: ['off', 'optional', 'required'],
4809
+ };
4810
+ const SIGNIN_LAYOUT_ALIASES: Record<string, string> = { card: 'compact', fullscreen: 'branded-panel' };
4811
+ const SIGNIN_API_ORIGIN = 'https://api.example.com';
4812
+ /** Values from the server config file — GitHub is configured there. */
4813
+ const SIGNIN_DEPLOYMENT_CREDENTIALS: Record<string, string> = {
4814
+ GITHUB_CLIENT_ID: 'Iv1.8a61f9b3a7aba766',
4815
+ GITHUB_CLIENT_SECRET: 'deployment-github-secret-9f2c',
4816
+ };
4817
+ interface SigninFieldSpec { key: string; label: string; secret: boolean; multiline: boolean }
4818
+ const SIGNIN_PROVIDERS: Array<{ name: string; label: string; console_url: string; help: string; fields: SigninFieldSpec[] }> = [
4819
+ {
4820
+ name: 'google', label: 'Google',
4821
+ console_url: 'https://console.cloud.google.com/apis/credentials',
4822
+ help: 'Create an OAuth client ID (type "Web application") in Google Cloud and add the callback URL below as an authorized redirect URI.',
4823
+ fields: [
4824
+ { key: 'GOOGLE_CLIENT_ID', label: 'Client ID', secret: false, multiline: false },
4825
+ { key: 'GOOGLE_CLIENT_SECRET', label: 'Client secret', secret: true, multiline: false },
4826
+ ],
4827
+ },
4828
+ {
4829
+ name: 'apple', label: 'Apple',
4830
+ console_url: 'https://developer.apple.com/account/resources/identifiers/list/serviceId',
4831
+ help: 'Create a Services ID with Sign in with Apple enabled, register the callback URL as a return URL, then create a key and paste its .p8 contents.',
4832
+ fields: [
4833
+ { key: 'APPLE_TEAM_ID', label: 'Team ID', secret: false, multiline: false },
4834
+ { key: 'APPLE_CLIENT_ID', label: 'Services ID', secret: false, multiline: false },
4835
+ { key: 'APPLE_KEY_ID', label: 'Key ID', secret: false, multiline: false },
4836
+ { key: 'APPLE_PRIVATE_KEY', label: 'Private key (.p8)', secret: true, multiline: true },
4837
+ ],
4838
+ },
4839
+ {
4840
+ name: 'github', label: 'GitHub',
4841
+ console_url: 'https://github.com/settings/developers',
4842
+ help: 'Register a new OAuth App in GitHub developer settings and use the callback URL below as its authorization callback URL.',
4843
+ fields: [
4844
+ { key: 'GITHUB_CLIENT_ID', label: 'Client ID', secret: false, multiline: false },
4845
+ { key: 'GITHUB_CLIENT_SECRET', label: 'Client secret', secret: true, multiline: false },
4846
+ ],
4847
+ },
4848
+ ];
4849
+
4850
+ function signinPayload(): Record<string, unknown> {
4851
+ const auth = publicAuthConfig(resolveAuthConfig());
4852
+ const theme = auth.theme as Record<string, unknown>;
4853
+ const layout = String(theme.layout ?? '');
4854
+ theme.layout = SIGNIN_LAYOUT_ALIASES[layout] ?? (SIGNIN_OPTIONS.layouts.includes(layout) ? layout : 'minimal');
4855
+ const loginMethods = (auth.login as { methods: string[] }).methods;
4856
+ const providers = SIGNIN_PROVIDERS.map((provider) => {
4857
+ const fields = provider.fields.map((field) => {
4858
+ const stored = db.signinCredentials[field.key];
4859
+ const deployed = SIGNIN_DEPLOYMENT_CREDENTIALS[field.key];
4860
+ const value = stored ?? deployed;
4861
+ const source = stored != null ? 'admin' : deployed != null ? 'deployment' : 'none';
4862
+ return {
4863
+ key: field.key, label: field.label, secret: field.secret, multiline: field.multiline,
4864
+ configured: value != null, source,
4865
+ value: field.secret || value == null ? null : value,
4866
+ hint: field.secret && value != null ? `…${value.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '').slice(-4)}` : null,
4867
+ };
4868
+ });
4869
+ const missing = fields.filter((field) => !field.configured).map((field) => field.label);
4870
+ return {
4871
+ name: provider.name, label: provider.label,
4872
+ enabled: loginMethods.includes(provider.name),
4873
+ ready: missing.length === 0, missing,
4874
+ callback_url: `${SIGNIN_API_ORIGIN}/api/auth/oauth/${provider.name}/callback`,
4875
+ console_url: provider.console_url, help: provider.help, fields,
4876
+ };
4877
+ });
4878
+ return { schema_version: 1, auth, editable: [...SIGNIN_EDITABLE], options: structuredClone(SIGNIN_OPTIONS), providers };
4879
+ }
4880
+
4881
+ function signinBadRequest(error: string): Record<string, unknown> {
4882
+ return { status: false, error, error_code: 400 };
4883
+ }
4884
+
4885
+ function validateSigninAuthValue(path: string, value: unknown): string | null {
4886
+ const oneOf = (allowed: string[]) => typeof value === 'string' && allowed.includes(value) ? null : `${path} must be one of: ${allowed.join(', ')}`;
4887
+ const methods = (allowed: string[]) => {
4888
+ if (!Array.isArray(value)) return `${path} must be a list`;
4889
+ const unknown = value.find((method) => typeof method !== 'string' || !allowed.includes(method));
4890
+ return unknown == null ? null : `${path} has an unknown method '${String(unknown)}'`;
4891
+ };
4892
+ switch (path) {
4893
+ case 'theme.layout': return oneOf(SIGNIN_OPTIONS.layouts);
4894
+ case 'theme.appearance': return oneOf(SIGNIN_OPTIONS.appearances);
4895
+ case 'theme.hero_image_position': return oneOf(SIGNIN_OPTIONS.hero_image_positions);
4896
+ case 'registration.passkey_prompt': return oneOf(SIGNIN_OPTIONS.passkey_prompts);
4897
+ case 'registration.enabled': return typeof value === 'boolean' ? null : `${path} must be true or false`;
4898
+ case 'registration.methods': return methods(SIGNIN_OPTIONS.registration_methods);
4899
+ case 'login.methods': {
4900
+ const error = methods(SIGNIN_OPTIONS.login_methods);
4901
+ if (error) return error;
4902
+ return (value as string[]).includes('password') ? null : 'Password sign-in must stay enabled so admins can always log in.';
4903
+ }
4904
+ case 'theme.accent_color': return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? null : `${path} must be a #rrggbb color`;
4905
+ case 'theme.custom_css':
4906
+ if (typeof value !== 'string') return `${path} must be a string`;
4907
+ return value.includes('<') || value.toLowerCase().includes('@import') || value.includes('://') ? `${path} cannot reference external content` : null;
4908
+ default: return typeof value === 'string' ? null : `${path} must be a string`;
4909
+ }
4910
+ }
4911
+
4912
+ function setSystemAuthPath(path: string, value: unknown): void {
4913
+ const [section, key] = path.split('.') as [string, string];
4914
+ const current = isPlainObject(db.systemAuthConfig[section]) ? { ...(db.systemAuthConfig[section] as Record<string, unknown>) } : {};
4915
+ current[key] = Array.isArray(value) ? [...value] : value;
4916
+ db.systemAuthConfig = { ...db.systemAuthConfig, [section]: current };
4917
+ }
4918
+
4919
+ function signinFetch(method: string, body: Record<string, unknown>, headers: Record<string, string> | undefined): unknown {
4920
+ const caller = userFromBearer(headers);
4921
+ if (!caller) return permissionDenied(401);
4922
+ if (!hasGlobalPermission(caller, ['manage_settings', 'admin'])) return permissionDenied();
4923
+ if (method === 'GET') return { status: true, data: signinPayload() };
4924
+ if (method !== 'POST') return { status: false, error: 'Method not allowed', error_code: 405 };
4925
+
4926
+ // Validate everything first so a rejected request changes nothing.
4927
+ const authChanges: Array<[string, unknown]> = [];
4928
+ if (body.auth != null) {
4929
+ if (!isPlainObject(body.auth)) return signinBadRequest('auth must be an object of dotted paths');
4930
+ for (const [path, value] of Object.entries(body.auth)) {
4931
+ if (!SIGNIN_EDITABLE.includes(path)) return signinBadRequest(`${path} is not editable here`);
4932
+ const error = validateSigninAuthValue(path, value);
4933
+ if (error) return signinBadRequest(error);
4934
+ authChanges.push([path, value]);
4935
+ }
4936
+ }
4937
+ let provider: typeof SIGNIN_PROVIDERS[number] | undefined;
4938
+ const credentialChanges: Array<[string, string | null]> = [];
4939
+ if (body.provider != null) {
4940
+ provider = SIGNIN_PROVIDERS.find((candidate) => candidate.name === body.provider);
4941
+ if (!provider) return signinBadRequest(`Unknown provider '${String(body.provider)}'`);
4942
+ if (body.values != null) {
4943
+ if (!isPlainObject(body.values)) return signinBadRequest('values must be an object');
4944
+ for (const [key, value] of Object.entries(body.values)) {
4945
+ if (!provider.fields.some((field) => field.key === key)) return signinBadRequest(`${key} is not a ${provider.label} setting`);
4946
+ if (value === undefined || value === '') continue;
4947
+ if (value !== null && typeof value !== 'string') return signinBadRequest(`${key} must be a string or null`);
4948
+ credentialChanges.push([key, value === null ? null : value.trim()]);
4949
+ }
4950
+ }
4951
+ if (body.enabled != null && typeof body.enabled !== 'boolean') return signinBadRequest('enabled must be true or false');
4952
+ } else if (body.values != null || body.enabled != null) {
4953
+ return signinBadRequest('provider is required with values or enabled');
4954
+ }
4955
+
4956
+ for (const [path, value] of authChanges) setSystemAuthPath(path, value);
4957
+ for (const [key, value] of credentialChanges) {
4958
+ if (value === null) delete db.signinCredentials[key];
4959
+ else db.signinCredentials[key] = value;
4960
+ }
4961
+ if (provider && typeof body.enabled === 'boolean') {
4962
+ const resolved = publicAuthConfig(resolveAuthConfig());
4963
+ const toggle = (list: string[]) => body.enabled
4964
+ ? (list.includes(provider!.name) ? list : [...list, provider!.name])
4965
+ : list.filter((name) => name !== provider!.name);
4966
+ setSystemAuthPath('login.methods', toggle((resolved.login as { methods: string[] }).methods));
4967
+ setSystemAuthPath('registration.methods', toggle((resolved.registration as { methods: string[] }).methods));
4968
+ }
4969
+ return { status: true, data: signinPayload() };
4970
+ }
4971
+
4776
4972
  // NO_SAVE_FIELDS parity (account/models/user.py RestMeta): silently dropped,
4777
4973
  // never an error — matching rest.py's strip. NOTE is_dob_verified IS in this
4778
4974
  // set live, which is why the portal ships no DOB force-verify affordance.
@@ -7600,10 +7796,10 @@ const PUSH_CONFIG_GRANTS=['manage_push_config','manage_groups','comms'];
7600
7796
  const PUSH_CONFIG_TEST_GRANTS=['manage_push_config','comms'];
7601
7797
  function mockPushUser(id:number){const row=db.users.find(item=>item.id===id);return row?userBasic(row):null;}
7602
7798
  function mockPushGroup(id:number|null){if(id==null)return null;const row=db.groups.find(item=>item.id===id);return row?{id:row.id,name:row.name}:null;}
7603
- function pushDeviceWire(row:MockPushDevice,graph:string){const basic={id:row.id,device_id:row.device_id,platform:row.platform,device_name:row.device_name,push_enabled:row.push_enabled,last_seen:row.last_seen};if(graph==='basic')return basic;const normal={...basic,app_version:row.app_version,os_version:row.os_version,push_preferences:{...row.push_preferences},user:mockPushUser(row.user)};return graph==='full'?{...normal,created:row.created,modified:row.modified,is_active:row.is_active}:normal;}
7799
+ function pushDeviceWire(row:MockPushDevice,graph:string){const basic={id:row.id,device_id:row.device_id,platform:row.platform,device_name:row.device_name,push_enabled:row.push_enabled,is_active:row.is_active,last_seen:row.last_seen};if(graph==='basic')return basic;const normal={...basic,app_version:row.app_version,os_version:row.os_version,push_preferences:{...row.push_preferences},user:mockPushUser(row.user)};return graph==='full'?{...normal,created:row.created,modified:row.modified,is_active:row.is_active}:normal;}
7604
7800
  function pushTemplateWire(row:MockPushTemplate,graph:string){const basic={id:row.id,name:row.name,category:row.category,priority:row.priority,is_active:row.is_active};if(graph==='basic')return basic;const normal={...basic,title_template:row.title_template,body_template:row.body_template,action_url:row.action_url,data_template:{...row.data_template},variables:{...row.variables},group:mockPushGroup(row.group)};return graph==='full'?{...normal,created:row.created,modified:row.modified}:normal;}
7605
- function pushConfigWire(row:MockPushConfig,graph:string){const basic={id:row.id,name:row.name,test_mode:row.test_mode,default_sound:row.default_sound,is_active:row.is_active,fcm_project_id:row.fcm_project_id};if(graph==='basic')return basic;const normal={...basic,created:row.created,modified:row.modified,group:mockPushGroup(row.group)};return normal;}
7606
- function pushDeliveryWire(row:MockPushDelivery,graph:string){const basic={id:row.id,title:row.title,category:row.category,status:row.status,sent_at:row.sent_at,created:row.created};if(graph==='basic')return basic;const device=db.pushDevices.find(item=>item.id===row.device);const normal={...basic,body:row.body,action_url:row.action_url,data_payload:{...row.data_payload},delivered_at:row.delivered_at,error_message:row.error_message,user:mockPushUser(row.user),device:device?pushDeviceWire(device,'basic'):null};return graph==='full'?{...normal,modified:row.modified,platform_data:{...row.platform_data},template:row.template==null?null:pushTemplateWire(db.pushTemplates.find(item=>item.id===row.template)!,'basic')}:normal;}
7801
+ function pushConfigWire(row:MockPushConfig,graph:string){const basic={id:row.id,name:row.name,test_mode:row.test_mode,default_sound:row.default_sound,is_active:row.is_active,fcm_project_id:row.fcm_project_id,has_fcm_credentials:db.pushConfigSecrets.has(row.id),fcm_client_email:db.pushConfigSecrets.get(row.id)?.client_email??null};if(graph==='basic')return basic;const normal={...basic,created:row.created,modified:row.modified,group:mockPushGroup(row.group)};return normal;}
7802
+ function pushDeliveryWire(row:MockPushDelivery,graph:string){const basic={id:row.id,title:row.title,category:row.category,status:row.status,push_outcome:row.platform_data.test_mode?'simulated':row.status==='delivered'&&row.delivered_at?'delivered':row.platform_data.outcome??row.status,sent_at:row.sent_at,created:row.created};if(graph==='basic')return basic;const device=db.pushDevices.find(item=>item.id===row.device);const normal={...basic,body:row.body,action_url:row.action_url,data_payload:{...row.data_payload},delivered_at:row.delivered_at,error_message:row.error_message,user:mockPushUser(row.user),device:device?pushDeviceWire(device,'basic'):null};return graph==='full'?{...normal,modified:row.modified,platform_data:{...row.platform_data},template:row.template==null?null:pushTemplateWire(db.pushTemplates.find(item=>item.id===row.template)!,'basic')}:normal;}
7607
7803
  function pushOwnRows<T extends {user:number}>(caller:MockUser,rows:T[],grants:string[]):T[]{return hasGlobalPermission(caller,grants)?rows:rows.filter(row=>row.user===caller.id);}
7608
7804
  function applyPushTemplate(row:MockPushTemplate,body:Record<string,unknown>){for(const key of ['name','title_template','body_template','action_url','category'] as const)if(key in body)(row as unknown as Record<string,unknown>)[key]=body[key]==null?null:String(body[key]);if('group'in body)row.group=body.group==null?null:Number(body.group);if('priority'in body&&['low','normal','high'].includes(String(body.priority)))row.priority=String(body.priority) as MockPushTemplate['priority'];if(isPlainObject(body.data_template))row.data_template={...body.data_template};if(isPlainObject(body.variables))row.variables={...body.variables};if('is_active'in body)row.is_active=Boolean(body.is_active);row.modified=Math.floor(Date.now()/1000);}
7609
7805
  function applyPushConfig(row:MockPushConfig,body:Record<string,unknown>){if('name'in body)row.name=String(body.name??'');if('group'in body)row.group=body.group==null?null:Number(body.group);if('default_sound'in body)row.default_sound=String(body.default_sound??'default');if('is_active'in body)row.is_active=Boolean(body.is_active);if('test_mode'in body)row.test_mode=Boolean(body.test_mode);if('fcm_service_account'in body){if(body.fcm_service_account==null){db.pushConfigSecrets.delete(row.id);row.fcm_project_id=null;}else if(isPlainObject(body.fcm_service_account)){db.pushConfigSecrets.set(row.id,{...body.fcm_service_account});row.fcm_project_id=typeof body.fcm_service_account.project_id==='string'?body.fcm_service_account.project_id:null;}}row.modified=Math.floor(Date.now()/1000);}
@@ -7611,7 +7807,34 @@ export function getMockPushConfigCredentialState(id:number):{configured:boolean;
7611
7807
  async function pushFetch(path:string,opts:MockFetchOpts):Promise<unknown|undefined>{
7612
7808
  if(!path.startsWith('/api/account/devices/push'))return undefined;const method=(opts.method??'GET').toUpperCase();const caller=userFromBearer(opts.headers);if(!caller)return permissionDenied(401);
7613
7809
  if(path==='/api/account/devices/push/stats'){if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const deliveries=db.pushDeliveries.filter(row=>row.user===caller.id);const devices=db.pushDevices.filter(row=>row.user===caller.id&&row.is_active);return {status:true,data:{total_sent:deliveries.filter(row=>row.status==='sent').length,total_failed:deliveries.filter(row=>row.status==='failed').length,total_pending:deliveries.filter(row=>row.status==='pending').length,registered_devices:devices.length,enabled_devices:devices.filter(row=>row.push_enabled).length}};}
7614
- const testMatch=path.match(/^\/api\/account\/devices\/push\/config\/(\d+)\/test$/);if(testMatch){if(method!=='POST')return {status:false,error:'Method not allowed',error_code:405};if(!hasGlobalPermission(caller,PUSH_CONFIG_TEST_GRANTS))return permissionDenied();const row=db.pushConfigs.find(item=>item.id===Number(testMatch[1]));if(!row)return {status:false,error:'Push configuration not found',error_code:404};if(row.test_mode)return {status:true,data:{success:true,message:'Config is in test mode - FCM not tested',test_mode:true}};if(!db.pushConfigSecrets.has(row.id))return {status:false,error:'No FCM service account configured',error_code:400,data:{success:false,message:'No FCM service account configured',error:'missing_credentials'}};return {status:true,data:{success:true,message:'FCM v1 credentials valid (dummy token rejected by FCM)',note:'Credentials are valid; the built-in dummy token was rejected as expected.',fcm_version:'v1'}};}
7810
+ if(path==='/api/account/devices/push/test'){
7811
+ if(!['GET','POST'].includes(method))return {status:false,error:'Method not allowed',error_code:405};
7812
+ if(!hasGlobalPermission(caller,['send_notifications','comms']))return permissionDenied();
7813
+ const body=opts.body??{};const deviceId=method==='GET'?opts.params?.device_id:body.device_id;
7814
+ if(typeof deviceId==='boolean'||!/^\d+$/.test(String(deviceId))||!Number.isSafeInteger(Number(deviceId))||Number(deviceId)<=0)return {status:false,error:'A positive registered-device ID is required',error_code:400};
7815
+ const device=pushOwnRows(caller,db.pushDevices,PUSH_DEVICE_VIEW_GRANTS).find(row=>row.id===Number(deviceId));
7816
+ if(!device)return {status:false,error:'Device not found',error_code:404};
7817
+ const user=db.users.find(row=>row.id===device.user);const org=typeof user?.org==='number'?user.org:user?.org?.id;
7818
+ const config=(org?db.pushConfigs.find(row=>row.is_active&&row.group===org):null)??db.pushConfigs.filter(row=>row.is_active&&row.group==null).sort((a,b)=>a.name.localeCompare(b.name))[0];
7819
+ const code=!device.is_active?'inactive_device':!device.push_enabled?'push_disabled':device.push_preferences.test===false?'category_disabled':!device.device_token?'missing_token':!config?'no_config':config.test_mode?'test_mode':!db.pushConfigSecrets.has(config.id)?'missing_credentials':null;
7820
+ const readiness={ready:code===null,device_id:device.id,error_code:code,message:code??'Ready',config:config?{id:config.id,name:config.name,group:mockPushGroup(config.group),is_active:config.is_active,test_mode:config.test_mode,has_fcm_credentials:db.pushConfigSecrets.has(config.id),fcm_project_id:config.fcm_project_id}:null};
7821
+ if(method==='GET')return {status:true,data:readiness};
7822
+ const title=body.title??'Push test';const message=body.message??'This is a test notification.';
7823
+ if(typeof title!=='string'||!title.trim()||title.length>200||typeof message!=='string'||!message.trim()||message.length>1000)return {status:false,error:'Title and message are required and must fit their limits',error_code:400};
7824
+ if(code)return {status:true,data:{...readiness,success:false,outcome:'blocked',delivery_id:null}};
7825
+ const now=Math.floor(Date.now()/1000);const deliveryId=Math.max(0,...db.pushDeliveries.map(row=>row.id))+1;
7826
+ const messageId=`projects/${config.fcm_project_id}/messages/mock-${deliveryId}`;
7827
+ db.pushDeliveries.push({id:deliveryId,created:now,modified:now,user:device.user,device:device.id,template:null,title,body:message,category:'test',action_url:null,data_payload:{},status:'sent',sent_at:now,delivered_at:null,error_message:null,platform_data:{outcome:'accepted',message_id:messageId,config_id:config.id}});
7828
+ return {status:true,data:{...readiness,success:true,outcome:'accepted',message:'FCM accepted the notification. Confirm receipt on the device.',message_id:messageId,delivery_id:deliveryId}};
7829
+ }
7830
+ const testMatch=path.match(/^\/api\/account\/devices\/push\/config\/(\d+)\/test$/);
7831
+ if(testMatch){
7832
+ if(method!=='POST')return {status:false,error:'Method not allowed',error_code:405};
7833
+ if(!hasGlobalPermission(caller,PUSH_CONFIG_TEST_GRANTS))return permissionDenied();
7834
+ const row=db.pushConfigs.find(item=>item.id===Number(testMatch[1]));if(!row)return {status:false,error:'Push configuration not found',error_code:404};
7835
+ if(!db.pushConfigSecrets.has(row.id))return {status:false,error:'No FCM service account configured',error_code:400,data:{success:false,outcome:'blocked',error_code:'missing_credentials',validation_only:true}};
7836
+ return {status:true,data:{success:true,outcome:'validated',message:'FCM authenticated and validated this project. No notification was delivered.',message_id:`projects/${row.fcm_project_id}/messages/mock-check`,test_mode:row.test_mode,validation_only:true,fcm_version:'v1'}};
7837
+ }
7615
7838
  const deliveriesMatch=path.match(/^\/api\/account\/devices\/push\/deliveries(?:\/(\d+))?$/);if(deliveriesMatch){const id=deliveriesMatch[1]?Number(deliveriesMatch[1]):null;if(method==='DELETE')return {status:false,error:'DELETE not allowed: NotificationDelivery',error_code:403};const visible=pushOwnRows(caller,db.pushDeliveries,PUSH_DELIVERY_VIEW_GRANTS);if(id!=null){const row=visible.find(item=>item.id===id);if(!row)return db.pushDeliveries.some(item=>item.id===id)?permissionDenied():{status:false,error:'NotificationDelivery not found',error_code:404};if(method==='POST'){if(!hasGlobalPermission(caller,PUSH_DELIVERY_SAVE_GRANTS))return permissionDenied();for(const key of ['title','body','category','action_url','status','sent_at','delivered_at','error_message'] as const)if(key in(opts.body??{}))(row as unknown as Record<string,unknown>)[key]=opts.body?.[key];row.modified=Math.floor(Date.now()/1000);}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');return {status:true,data:pushDeliveryWire(row,graph),graph};}if(method==='POST')return {status:false,error:'CREATE not supported in Push Admin mock',error_code:403};if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');const params=messagingParams(opts.params??{},['status','category','user','device'],['created','status','category','sent_at','delivered_at'],graph,'-created',true);return messagingList(visible as unknown as Record<string,unknown>[],params,row=>`${row.title??''} ${row.category}` ,graph,row=>pushDeliveryWire(row as unknown as MockPushDelivery,graph),'-created');}
7616
7839
  const templatesMatch=path.match(/^\/api\/account\/devices\/push\/templates(?:\/(\d+))?$/);if(templatesMatch){if(method==='DELETE')return {status:false,error:'DELETE not allowed: NotificationTemplate',error_code:403};if(!hasGlobalPermission(caller,PUSH_TEMPLATE_VIEW_GRANTS))return permissionDenied();const id=templatesMatch[1]?Number(templatesMatch[1]):null;if(id!=null){const row=db.pushTemplates.find(item=>item.id===id);if(!row)return {status:false,error:'NotificationTemplate not found',error_code:404};if(method==='POST'){if(!hasGlobalPermission(caller,PUSH_TEMPLATE_SAVE_GRANTS))return permissionDenied();applyPushTemplate(row,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');return {status:true,data:pushTemplateWire(row,graph),graph};}if(method==='POST'){if(!hasGlobalPermission(caller,PUSH_TEMPLATE_SAVE_GRANTS))return permissionDenied();const body=opts.body??{};const hasContent=Boolean(String(body.title_template??'').trim()||String(body.body_template??'').trim()||(isPlainObject(body.data_template)&&Object.keys(body.data_template).length));if(!hasContent)return {status:false,error:'Template must have at least one of: title_template, body_template, or data_template',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPushTemplate={id:Math.max(0,...db.pushTemplates.map(item=>item.id))+1,created:now,modified:now,group:null,name:'',title_template:null,body_template:null,action_url:null,data_template:{},category:'general',priority:'normal',variables:{},is_active:true};applyPushTemplate(row,body);db.pushTemplates.push(row);return {status:true,data:pushTemplateWire(row,'default'),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');const params=messagingParams(opts.params??{},['category','priority','is_active','group'],['name','category','priority','is_active'],graph,'name');return messagingList(db.pushTemplates as unknown as Record<string,unknown>[],params,row=>`${row.name} ${row.category}`,graph,row=>pushTemplateWire(row as unknown as MockPushTemplate,graph),'name');}
7617
7840
  const configsMatch=path.match(/^\/api\/account\/devices\/push\/config(?:\/(\d+))?$/);if(configsMatch){if(method==='DELETE')return {status:false,error:'DELETE not allowed: PushConfig',error_code:403};if(!hasGlobalPermission(caller,PUSH_CONFIG_GRANTS))return permissionDenied();const id=configsMatch[1]?Number(configsMatch[1]):null;if(id!=null){const row=db.pushConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'PushConfig not found',error_code:404};if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body?.group)):row.group;if(nextGroup!=null&&db.pushConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A push configuration already exists for this group',error_code:400};applyPushConfig(row,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');return {status:true,data:pushConfigWire(row,graph),graph};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&db.pushConfigs.some(item=>item.group===group))return {status:false,error:'A push configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPushConfig={id:Math.max(0,...db.pushConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,test_mode:Boolean(body.test_mode),default_sound:String(body.default_sound??'default'),fcm_project_id:null};db.pushConfigs.push(row);applyPushConfig(row,body);return {status:true,data:pushConfigWire(row,'default'),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const graph=String(opts.params?.graph??'default');const params=messagingParams(opts.params??{},['test_mode','is_active','group'],['name','test_mode','is_active','created','modified'],graph,'name');return messagingList(db.pushConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),graph,row=>pushConfigWire(row as unknown as MockPushConfig,graph),'name');}
@@ -7676,7 +7899,7 @@ async function phoneHubFetch(path:string,opts:MockFetchOpts):Promise<unknown|und
7676
7899
  const smsMatch=path.match(/^\/api\/phonehub\/sms(?:\/(\d+))?$/);
7677
7900
  if(smsMatch){const id=smsMatch[1]?Number(smsMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,SMS_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.sms=db.sms.filter(row=>row.id!==id);return {status:'deleted'};}if(method==='POST'&&!hasGlobalPermission(caller,SMS_SAVE_GRANTS))return permissionDenied();if(method==='GET'&&!hasGlobalPermission(caller,SMS_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.sms.find(item=>item.id===id);if(!row)return {status:false,error:'SMS not found',error_code:404};if(method==='POST'){for(const key of ['status','error_message','delivered_at'] as const)if(key in (opts.body??{}))(row as unknown as Record<string,unknown>)[key]=opts.body?.[key];row.modified=Math.floor(Date.now()/1000);}return {status:true,data:smsWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const now=Math.floor(Date.now()/1000);const row:MockSms={id:Math.max(0,...db.sms.map(item=>item.id))+1,created:now,modified:now,direction:String(body.direction??'outbound'),from_number:String(body.from_number??''),to_number:String(body.to_number??''),body:String(body.body??''),status:String(body.status??'queued'),provider:body.provider?String(body.provider):null,provider_message_id:null,error_code:null,error_message:null,metadata:{},is_test:Boolean(body.is_test),sent_at:null,delivered_at:null,user:body.user==null?null:Number(body.user),group:body.group==null?null:Number(body.group)};db.sms.unshift(row);return {status:true,data:smsWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['direction','status','provider','group','user'],['created','direction','status','provider','sent_at','delivered_at'],'default','-created',true);return messagingList(db.sms as unknown as Record<string,unknown>[],params,row=>`${row.from_number} ${row.to_number} ${row.body}`,'default',row=>smsWire(row as unknown as MockSms),'-created');}
7678
7901
  const configMatch=path.match(/^\/api\/phonehub\/config(?:\/(\d+))?$/);
7679
- if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){if(row.test_mode)return {status:true,message:'Config is in test mode - provider not tested'};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return ready?{status:true,message:`${row.provider} connection succeeded`}:{status:false,error:`${row.provider} credentials are incomplete`,error_code:400};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
7902
+ if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){if(row.test_mode)return {status:true,data:{success:true,test_mode:true,message:'Config is in test mode - provider not tested'}};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return {status:true,data:ready?{success:true,message:`${row.provider} connection succeeded`}:{success:false,message:`${row.provider} credentials are incomplete`,error:'missing_credentials'}};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
7680
7903
  return undefined;
7681
7904
  }
7682
7905
  async function messagingFetch(path:string,opts:MockFetchOpts):Promise<unknown|undefined>{
@@ -7986,6 +8209,7 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
7986
8209
  const had = db.throttle.delete(uid);
7987
8210
  return { status: true, data: { deleted: had ? 1 : 0 } };
7988
8211
  }
8212
+ if (path === '/api/account/admin/signin') return signinFetch(method, opts.body ?? {}, opts.headers);
7989
8213
  if (path === '/api/login' || path === '/api/token/refresh' || path.startsWith('/api/auth/')) {
7990
8214
  return authFetch(path, opts.body ?? {}, opts.params ?? {});
7991
8215
  }
@@ -8795,6 +9019,11 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
8795
9019
  return { status: true, data: { name: domain.name, registrant: transientContact, admin: transientContact, tech: transientContact, privacy: domain.privacy, admin_privacy: domain.privacy, registrant_privacy: domain.privacy, tech_privacy: domain.privacy, auto_renew: domain.auto_renew, nameservers: ['ns-1.example.invalid', 'ns-2.example.invalid'], registrar: 'Amazon Registrar, Inc.', registered_on: domain.registered_on, expires: domain.expires, status_list: ['ok'], privacy_supported: true } };
8796
9020
  }
8797
9021
  // ══ end DNSMan wire ═══════════════════════════════════════════════
9022
+ if (path === '/api/account/admin/fleet' || path.startsWith('/api/account/admin/fleet/')) {
9023
+ const caller = userFromBearer(opts.headers);
9024
+ if (!caller) return permissionDenied(401);
9025
+ return fleetMock(path, method, opts.body, caller.is_superuser === true);
9026
+ }
8798
9027
  // ── Settings — plaintext stays private; secrets serialize masked ─────
8799
9028
  const oneSetting = path.match(/^\/api\/settings\/(\d+)$/);
8800
9029
  if (oneSetting || path === '/api/settings') {
@@ -296,6 +296,18 @@ export class RealtimeClient {
296
296
  if (this.lifecycle === 'ready') this.clearPong();
297
297
  return;
298
298
  }
299
+ if (frame.type === 'ping') {
300
+ // Server-initiated heartbeat (django-mojo >= 1.32: every 20 s,
301
+ // idle cull at 90 s). Answered from this handler, not a timer, so
302
+ // a throttled background tab still proves the socket alive; it
303
+ // also counts as proof of life for our own pong deadline. A
304
+ // framework frame: never dispatched to consumers.
305
+ if (this.lifecycle === 'ready') {
306
+ socket.send(JSON.stringify({ type: 'pong' }));
307
+ this.clearPong();
308
+ }
309
+ return;
310
+ }
299
311
  if (frame.type === 'subscribed' || frame.type === 'unsubscribed') {
300
312
  if (typeof frame.topic === 'string') this.completeTopic(frame.type, frame.topic);
301
313
  return;
@@ -26,6 +26,7 @@ import { toast } from './toast';
26
26
  import {
27
27
  useFormAutosave,
28
28
  type AutosaveBatchInfo,
29
+ type AutosaveBeforeSave,
29
30
  type FieldStatus,
30
31
  type FormAutosaveApi,
31
32
  } from './form-autosave';
@@ -104,13 +105,34 @@ export interface FormViewProps<T extends { id: number | string }> {
104
105
  savedFlashMs?: number;
105
106
  onSaved?: (info: AutosaveBatchInfo & { row: T }) => void;
106
107
  onSaveError?: (info: AutosaveBatchInfo & { error: Error }) => void;
108
+ /**
109
+ * Pre-save gate, once per batch. `false` drops the batch (its fields
110
+ * revert, no toast); an object replaces the wire body; true/undefined
111
+ * proceeds. Put a `confirmGuardrail` here for grants/identity fields.
112
+ */
113
+ beforeSave?: AutosaveBeforeSave<T>;
107
114
  /** True while any FileField transfer is active. Owners can block dismissal. */
108
115
  onPendingChange?: (pending: boolean) => void;
109
116
  className?: string;
110
117
  }
111
118
 
119
+ const saveOwnerIds = new WeakMap<object, number>();
120
+ let nextSaveOwnerId = 0;
121
+
112
122
  export function FormView<T extends { id: number | string }>(props: FormViewProps<T>) {
113
- const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, className } = props;
123
+ // A TanStack mutation observer updates its mutationFn on rerender.
124
+ // Keep the observer and reducer with the model/record that created them,
125
+ // so a confirmation already in flight cannot migrate to another owner.
126
+ let modelId = saveOwnerIds.get(props.model.useSave);
127
+ if (modelId === undefined) {
128
+ modelId = ++nextSaveOwnerId;
129
+ saveOwnerIds.set(props.model.useSave, modelId);
130
+ }
131
+ return <OwnedFormView key={JSON.stringify([modelId, props.row.id])} {...props} />;
132
+ }
133
+
134
+ function OwnedFormView<T extends { id: number | string }>(props: FormViewProps<T>) {
135
+ const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, beforeSave, className } = props;
114
136
  const save = model.useSave();
115
137
 
116
138
  // Registry-named tabsets re-render on registration; inline defs pass through.
@@ -164,6 +186,7 @@ export function FormView<T extends { id: number | string }>(props: FormViewProps
164
186
  save: (changes) => save.mutateAsync({ id: row.id, changes } satisfies SaveVars),
165
187
  debounceMs,
166
188
  savedFlashMs,
189
+ beforeSave,
167
190
  onSaved: (info) => {
168
191
  publishOwnerResult(info.fields, 'success', info.changes, info.row);
169
192
  onSaved?.(info);
@@ -56,12 +56,18 @@ interface BatchActionBase<T> {
56
56
  danger?: boolean;
57
57
  /** Row-state eligibility. Ineligible selected rows are never submitted. */
58
58
  eligible?: (row: T) => boolean;
59
- /** Confirm copy; false skips the prompt. Default: "<label> N item(s)?" */
60
- confirm?: string | false;
59
+ /**
60
+ * The permission step. A string is the `modal.confirm` copy; false skips
61
+ * the prompt; a function REPLACES the built-in confirm — resolve false
62
+ * to cancel (open a `confirmGuardrail` here for batches that take a
63
+ * tenant dark or can't be undone). Default: "<label> N item(s)?"
64
+ */
65
+ confirm?: string | false | ((rows: T[]) => Promise<boolean>);
61
66
  /**
62
67
  * Optional once-per-batch step (collect a reason, pick a value…) run
63
68
  * AFTER the confirm. Resolve null to cancel the batch; the resolved
64
- * value is passed to every run() call.
69
+ * value is passed to every run() call. Ask permission in `confirm`,
70
+ * not here — a null from prepare means "nothing collected".
65
71
  */
66
72
  prepare?: (rows: T[]) => Promise<unknown | null>;
67
73
  }
@@ -449,7 +455,9 @@ export function ModelTable<T extends { id: RowId }>({
449
455
  const runBatch = async (action: BatchAction<T>) => {
450
456
  const targets = rows.filter((r) => selected.has(r.id) && (!action.eligible || action.eligible(r)));
451
457
  if (targets.length === 0) return;
452
- if (action.confirm !== false) {
458
+ if (typeof action.confirm === 'function') {
459
+ if (!(await action.confirm(targets))) return;
460
+ } else if (action.confirm !== false) {
453
461
  const ok = await modal.confirm({
454
462
  title: action.label,
455
463
  message: action.confirm ?? `${action.label} ${targets.length} item(s)?`,
@@ -18,9 +18,14 @@
18
18
  // · Controlled on `metadata` (architecture rule 5): the component never
19
19
  // mirrors server state. `onSaved` is REQUIRED — it is how the owner
20
20
  // (TanStack cache, parent state) learns about the write.
21
+ // · Every save posts the WHOLE blob, so one bad write can wipe a protected
22
+ // key (auth_config / geofence / redemption_policy) in a single POST.
23
+ // Removing a top-level key therefore stops on a guardrail that lists
24
+ // the keys about to vanish; owners add `beforeSave(next, prev)` to diff
25
+ // their own protected keys on top.
21
26
  import { useState } from 'react';
22
27
  import { mojoCall } from '../../client/client';
23
- import { modal } from '../modal';
28
+ import { confirmGuardrail } from '../guardrail';
24
29
  import { toast } from '../toast';
25
30
 
26
31
  type Draft =
@@ -49,13 +54,42 @@ function parseValue(raw: string): unknown {
49
54
  }
50
55
  }
51
56
 
52
- export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Metadata' }: {
57
+ /**
58
+ * Default stop: a top-level key is about to leave the blob. Names every key
59
+ * so a wipe of `auth_config` reads as exactly that, not "Remove entry?".
60
+ */
61
+ function confirmRemovedKeys(removed: string[], next: Record<string, unknown>): Promise<boolean> {
62
+ const one = removed.length === 1;
63
+ const remaining = Object.keys(next).length;
64
+ return confirmGuardrail({
65
+ title: one ? `Remove metadata key ${removed[0]}?` : `Remove ${removed.length} metadata keys?`,
66
+ effect: <>
67
+ {removed.map((key, i) => <span key={key}>{i > 0 ? ', ' : ''}<code>{key}</code></span>)}
68
+ {one ? ' leaves' : ' leave'} the record the moment this saves — the POST carries the whole blob, {remaining === 0 ? 'and it will be empty' : `${remaining} key${remaining === 1 ? '' : 's'} remain`}.
69
+ </>,
70
+ why: [
71
+ <>Anything that reads {one ? 'this key' : 'these keys'} (policy, auth configuration, geofences, redemption rules) sees it missing on its next read, with no error raised here.</>,
72
+ <>The value is not kept anywhere in this portal — re-adding the key means re-entering it by hand.</>,
73
+ ],
74
+ undo: 'There is no undo; re-add the key with its previous value.',
75
+ confirmText: one ? 'Remove key' : `Remove ${removed.length} keys`,
76
+ typeToConfirm: removed.length > 1 ? String(removed.length) : undefined,
77
+ });
78
+ }
79
+
80
+ export function MetadataSection({ endpoint, id, metadata, onSaved, beforeSave, title = 'Metadata' }: {
53
81
  /** Collection endpoint — the save posts to `<endpoint>/<id>`. */
54
82
  endpoint: string;
55
83
  id: number | string;
56
84
  metadata: Record<string, unknown>;
57
85
  /** Called with the new blob after the server accepted it. */
58
86
  onSaved: (next: Record<string, unknown>) => void;
87
+ /**
88
+ * Owner gate, after the built-in removed-key stop: resolve false to
89
+ * cancel the write (no error, editor state kept). Diff protected keys
90
+ * between `next` and `prev` here.
91
+ */
92
+ beforeSave?: (next: Record<string, unknown>, prev: Record<string, unknown>) => Promise<boolean> | boolean;
59
93
  title?: string;
60
94
  }) {
61
95
  const [draft, setDraft] = useState<Draft>(null);
@@ -65,8 +99,16 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
65
99
  const keys = Object.keys(metadata).sort();
66
100
  const busy = pending !== null;
67
101
 
68
- /** The one write path. Resolves true only when the server accepted it. */
102
+ /**
103
+ * The one write path. Resolves true only when the server accepted it;
104
+ * false when a gate declined (nothing posted, no error) or the POST
105
+ * rejected (banner). Gates run BEFORE pending so the dialog is not
106
+ * stacked over disabled controls.
107
+ */
69
108
  async function commit(next: Record<string, unknown>, token: string, message: string): Promise<boolean> {
109
+ const removed = Object.keys(metadata).filter((key) => !Object.prototype.hasOwnProperty.call(next, key));
110
+ if (removed.length > 0 && !(await confirmRemovedKeys(removed, next))) return false;
111
+ if (beforeSave && !(await beforeSave(next, metadata))) return false;
70
112
  setPending(token);
71
113
  setError(null);
72
114
  try {
@@ -101,13 +143,7 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
101
143
  }
102
144
 
103
145
  async function removeKey(key: string) {
104
- const confirmed = await modal.confirm({
105
- title: 'Remove entry',
106
- message: <>Remove metadata key <b>{key}</b>?</>,
107
- confirmText: 'Remove',
108
- danger: true,
109
- });
110
- if (!confirmed) return;
146
+ // The removed-key guardrail inside commit() is the confirm.
111
147
  const next = { ...metadata };
112
148
  delete next[key];
113
149
  await commit(next, `delete:${key}`, 'Metadata entry removed');