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.
- package/docs/README.md +2 -0
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/domains/identity.ts +7 -1
- package/src/admin/domains/operations.ts +8 -1
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
- package/src/admin/fleet-configuration/data.ts +89 -0
- package/src/admin/index.ts +3 -0
- package/src/admin/messaging/push/PushPage.tsx +61 -13
- package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
- package/src/admin/messaging/push/api.ts +25 -1
- package/src/admin/messaging/push/models.ts +7 -6
- package/src/admin/public/identity.ts +1 -0
- package/src/admin/public/operations.ts +1 -0
- package/src/admin/registry.ts +4 -2
- package/src/admin/settings/SettingDetail.tsx +2 -1
- package/src/admin/settings/SettingValue.tsx +37 -0
- package/src/admin/settings/SettingsPage.tsx +2 -1
- package/src/admin/signin/SigninPage.tsx +397 -0
- package/src/admin/signin/api.ts +83 -0
- package/src/admin/signin/index.ts +4 -0
- package/src/client/fleet-mock.ts +61 -0
- package/src/client/mock.ts +239 -10
- package/src/client/realtime.ts +12 -0
package/src/client/mock.ts
CHANGED
|
@@ -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
|
-
|
|
4657
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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') {
|
package/src/client/realtime.ts
CHANGED
|
@@ -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;
|