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.
Files changed (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. package/src/ui/toast.tsx +48 -5
@@ -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
@@ -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 (!API_BASE) {
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(`${API_BASE}${path}${buildQuery(opts.params ?? {})}`, {
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 (!API_BASE) {
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(`${API_BASE}${endpoint}${buildQuery(withFormat)}`, { headers });
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
  }
@@ -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?{status:true,message:`${row.provider} connection succeeded`}:{status:false,error:`${row.provider} credentials are incomplete`,error_code:400};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
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') { const membership = db.members.find((member) => member.user === caller.id && member.group === groupId && member.is_active); if (!membership) return permissionDenied(); }
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
- return { status: true, data: { ...row, ...(graph === 'detailed' ? { ip_info: { ip_address: row.source_ip, country_code: row.country_code } } : {}) }, graph };
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(
@@ -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);
@@ -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="detail">
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
- <nav className="detail-rail" aria-label={`${title} sections`}>
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
- onClick={() => setActiveKey(entry.key)}
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}"`))}
@@ -26,6 +26,7 @@ import { toast } from './toast';
26
26
  import {
27
27
  useFormAutosave,
28
28
  type AutosaveBatchInfo,
29
+ type AutosaveBeforeSave,
29
30
  type FieldStatus,
30
31
  type FormAutosaveApi,
31
32
  } from './form-autosave';
@@ -104,13 +105,34 @@ export interface FormViewProps<T extends { id: number | string }> {
104
105
  savedFlashMs?: number;
105
106
  onSaved?: (info: AutosaveBatchInfo & { row: T }) => void;
106
107
  onSaveError?: (info: AutosaveBatchInfo & { error: Error }) => void;
108
+ /**
109
+ * Pre-save gate, once per batch. `false` drops the batch (its fields
110
+ * revert, no toast); an object replaces the wire body; true/undefined
111
+ * proceeds. Put a `confirmGuardrail` here for grants/identity fields.
112
+ */
113
+ beforeSave?: AutosaveBeforeSave<T>;
107
114
  /** True while any FileField transfer is active. Owners can block dismissal. */
108
115
  onPendingChange?: (pending: boolean) => void;
109
116
  className?: string;
110
117
  }
111
118
 
119
+ const saveOwnerIds = new WeakMap<object, number>();
120
+ let nextSaveOwnerId = 0;
121
+
112
122
  export function FormView<T extends { id: number | string }>(props: FormViewProps<T>) {
113
- const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, className } = props;
123
+ // A TanStack mutation observer updates its mutationFn on rerender.
124
+ // Keep the observer and reducer with the model/record that created them,
125
+ // so a confirmation already in flight cannot migrate to another owner.
126
+ let modelId = saveOwnerIds.get(props.model.useSave);
127
+ if (modelId === undefined) {
128
+ modelId = ++nextSaveOwnerId;
129
+ saveOwnerIds.set(props.model.useSave, modelId);
130
+ }
131
+ return <OwnedFormView key={JSON.stringify([modelId, props.row.id])} {...props} />;
132
+ }
133
+
134
+ function OwnedFormView<T extends { id: number | string }>(props: FormViewProps<T>) {
135
+ const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, beforeSave, className } = props;
114
136
  const save = model.useSave();
115
137
 
116
138
  // Registry-named tabsets re-render on registration; inline defs pass through.
@@ -164,6 +186,7 @@ export function FormView<T extends { id: number | string }>(props: FormViewProps
164
186
  save: (changes) => save.mutateAsync({ id: row.id, changes } satisfies SaveVars),
165
187
  debounceMs,
166
188
  savedFlashMs,
189
+ beforeSave,
167
190
  onSaved: (info) => {
168
191
  publishOwnerResult(info.fields, 'success', info.changes, info.row);
169
192
  onSaved?.(info);
@@ -56,12 +56,18 @@ interface BatchActionBase<T> {
56
56
  danger?: boolean;
57
57
  /** Row-state eligibility. Ineligible selected rows are never submitted. */
58
58
  eligible?: (row: T) => boolean;
59
- /** Confirm copy; false skips the prompt. Default: "<label> N item(s)?" */
60
- confirm?: string | false;
59
+ /**
60
+ * The permission step. A string is the `modal.confirm` copy; false skips
61
+ * the prompt; a function REPLACES the built-in confirm — resolve false
62
+ * to cancel (open a `confirmGuardrail` here for batches that take a
63
+ * tenant dark or can't be undone). Default: "<label> N item(s)?"
64
+ */
65
+ confirm?: string | false | ((rows: T[]) => Promise<boolean>);
61
66
  /**
62
67
  * Optional once-per-batch step (collect a reason, pick a value…) run
63
68
  * AFTER the confirm. Resolve null to cancel the batch; the resolved
64
- * value is passed to every run() call.
69
+ * value is passed to every run() call. Ask permission in `confirm`,
70
+ * not here — a null from prepare means "nothing collected".
65
71
  */
66
72
  prepare?: (rows: T[]) => Promise<unknown | null>;
67
73
  }
@@ -449,7 +455,9 @@ export function ModelTable<T extends { id: RowId }>({
449
455
  const runBatch = async (action: BatchAction<T>) => {
450
456
  const targets = rows.filter((r) => selected.has(r.id) && (!action.eligible || action.eligible(r)));
451
457
  if (targets.length === 0) return;
452
- if (action.confirm !== false) {
458
+ if (typeof action.confirm === 'function') {
459
+ if (!(await action.confirm(targets))) return;
460
+ } else if (action.confirm !== false) {
453
461
  const ok = await modal.confirm({
454
462
  title: action.label,
455
463
  message: action.confirm ?? `${action.label} ${targets.length} item(s)?`,
@@ -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
- // Browsers without the API fall back to a plain portaled div; when the
15
- // anchor sits inside an open <dialog> the portal target becomes that dialog
16
- // (a descendant paints with its top-layer host, and position:fixed children
17
- // are not clipped by ancestor overflow), so pickers-in-modals keep working.
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
- // Portal target is decided at render. With the Popover API the top layer
184
- // makes document.body universal; without it, an anchor inside an open
185
- // modal portals into that <dialog> so the popover paints with its
186
- // top-layer host. (Ref read during render is idempotent here — the
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