portal-mojo 0.2.4 → 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.
@@ -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');}
@@ -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;