portal-mojo 0.2.2 → 0.2.4
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/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- 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/modal.tsx +8 -4
- package/src/ui/toast.tsx +48 -5
package/src/client/auth.ts
CHANGED
|
@@ -306,6 +306,12 @@ function adoptGrant(data: TokenGrant, remember = true): AuthUser {
|
|
|
306
306
|
|
|
307
307
|
/** Password login. `username` may be a username, email, or (if enabled) phone. */
|
|
308
308
|
export async function login(username: string, password: string, opts: LoginOptions = {}): Promise<LoginResult> {
|
|
309
|
+
// A malformed/expired session must not prevent the user from replacing it.
|
|
310
|
+
// The transport gate deliberately blocks authenticated requests when both
|
|
311
|
+
// tokens are unusable, but /api/login is itself the recovery path. Clear
|
|
312
|
+
// only that dead session; a valid session (including fresh-auth re-login)
|
|
313
|
+
// remains intact until the new grant is adopted.
|
|
314
|
+
if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
|
|
309
315
|
const body = await mojoCall('/api/login', { method: 'POST', body: { username, password, ...opts.extra } });
|
|
310
316
|
const data = body.data as TokenGrant;
|
|
311
317
|
if (data.mfa_required) {
|
|
@@ -599,6 +605,9 @@ export function exchangeAuthCode(code: string): Promise<AuthUser | null> {
|
|
|
599
605
|
|
|
600
606
|
async function doExchange(code: string): Promise<AuthUser | null> {
|
|
601
607
|
try {
|
|
608
|
+
// Hosted sign-in is also a recovery path: stale credentials must not
|
|
609
|
+
// block exchanging the fresh one-time login code.
|
|
610
|
+
if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
|
|
602
611
|
const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
|
|
603
612
|
return adoptGrant(body.data as TokenGrant);
|
|
604
613
|
} catch (error) {
|
|
@@ -721,7 +730,7 @@ export function initAuth(): void {
|
|
|
721
730
|
// Recursion guard: never gate the refresh call itself.
|
|
722
731
|
if (path === REFRESH_PATH) return;
|
|
723
732
|
// No stored token → pre-login / public flow, pass through.
|
|
724
|
-
if (!getAccessToken()) return;
|
|
733
|
+
if (!getAccessToken() && !getRefreshToken()) return;
|
|
725
734
|
await ensureValidToken();
|
|
726
735
|
// NOTE: web-mojo had to re-stamp Authorization here because its
|
|
727
736
|
// Rest snapshotted headers before the gate ran. Our transport
|
package/src/client/client.ts
CHANGED
|
@@ -23,20 +23,25 @@ import type { MojoList, Params } from './types';
|
|
|
23
23
|
export { MojoError, AuthRequiredError } from './errors';
|
|
24
24
|
|
|
25
25
|
const API_BASE: string = import.meta.env.VITE_MOJO_API ?? '';
|
|
26
|
+
/** Reserved for the controlled Django artifact producer; ordinary dev stays mock-backed. */
|
|
27
|
+
export function isPackagedAdmin(): boolean {
|
|
28
|
+
return import.meta.env.VITE_MOJO_PACKAGED_ADMIN === '1';
|
|
29
|
+
}
|
|
26
30
|
let mockModule: Promise<typeof import('./mock')> | null = null;
|
|
27
31
|
|
|
28
32
|
function loadMockModule(): Promise<typeof import('./mock')> {
|
|
33
|
+
if (import.meta.env.VITE_MOJO_PACKAGED_ADMIN === '1') throw new Error('Mock transport is unavailable in packaged Admin');
|
|
29
34
|
return mockModule ??= import('./mock');
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/** True when the in-memory mock transport is active (VITE_MOJO_API unset). */
|
|
33
38
|
export function usingMockTransport(): boolean {
|
|
34
|
-
return !API_BASE;
|
|
39
|
+
return !isPackagedAdmin() && !API_BASE;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** The configured django-mojo origin ('' under the mock transport). */
|
|
38
43
|
export function apiOrigin(): string {
|
|
39
|
-
return API_BASE;
|
|
44
|
+
return isPackagedAdmin() ? window.location.origin : API_BASE;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/**
|
|
@@ -81,6 +86,8 @@ export interface FetchOpts {
|
|
|
81
86
|
body?: Record<string, unknown>;
|
|
82
87
|
/** Cancels the selected transport request. */
|
|
83
88
|
signal?: AbortSignal;
|
|
89
|
+
/** Optional final validity check after auth refresh and immediately before sending. */
|
|
90
|
+
beforeSend?: () => void;
|
|
84
91
|
/** Explicit opt-out from a registered endpoint scope (scoped.ts) — for
|
|
85
92
|
* genuinely global calls inside a scoped family. Greppable, never silent. */
|
|
86
93
|
unscoped?: boolean;
|
|
@@ -117,6 +124,7 @@ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
|
117
124
|
// Authorization value is picked up below by construction (web-mojo had to
|
|
118
125
|
// re-stamp a snapshotted header here).
|
|
119
126
|
if (authHooks) await authHooks.preRequest(path);
|
|
127
|
+
opts.beforeSend?.();
|
|
120
128
|
|
|
121
129
|
const headers: Record<string, string> = {
|
|
122
130
|
'Content-Type': 'application/json',
|
|
@@ -125,11 +133,11 @@ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
|
125
133
|
const bearer = authHooks?.authHeader();
|
|
126
134
|
if (bearer) headers['Authorization'] = bearer;
|
|
127
135
|
|
|
128
|
-
if (
|
|
136
|
+
if (usingMockTransport()) {
|
|
129
137
|
const { mockFetch } = await loadMockModule();
|
|
130
138
|
return (await mockFetch(path, { ...opts, headers })) as Envelope;
|
|
131
139
|
}
|
|
132
|
-
const res = await fetch(`${
|
|
140
|
+
const res = await fetch(`${apiOrigin()}${path}${buildQuery(opts.params ?? {})}`, {
|
|
133
141
|
method: opts.method ?? 'GET',
|
|
134
142
|
headers,
|
|
135
143
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
@@ -265,7 +273,7 @@ export async function mojoDownload(endpoint: string, params: Params, format: 'cs
|
|
|
265
273
|
const bearer = authHooks?.authHeader();
|
|
266
274
|
if (bearer) headers['Authorization'] = bearer;
|
|
267
275
|
|
|
268
|
-
if (
|
|
276
|
+
if (usingMockTransport()) {
|
|
269
277
|
const { mockFetch } = await loadMockModule();
|
|
270
278
|
const body = (await mockFetch(endpoint, { params: withFormat, headers })) as Envelope;
|
|
271
279
|
if (body.status === false) {
|
|
@@ -276,7 +284,7 @@ export async function mojoDownload(endpoint: string, params: Params, format: 'cs
|
|
|
276
284
|
downloadBlob(new Blob([file.content], { type: file.mime }), file.filename);
|
|
277
285
|
return;
|
|
278
286
|
}
|
|
279
|
-
const res = await fetch(`${
|
|
287
|
+
const res = await fetch(`${apiOrigin()}${endpoint}${buildQuery(withFormat)}`, { headers });
|
|
280
288
|
if (!res.ok) throw new MojoError(`Export failed (HTTP ${res.status})`, res.status);
|
|
281
289
|
downloadBlob(await res.blob(), filename);
|
|
282
290
|
}
|
package/src/client/mock.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// field so they are drivable end-to-end in dev (a real server never does)
|
|
14
14
|
// Data endpoints stay open (no auth required) until the portal grows login
|
|
15
15
|
// pages (Chunk C3) — then they 401 without a bearer, like the real backend.
|
|
16
|
+
import { getDnsAdminIntegration, type ManagedDnsRecordInput } from '../admin/dns/dns-integration';
|
|
16
17
|
import { markdownToHtml } from './markdown-parse';
|
|
17
18
|
import type { Params, User } from './types';
|
|
18
|
-
import { getDnsAdminIntegration, type ManagedDnsRecordInput } from '../admin/dns/dns-integration';
|
|
19
19
|
|
|
20
20
|
// Deterministic dataset — same 57 users on every load so the demo is stable.
|
|
21
21
|
function mulberry32(seed: number) {
|
|
@@ -1540,6 +1540,10 @@ interface MockGeoIp {
|
|
|
1540
1540
|
is_whitelisted: boolean;
|
|
1541
1541
|
whitelisted_reason: string | null;
|
|
1542
1542
|
whitelisted_until: number | null;
|
|
1543
|
+
firewall_generation: number;
|
|
1544
|
+
firewall_pending: boolean;
|
|
1545
|
+
firewall_sync_error: string;
|
|
1546
|
+
firewall_observed_at: number | null;
|
|
1543
1547
|
expires_at: number | null;
|
|
1544
1548
|
/** Excluded from the `default` graph — the raw provider blob. */
|
|
1545
1549
|
provider: string | null;
|
|
@@ -1616,6 +1620,9 @@ const GEOIP_SEEDS: GeoIpSeed[] = [
|
|
|
1616
1620
|
// ── A datacenter VPN, blocked with a live TTL ──
|
|
1617
1621
|
{ ip: '45.33.32.156', cc: 'US', country: 'United States', region: 'California', rc: 'US-CA', city: 'Fremont', lat: 37.5483, lng: -121.9886, tz: 'America/Los_Angeles', asn: 'AS63949', asnOrg: 'Akamai Connected Cloud', isp: 'Linode', connection: 'hosting', vpn: true, cloud: true, datacenter: true, threat: 'high', blockedReason: 'Credential stuffing from a VPN exit', blockedInDays: 6, blockCount: 2, lastSeenHours: 4 },
|
|
1618
1622
|
|
|
1623
|
+
// ── The bouncer incident source used by Incident #603 ──
|
|
1624
|
+
{ ip: '198.51.100.66', cc: 'CN', country: 'China', region: 'Beijing', rc: 'CN-BJ', city: 'Beijing', postal: '100000', lat: 39.9042, lng: 116.4074, tz: 'Asia/Shanghai', asn: 'AS45090', asnOrg: 'Shenzhen Tencent Computer Systems', isp: 'Tencent Cloud', connection: 'hosting', vpn: true, proxy: true, cloud: true, datacenter: true, attacker: true, abuser: true, threat: 'critical', blockedReason: 'Bouncer blocked automated credential attack', blockedInDays: null, blockCount: 18, lastSeenHours: 2 },
|
|
1625
|
+
|
|
1619
1626
|
// ── An EXPIRED block: is_blocked is still true, blocked_until is past.
|
|
1620
1627
|
// web-mojo rendered this as "Blocked". block_active() says otherwise.
|
|
1621
1628
|
{ ip: '104.28.14.33', cc: 'BR', country: 'Brazil', region: 'São Paulo', rc: 'BR-SP', city: 'São Paulo', lat: -23.5505, lng: -46.6333, tz: 'America/Sao_Paulo', asn: 'AS13335', asnOrg: 'Cloudflare', isp: 'Cloudflare', connection: 'hosting', proxy: true, cloud: true, threat: 'medium', blockedReason: 'Scraper burst', blockedInDays: -3, blockCount: 1, lastSeenHours: 9 },
|
|
@@ -1690,6 +1697,10 @@ function buildGeoIps(): MockGeoIp[] {
|
|
|
1690
1697
|
is_whitelisted: whitelisted,
|
|
1691
1698
|
whitelisted_reason: seed.whitelistReason ?? null,
|
|
1692
1699
|
whitelisted_until: whitelisted && seed.whitelistInDays != null ? nowSec + seed.whitelistInDays * DAY : null,
|
|
1700
|
+
firewall_generation: blocked || whitelisted ? 1 : 0,
|
|
1701
|
+
firewall_pending: false,
|
|
1702
|
+
firewall_sync_error: '',
|
|
1703
|
+
firewall_observed_at: blocked || whitelisted ? lastSeen : null,
|
|
1693
1704
|
// `is_expired`: internal records never expire.
|
|
1694
1705
|
expires_at: provider === 'internal' ? null : nowSec + 60 * DAY,
|
|
1695
1706
|
provider,
|
|
@@ -1806,7 +1817,8 @@ const GEOIP_BASIC_FIELDS = [
|
|
|
1806
1817
|
'is_tor', 'is_vpn', 'is_proxy', 'is_known_attacker', 'is_known_abuser',
|
|
1807
1818
|
'threat_level', 'is_blocked', 'blocked_at', 'blocked_until', 'provider',
|
|
1808
1819
|
'blocked_reason', 'block_count', 'is_whitelisted', 'whitelisted_reason',
|
|
1809
|
-
'whitelisted_until',
|
|
1820
|
+
'whitelisted_until', 'firewall_generation', 'firewall_pending',
|
|
1821
|
+
'firewall_sync_error', 'firewall_observed_at',
|
|
1810
1822
|
];
|
|
1811
1823
|
|
|
1812
1824
|
/** The `federation` graph: NO enforcement state and NO raw provider blob. */
|
|
@@ -1997,6 +2009,8 @@ function geolocateMock(ip: string, autoRefresh: boolean): MockGeoIp {
|
|
|
1997
2009
|
is_blocked: false, blocked_at: null, blocked_until: null,
|
|
1998
2010
|
blocked_reason: null, block_count: 0,
|
|
1999
2011
|
is_whitelisted: false, whitelisted_reason: null, whitelisted_until: null,
|
|
2012
|
+
firewall_generation: 0, firewall_pending: false,
|
|
2013
|
+
firewall_sync_error: '', firewall_observed_at: null,
|
|
2000
2014
|
expires_at: null, provider: null, data: {},
|
|
2001
2015
|
};
|
|
2002
2016
|
db.geoIps.unshift(row);
|
|
@@ -2279,6 +2293,41 @@ function buildIncidentEvents(): MockIncidentEvent[] {
|
|
|
2279
2293
|
model_name: 'account.BouncerDevice', model_id: 902,
|
|
2280
2294
|
metadata: { muid: 'muid-monitor-002', risk_score: 51, decision: 'monitor', server: 'auth-1' }, group_id: null, incident: 603,
|
|
2281
2295
|
},
|
|
2296
|
+
{
|
|
2297
|
+
id: 8985, created: nowSec - 600, level: 8, scope: 'mojosec',
|
|
2298
|
+
category: 'mojosec.auth.sudo_command', source_ip: '88.184.56.101',
|
|
2299
|
+
hostname: null, uid: null, country_code: null,
|
|
2300
|
+
title: 'MojoSec detected auth.sudo_command',
|
|
2301
|
+
details: 'MojoSec sensor prod-web-i-04aab759def924cdd reported 1 occurrence(s) of auth.sudo_command. Host recommendation: review (advisory only).',
|
|
2302
|
+
model_name: 'mojosec_sensor', model_id: null,
|
|
2303
|
+
geo_ip: '88.184.56.101 (Saint-Julien-du-Sault, FR)',
|
|
2304
|
+
metadata: {
|
|
2305
|
+
mojosec: {
|
|
2306
|
+
kind: 'auth.sudo_command', count: 1,
|
|
2307
|
+
event_id: 'd4dc2901fd65f59196244a9a8e200bfaff9e5a10b1a9f6f1936a79237f39481c',
|
|
2308
|
+
evidence: {
|
|
2309
|
+
cwd: '/opt/api', actor: 'ec2-user',
|
|
2310
|
+
cgroup: '/user.slice/user-1000.slice/session-95740.scope',
|
|
2311
|
+
boot_id: '194f44c3dcf945c08a0c3e076a784ff9',
|
|
2312
|
+
command: '/usr/local/sbin/mojo-firewall-broker',
|
|
2313
|
+
selinux: 'unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023',
|
|
2314
|
+
monotonic: 2767741178039, attribution: 'audit_session', target_user: 'root',
|
|
2315
|
+
command_path: '/usr/local/sbin/mojo-firewall-broker', producer_exe: '/usr/bin/sudo',
|
|
2316
|
+
producer_pid: 2552251, producer_uid: 1000, proof_status: 'partial',
|
|
2317
|
+
systemd_unit: 'session-95740.scope', audit_session: 95740,
|
|
2318
|
+
producer_comm: 'sudo', audit_loginuid: 1000, command_family: 'unknown',
|
|
2319
|
+
receipt_semantics: ['normalize exact IPv4 DROP rules'],
|
|
2320
|
+
},
|
|
2321
|
+
last_seen: '2026-09-09T22:53:46.490459Z',
|
|
2322
|
+
sensor_id: 'prod-web-i-04aab759def924cdd',
|
|
2323
|
+
first_seen: '2026-09-09T22:53:46.490459Z', recommendation: 'review',
|
|
2324
|
+
effective_level: 8, sensor_severity: 'high', protocol_version: 1,
|
|
2325
|
+
installation_key_id: 36,
|
|
2326
|
+
sensor_policy_revision_sha256: 'a071e28caf7e5b8a4657c2c80b2a020495507609bdd869cbcce0bce37109ee15',
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
group_id: null, incident: null,
|
|
2330
|
+
},
|
|
2282
2331
|
{
|
|
2283
2332
|
id: 8986, created: nowSec - 5 * 3600, level: 9, scope: 'ossec', category: 'ossec',
|
|
2284
2333
|
source_ip: '203.0.113.90', hostname: 'edge-1', uid: null, country_code: 'US',
|
|
@@ -7592,6 +7641,33 @@ async function phoneHubFetch(path:string,opts:MockFetchOpts):Promise<unknown|und
|
|
|
7592
7641
|
const method=(opts.method??'GET').toUpperCase();
|
|
7593
7642
|
if(path==='/api/phonehub/number/normalize'){if(method!=='POST')return {status:false,error:'Method not allowed',error_code:405};const normalized=normalizePhone(String(opts.body?.phone_number??''));return normalized?{status:true,data:{phone_number:normalized}}:{status:false,error:'Invalid phone number',error_code:400};}
|
|
7594
7643
|
const caller=userFromBearer(opts.headers);if(!caller)return permissionDenied(401);
|
|
7644
|
+
if (path === '/api/phonehub/sms/send') {
|
|
7645
|
+
if (method !== 'POST') return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
7646
|
+
if (!hasGlobalPermission(caller, ['send_sms', 'comms'])) return permissionDenied();
|
|
7647
|
+
const body = opts.body ?? {};
|
|
7648
|
+
const toNumber = normalizePhone(String(body.to_number ?? ''));
|
|
7649
|
+
if (!toNumber || typeof body.body !== 'string' || !body.body.trim()) return { status: false, error: 'A valid to_number and body are required', error_code: 400 };
|
|
7650
|
+
const group = body.group == null ? null : Number(body.group);
|
|
7651
|
+
const configId = getMockEffectivePhoneConfigId(group);
|
|
7652
|
+
const config = db.phoneConfigs.find(row => row.id === configId);
|
|
7653
|
+
const now = Math.floor(Date.now() / 1000);
|
|
7654
|
+
// Deterministic provider refusal for the demo, with the live envelope:
|
|
7655
|
+
// a persisted failed SMS still has outer status:true.
|
|
7656
|
+
const failed = toNumber.endsWith('0000');
|
|
7657
|
+
const row: MockSms = {
|
|
7658
|
+
id: Math.max(0, ...db.sms.map(item => item.id)) + 1,
|
|
7659
|
+
created: now, modified: now, direction: 'outbound',
|
|
7660
|
+
from_number: typeof body.from_number === 'string' ? body.from_number : '+14155550100',
|
|
7661
|
+
to_number: toNumber, body: body.body, status: failed ? 'failed' : 'sent',
|
|
7662
|
+
provider: config?.provider ?? 'twilio', provider_message_id: failed ? null : `mock-sms-${now}`,
|
|
7663
|
+
error_code: failed ? 'mock_recipient_refused' : null,
|
|
7664
|
+
error_message: failed ? 'Demo provider refused this recipient.' : null,
|
|
7665
|
+
metadata: {}, is_test: toNumber.startsWith('+1555'),
|
|
7666
|
+
sent_at: failed ? null : now, delivered_at: null, user: caller.id, group,
|
|
7667
|
+
};
|
|
7668
|
+
db.sms.unshift(row);
|
|
7669
|
+
return { status: true, data: smsWire(row), graph: 'default' };
|
|
7670
|
+
}
|
|
7595
7671
|
if(path==='/api/phonehub/number/lookup'){
|
|
7596
7672
|
if(method!=='POST')return {status:false,error:'Method not allowed',error_code:405};const normalized=normalizePhone(String(opts.body?.phone_number??''));if(!normalized)return {status:false,error:'Invalid phone number',error_code:400};const now=Math.floor(Date.now()/1000);let row=db.phoneNumbers.find(item=>item.phone_number===normalized);if(!row){row={id:Math.max(0,...db.phoneNumbers.map(item=>item.id))+1,created:now,modified:now,phone_number:normalized,country_code:normalized.startsWith('+1')?'US':null,region:null,state:null,carrier:null,line_type:null,is_mobile:false,is_voip:false,is_valid:false,registered_owner:null,owner_type:null,address_line1:null,address_city:null,address_state:null,address_zip:null,address_country:null,lookup_provider:null,lookup_data:{},lookup_expires_at:null,lookup_count:0,last_lookup_at:null};db.phoneNumbers.push(row);}if(row.lookup_expires_at==null||row.lookup_expires_at<=now)refreshMockPhone(row);if(opts.body?.force_refresh)refreshMockPhone(row);return {status:true,data:{...row},graph:'default'};
|
|
7597
7673
|
}
|
|
@@ -7600,7 +7676,7 @@ async function phoneHubFetch(path:string,opts:MockFetchOpts):Promise<unknown|und
|
|
|
7600
7676
|
const smsMatch=path.match(/^\/api\/phonehub\/sms(?:\/(\d+))?$/);
|
|
7601
7677
|
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');}
|
|
7602
7678
|
const configMatch=path.match(/^\/api\/phonehub\/config(?:\/(\d+))?$/);
|
|
7603
|
-
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?{
|
|
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,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');}
|
|
7604
7680
|
return undefined;
|
|
7605
7681
|
}
|
|
7606
7682
|
async function messagingFetch(path:string,opts:MockFetchOpts):Promise<unknown|undefined>{
|
|
@@ -7749,14 +7825,18 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
|
|
|
7749
7825
|
if (skillMatch) {
|
|
7750
7826
|
if (!assistantGate) return permissionDenied(); const id = skillMatch[1] ? Number(skillMatch[1]) : null;
|
|
7751
7827
|
const skillWire = (row: MockAssistantSkill, detail = false) => ({ id: row.id, tier: row.tier, name: row.name, description: row.description, auto_execute: row.auto_execute, is_active: row.is_active, created: row.created, modified: row.modified, user: row.user == null ? null : assistantUser(row.user), ...(detail ? { triggers: [...row.triggers], steps: structuredClone(row.steps), metadata: { ...row.metadata } } : {}) });
|
|
7752
|
-
if (id != null) { const row = db.assistantSkills.find((candidate) => candidate.id === id); if (!row) return { status: false, error: 'Skill not found', error_code: 404 }; if (method === 'DELETE') { db.assistantSkills = db.assistantSkills.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: skillWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
|
|
7828
|
+
if (id != null) { const row = db.assistantSkills.find((candidate) => candidate.id === id); if (!row) return { status: false, error: 'Skill not found', error_code: 404 }; if (method === 'DELETE') { db.assistantSkills = db.assistantSkills.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method === 'POST') { if (!hasGlobalPermission(caller, ['view_admin'])) return permissionDenied(); if (typeof opts.body?.is_active !== 'boolean') return { status: false, error: 'is_active is required', error_code: 400 }; row.is_active = opts.body.is_active; row.modified = Math.floor(Date.now() / 1000); } else if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: skillWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
|
|
7753
7829
|
if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; const rows = [...db.assistantSkills].sort((a, b) => a.name.localeCompare(b.name)); return { status: true, data: rows.map((row) => skillWire(row)), count: rows.length, start: 0, size: rows.length, graph: 'default' };
|
|
7754
7830
|
}
|
|
7755
7831
|
const memoryMatch = path.match(/^\/api\/assistant\/memory\/(global|user|group)(?:\/([^/]+))?$/);
|
|
7756
7832
|
if (memoryMatch) {
|
|
7757
7833
|
if (!hasGlobalPermission(caller, ['assistant'])) return permissionDenied(); const tier = memoryMatch[1] as 'global' | 'user' | 'group'; const entryKey = memoryMatch[2] ? decodeURIComponent(memoryMatch[2]) : null; const groupId = tier === 'group' ? Number(opts.params?.group) : null;
|
|
7758
7834
|
if ('group' in (opts.body ?? {})) return { status: false, error: 'group belongs in request context, not the body', error_code: 400 };
|
|
7759
|
-
if (tier === 'group'
|
|
7835
|
+
if (tier === 'group' && !caller.is_superuser) {
|
|
7836
|
+
let currentGroup: number | null = groupId; const visited = new Set<number>(); let membership: MockMember | undefined;
|
|
7837
|
+
while (currentGroup != null && !visited.has(currentGroup)) { visited.add(currentGroup); membership = db.members.find(member => member.user === caller.id && member.group === currentGroup && member.is_active); if (membership) break; const parent = db.groups.find(row => row.id === currentGroup)?.parent; currentGroup = typeof parent === 'number' ? parent : typeof parent?.id === 'number' ? parent.id : null; }
|
|
7838
|
+
if (!membership || method !== 'GET' && !['assistant', 'admin'].some(permission => Boolean(membership!.permissions[permission]))) return permissionDenied();
|
|
7839
|
+
}
|
|
7760
7840
|
const store: Record<string, unknown> = tier === 'global' ? db.assistantMemory.global : tier === 'user' ? (db.assistantMemory.users.get(caller.id) ?? {}) : (db.assistantMemory.groups.get(groupId!) ?? {});
|
|
7761
7841
|
if (method === 'GET' && entryKey == null) return { status: true, data: { ...store } };
|
|
7762
7842
|
if (method === 'POST' && entryKey == null) { const key = typeof opts.body?.key === 'string' ? opts.body.key : ''; const value = typeof opts.body?.value === 'string' ? opts.body.value : ''; if (!/^[a-z0-9:_-]{1,64}$/.test(key) || value.length < 1 || value.length > 500) return { status: false, error: 'Invalid memory key or value', error_code: 400 }; store[key] = value; if (tier === 'user') db.assistantMemory.users.set(caller.id, store); if (tier === 'group') db.assistantMemory.groups.set(groupId!, store); return { status: true, data: { key, value } }; }
|
|
@@ -9058,7 +9138,11 @@ export async function mockFetch(path: string, opts: MockFetchOpts): Promise<unkn
|
|
|
9058
9138
|
return { status: true, data: { ...row }, graph: String(opts.params?.graph ?? 'default') };
|
|
9059
9139
|
}
|
|
9060
9140
|
const graph = String(opts.params?.graph ?? 'default');
|
|
9061
|
-
|
|
9141
|
+
const geo = row.source_ip ? db.geoIps.find((candidate) => candidate.ip_address === row.source_ip) : null;
|
|
9142
|
+
const ipInfo = geo
|
|
9143
|
+
? serializeGeoIp(geo, 'default')
|
|
9144
|
+
: { ip_address: row.source_ip, country_code: row.country_code };
|
|
9145
|
+
return { status: true, data: { ...row, ...(graph === 'detailed' ? { ip_info: ipInfo } : {}) }, graph };
|
|
9062
9146
|
}
|
|
9063
9147
|
if (method === 'POST') return { status: false, error: 'Incident creation is not available', error_code: 403 };
|
|
9064
9148
|
const result = listRows(
|
package/src/client/upload.ts
CHANGED
|
@@ -368,6 +368,7 @@ class UploadTask implements FileUploadTask {
|
|
|
368
368
|
onProgress: (loaded: number, total: number) => this.#publish({ loadedBytes: loaded, totalBytes: total > 0 ? total : null }, generation),
|
|
369
369
|
};
|
|
370
370
|
if (usingMockTransport()) {
|
|
371
|
+
if (import.meta.env.VITE_MOJO_PACKAGED_ADMIN === '1') throw new Error('Mock upload is unavailable in packaged Admin');
|
|
371
372
|
const { mockUploadBytes } = await import('./mock');
|
|
372
373
|
await mockUploadBytes(request);
|
|
373
374
|
} else await xhrUpload(request);
|
package/src/ui/DetailView.tsx
CHANGED
|
@@ -76,6 +76,8 @@ export interface DetailMenuItem<TCtx = unknown> {
|
|
|
76
76
|
onSelect: () => void;
|
|
77
77
|
/** Destructive styling (`--bad`), the source's `danger` flag. */
|
|
78
78
|
danger?: boolean;
|
|
79
|
+
/** Keep an action visible while preventing repeat submission. */
|
|
80
|
+
disabled?: boolean;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export type DetailMenuEntry<TCtx = unknown> = DetailMenuItem<TCtx> | { divider: true };
|
|
@@ -86,7 +88,7 @@ export interface DetailViewProps<TCtx = unknown> {
|
|
|
86
88
|
title: string;
|
|
87
89
|
subtitle?: string;
|
|
88
90
|
chips?: Chip[];
|
|
89
|
-
active?: { value: boolean; onChange: (next: boolean) => void };
|
|
91
|
+
active?: { value: boolean; onChange: (next: boolean) => void; disabled?: boolean };
|
|
90
92
|
sections: RailEntry[];
|
|
91
93
|
initialSection?: string;
|
|
92
94
|
/** Controlled rail badges keyed by section key — numbers, dots, text,
|
|
@@ -229,6 +231,8 @@ export function DetailView<TCtx = unknown>({
|
|
|
229
231
|
avatarName, icon, title, subtitle, chips = [], active,
|
|
230
232
|
sections, initialSection, badges, contextMenu, menuContext, onClose,
|
|
231
233
|
}: DetailViewProps<TCtx>) {
|
|
234
|
+
const [sectionsExpanded, setSectionsExpanded] = useState(false);
|
|
235
|
+
const sectionToggleRef = useRef<HTMLButtonElement>(null);
|
|
232
236
|
const can = useCanChecker();
|
|
233
237
|
const warnOnce = useWarnOnce();
|
|
234
238
|
|
|
@@ -299,7 +303,7 @@ export function DetailView<TCtx = unknown>({
|
|
|
299
303
|
if (menuOpen && menuEntries.length === 0) setMenuOpen(false);
|
|
300
304
|
|
|
301
305
|
return (
|
|
302
|
-
<div className=
|
|
306
|
+
<div className={`detail${onClose ? ' detail-modal-body' : ''}`}>
|
|
303
307
|
<header className="detail-header">
|
|
304
308
|
<div className="detail-avatar">
|
|
305
309
|
{avatarName ? initials(avatarName) : <i className={`bi ${icon ?? 'bi-file-earmark'}`} />}
|
|
@@ -323,6 +327,8 @@ export function DetailView<TCtx = unknown>({
|
|
|
323
327
|
role="switch"
|
|
324
328
|
className="switch"
|
|
325
329
|
checked={active.value}
|
|
330
|
+
disabled={active.disabled}
|
|
331
|
+
aria-label="Active"
|
|
326
332
|
onChange={(e) => active.onChange(e.target.checked)}
|
|
327
333
|
/>
|
|
328
334
|
</label>
|
|
@@ -356,8 +362,9 @@ export function DetailView<TCtx = unknown>({
|
|
|
356
362
|
key={`${entry.label}${i}`}
|
|
357
363
|
className={`dv-menu-item${entry.danger ? ' dv-danger' : ''}`}
|
|
358
364
|
role="menuitem"
|
|
365
|
+
disabled={entry.disabled}
|
|
359
366
|
// Source order: run the handler, then close.
|
|
360
|
-
onClick={() => { entry.onSelect(); setMenuOpen(false); }}
|
|
367
|
+
onClick={() => { if (!entry.disabled) { kebabRef.current?.focus(); entry.onSelect(); setMenuOpen(false); } }}
|
|
361
368
|
>
|
|
362
369
|
{entry.icon && <i className={`bi ${entry.icon}`} />}
|
|
363
370
|
<span>{entry.label}</span>
|
|
@@ -375,14 +382,24 @@ export function DetailView<TCtx = unknown>({
|
|
|
375
382
|
</div>
|
|
376
383
|
</header>
|
|
377
384
|
<div className="detail-body">
|
|
378
|
-
<
|
|
385
|
+
<button ref={sectionToggleRef} type="button" className="detail-section-toggle" aria-expanded={sectionsExpanded} onClick={() => setSectionsExpanded(value => !value)}>
|
|
386
|
+
<span>{visibleSections.find(section => section.key === effectiveKey)?.label ?? 'Sections'}</span>
|
|
387
|
+
{railBadge(safeNode(badges?.[effectiveKey ?? ''], 'DetailView active badge'))}
|
|
388
|
+
<i className={`bi bi-chevron-${sectionsExpanded ? 'up' : 'down'}`} />
|
|
389
|
+
</button>
|
|
390
|
+
<nav className={`detail-rail${sectionsExpanded ? ' detail-rail-expanded' : ''}`} aria-label={`${title} sections`}>
|
|
379
391
|
{visibleEntries.map((entry, i) => 'divider' in entry ? (
|
|
380
392
|
<div key={`d${i}`} className="rail-divider">{entry.divider}</div>
|
|
381
393
|
) : (
|
|
382
394
|
<button
|
|
383
395
|
key={entry.key}
|
|
384
396
|
className={`rail-item${entry.key === effectiveKey ? ' rail-active' : ''}`}
|
|
385
|
-
|
|
397
|
+
aria-current={entry.key === effectiveKey ? 'page' : undefined}
|
|
398
|
+
onClick={() => {
|
|
399
|
+
if (sectionsExpanded) sectionToggleRef.current?.focus();
|
|
400
|
+
setActiveKey(entry.key);
|
|
401
|
+
setSectionsExpanded(false);
|
|
402
|
+
}}
|
|
386
403
|
>
|
|
387
404
|
<i className={`bi ${entry.icon}`} /> {entry.label}
|
|
388
405
|
{railBadge(safeNode(badges?.[entry.key], `DetailView badge "${entry.key}"`))}
|
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)?`,
|
package/src/ui/Popover.tsx
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// `showPopover()`): top-layer members paint in promotion order, so a popover
|
|
12
12
|
// opened from inside a modal stacks above it. `manual` (not `auto`) opts out
|
|
13
13
|
// of UA light-dismiss — close behavior stays ours, matching the source.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// A popover anchored inside a modal remains a DOM descendant of that dialog
|
|
15
|
+
// in both paths. Top-layer painting alone does not escape the document's
|
|
16
|
+
// modal inertness: a body portal can be visible yet reject pointer/focus input.
|
|
17
|
+
// Browsers without the API paint the fixed descendant with its dialog host.
|
|
18
18
|
//
|
|
19
19
|
// Coordinates: the element is position:fixed in BOTH paths, so all math is
|
|
20
20
|
// viewport-relative (a deliberate translation from the source's
|
|
@@ -118,6 +118,10 @@ export function Popover({
|
|
|
118
118
|
top = rect.top - popRect.height - gap;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Keep a tall menu reachable even when it cannot fit wholly on
|
|
122
|
+
// either side. Individual menu content owns scrolling; do not clip
|
|
123
|
+
// this host, which may contain nested calendar/picker popovers.
|
|
124
|
+
top = Math.max(VIEWPORT_INSET, Math.min(top, vh - popRect.height - VIEWPORT_INSET));
|
|
121
125
|
el.style.top = `${top}px`;
|
|
122
126
|
el.style.left = `${left}px`;
|
|
123
127
|
};
|
|
@@ -162,6 +166,7 @@ export function Popover({
|
|
|
162
166
|
// request. The next Escape then reaches the dialog.
|
|
163
167
|
event.preventDefault();
|
|
164
168
|
event.stopPropagation();
|
|
169
|
+
anchorRef.current?.focus();
|
|
165
170
|
onCloseRef.current('escape');
|
|
166
171
|
};
|
|
167
172
|
|
|
@@ -180,14 +185,10 @@ export function Popover({
|
|
|
180
185
|
|
|
181
186
|
if (!open) return null;
|
|
182
187
|
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
// anchor mounts before any user-driven open.)
|
|
188
|
-
const host = SUPPORTS_POPOVER
|
|
189
|
-
? document.body
|
|
190
|
-
: anchorRef.current?.closest('dialog') ?? document.body;
|
|
188
|
+
// Keep modal descendants outside the inert document subtree, including
|
|
189
|
+
// when showPopover() promotes them to the native top layer. Ref reads are
|
|
190
|
+
// idempotent here: the anchor mounts before any user-driven opening.
|
|
191
|
+
const host = anchorRef.current?.closest('dialog') ?? document.body;
|
|
191
192
|
|
|
192
193
|
return createPortal(
|
|
193
194
|
<div
|