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.
- package/README.md +3 -0
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +3 -1
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-phonehub.md +11 -1
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +60 -1
- package/docs/forms.md +41 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +3 -3
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/credentials/group-api-keys.tsx +20 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
- 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/identity/members/MemberDetail.tsx +48 -3
- 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/phonehub/PhoneHubPage.tsx +19 -8
- package/src/admin/phonehub/api.ts +5 -5
- 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 +240 -11
- package/src/client/realtime.ts +12 -0
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/toast.tsx +48 -5
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');}
|
|
@@ -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?{
|
|
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') {
|
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;
|
package/src/ui/FormView.tsx
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/ui/ModelTable.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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');
|