@happyvertical/smrt-users 0.31.0 → 0.32.0

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 (38) hide show
  1. package/AGENTS.md +40 -15
  2. package/dist/chunks/{TerminalAuthService-DoAMQ_yn.js → TerminalAuthService-D5VVPG9e.js} +247 -89
  3. package/dist/chunks/TerminalAuthService-D5VVPG9e.js.map +1 -0
  4. package/dist/chunks/{index-DkoYIvIu.js → index-CitgZk-4.js} +10 -10
  5. package/dist/chunks/{index-DkoYIvIu.js.map → index-CitgZk-4.js.map} +1 -1
  6. package/dist/collections/GroupMemberCollection.d.ts +9 -0
  7. package/dist/collections/GroupMemberCollection.d.ts.map +1 -1
  8. package/dist/collections/SessionCollection.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +38 -100
  12. package/dist/index.js.map +1 -1
  13. package/dist/manifest.json +2 -2
  14. package/dist/services/PermissionResolver.d.ts +24 -3
  15. package/dist/services/PermissionResolver.d.ts.map +1 -1
  16. package/dist/services/SessionService.d.ts +42 -5
  17. package/dist/services/SessionService.d.ts.map +1 -1
  18. package/dist/services/index.d.ts +1 -1
  19. package/dist/services/index.d.ts.map +1 -1
  20. package/dist/smrt-knowledge.json +6 -6
  21. package/dist/svelte/components/InviteUserModal.svelte +72 -169
  22. package/dist/svelte/components/InviteUserModal.svelte.d.ts.map +1 -1
  23. package/dist/svelte/components/UserCard.svelte +2 -1
  24. package/dist/svelte/components/UserCard.svelte.d.ts.map +1 -1
  25. package/dist/svelte/components/UserForm.svelte +11 -4
  26. package/dist/svelte/components/UserForm.svelte.d.ts.map +1 -1
  27. package/dist/svelte/components/UserMenu.svelte +100 -25
  28. package/dist/svelte/components/UserMenu.svelte.d.ts +5 -4
  29. package/dist/svelte/components/UserMenu.svelte.d.ts.map +1 -1
  30. package/dist/svelte/components/__tests__/InviteUserModal.test.js +11 -0
  31. package/dist/svelte/components/__tests__/UserMenu.test.js +45 -0
  32. package/dist/svelte/components/__tests__/UserStatus.test.js +36 -0
  33. package/dist/sveltekit/index.d.ts +17 -1
  34. package/dist/sveltekit/index.d.ts.map +1 -1
  35. package/dist/sveltekit.js +37 -9
  36. package/dist/sveltekit.js.map +1 -1
  37. package/package.json +8 -8
  38. package/dist/chunks/TerminalAuthService-DoAMQ_yn.js.map +0 -1
@@ -8,7 +8,7 @@ export { type PermissionCatalog, PermissionCatalogService, type PermissionCatalo
8
8
  export { type PermissionResolutionOptions, type PermissionResolutionResult, PermissionResolver, type TenantPermissionInheritanceResult, } from './PermissionResolver.js';
9
9
  export { applyPostgresPermissionPolicies, type GeneratePostgresPermissionSqlResult, generatePostgresPermissionSql, type PostgresPermissionPolicyReportItem, type PostgresPermissionPolicyTarget, } from './PostgresPermissionPolicies.js';
10
10
  export { getCurrentSessionPermissionContext, getRequestScopedDatabase, type SessionPermissionRuntimeContext, type SessionPermissionRuntimeOptions, withSessionPermissionContext, } from './SessionPermissionContext.js';
11
- export { type SessionContext, SessionService, type SessionServiceOptions, } from './SessionService.js';
11
+ export { type SessionContext, SessionService, type SessionServiceOptions, type SwitchTenantResult, } from './SessionService.js';
12
12
  export { type EnsureTenantResult, TenantService, type TenantWithOwnershipResult, } from './TenantService.js';
13
13
  export { type ApproveCliAuthRequestInput, type CliAuthStartResult, type CliAuthTokenResult, DEFAULT_CLI_AUTH_APPROVE_ATTEMPT_WINDOW_SECONDS, DEFAULT_CLI_AUTH_MAX_APPROVE_ATTEMPTS, DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS, DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS, DEFAULT_CLI_SESSION_TTL_SECONDS, TerminalAuthError, TerminalAuthRateLimitError, TerminalAuthService, type TerminalAuthServiceOptions, } from './TerminalAuthService.js';
14
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/services/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,GAC3B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,6BAA6B,EAClC,qBAAqB,EACrB,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,0BAA0B,EAC/B,yBAAyB,EACzB,KAAK,eAAe,GACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,2BAA2B,EAChC,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,yBAAyB,EAC9B,6BAA6B,EAC7B,qBAAqB,EACrB,KAAK,WAAW,GACjB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,EAC/B,kBAAkB,EAClB,KAAK,iCAAiC,GACvC,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,+BAA+B,EAC/B,KAAK,mCAAmC,EACxC,6BAA6B,EAC7B,KAAK,kCAAkC,EACvC,KAAK,8BAA8B,GACpC,MAAM,iCAAiC,CAAC;AACzC,OAAO,EACL,kCAAkC,EAClC,wBAAwB,EACxB,KAAK,+BAA+B,EACpC,KAAK,+BAA+B,EACpC,4BAA4B,GAC7B,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,KAAK,cAAc,EACnB,cAAc,EACd,KAAK,qBAAqB,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,KAAK,kBAAkB,EACvB,aAAa,EACb,KAAK,yBAAyB,GAC/B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,+CAA+C,EAC/C,qCAAqC,EACrC,sCAAsC,EACtC,oCAAoC,EACpC,+BAA+B,EAC/B,iBAAiB,EACjB,0BAA0B,EAC1B,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/services/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,GAC3B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,6BAA6B,EAClC,qBAAqB,EACrB,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,cAAc,EACd,KAAK,eAAe,EACpB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,0BAA0B,EAC/B,yBAAyB,EACzB,KAAK,eAAe,GACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,2BAA2B,EAChC,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,yBAAyB,EAC9B,6BAA6B,EAC7B,qBAAqB,EACrB,KAAK,WAAW,GACjB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,EAC/B,kBAAkB,EAClB,KAAK,iCAAiC,GACvC,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,+BAA+B,EAC/B,KAAK,mCAAmC,EACxC,6BAA6B,EAC7B,KAAK,kCAAkC,EACvC,KAAK,8BAA8B,GACpC,MAAM,iCAAiC,CAAC;AACzC,OAAO,EACL,kCAAkC,EAClC,wBAAwB,EACxB,KAAK,+BAA+B,EACpC,KAAK,+BAA+B,EACpC,4BAA4B,GAC7B,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,KAAK,cAAc,EACnB,cAAc,EACd,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,GACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,KAAK,kBAAkB,EACvB,aAAa,EACb,KAAK,yBAAyB,GAC/B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,+CAA+C,EAC/C,qCAAqC,EACrC,sCAAsC,EACtC,oCAAoC,EACpC,+BAA+B,EAC/B,iBAAiB,EACjB,0BAA0B,EAC1B,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,0BAA0B,CAAC"}
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-23T03:03:25.017Z",
3
+ "generatedAt": "2026-06-23T07:09:19.361Z",
4
4
  "packageName": "@happyvertical/smrt-users",
5
- "packageVersion": "0.31.0",
5
+ "packageVersion": "0.32.0",
6
6
  "sourceManifestPath": "dist/manifest.json",
7
7
  "agentDocPath": "AGENTS.md",
8
8
  "sourceHashes": {
9
- "manifest": "8ec39bbe68106d5fe9abd1a3fb65a7f0931c40f0c08f86dce9421b2f10cd3821",
10
- "packageJson": "5b2d61d8efdf09830953fb9e7d9b5bedfd337cd02cf27c453c519d53da867745",
11
- "agents": "7265a29482d4a39a46d84a489ee09b49eb5ecc4e71a52c94909dbd20a51f619c"
9
+ "manifest": "f526b417cde5b18aa044abcdc6274ef62a764d0dd18f6bff043718fa6890e841",
10
+ "packageJson": "a32af4a443328aff88d71e82aa3baa1e7a2ac68ebecef915d9d9a0cf9ef982ba",
11
+ "agents": "2684404a1735fd4993d4383f2dfed5f9c0f4de68f0cbfcd996f7e94eb50ac9c8"
12
12
  },
13
13
  "exports": [
14
14
  ".",
@@ -2740,5 +2740,5 @@
2740
2740
  "polymorphicAssociations": 0,
2741
2741
  "uuidColumns": 47
2742
2742
  },
2743
- "agentDoc": "# @happyvertical/smrt-users\n\nMulti-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.\n\n## Models (13)\n\n| Model | Key Pattern |\n|-------|-------------|\n| User | Auth identity. `profileId` is plain string (not FK) to smrt-profiles. Email auto-lowercased. |\n| Tenant | **STI** + hierarchical parent-child. `hierarchyPath` (materialized path), `hierarchyLevel`. Max depth 10. |\n| Session | Server-side. Secure UUID. TTL in **seconds** (not ms). Status auto-updates to EXPIRED on access. |\n| MagicLinkToken | Single-use email login token. Backed by `MagicLinkService`. |\n| Role | `tenantId = null` → system role (available to all tenants). `isSystem: true` blocks deletion. |\n| Permission | Slug format: `resource.action`. Parsed by PermissionResolver. |\n| Membership | User + Tenant + Role junction. UNIQUE(userId, tenantId). |\n| Group | Team within a tenant. Multiple roles via GroupRole. |\n| GroupMember, GroupRole, RolePermission | Join tables. |\n| MembershipOverride | Per-user permission grant/deny. **DENY always wins.** |\n| TenantPermissionOverride | Tenant-level cascade overrides. Effect: INHERIT/GRANT/DENY. |\n\n## Permission Resolution — 4-Level Cascade\n\nPermissionResolver evaluates in order (each level can add/remove permissions):\n\n1. **Tenant hierarchy** — walk ancestors, apply TenantPermissionOverride at each level\n2. **Membership role** — base permissions from user's role in tenant\n3. **Group roles** — permissions from all groups user belongs to **in that tenant**\n4. **Membership overrides** — final GRANT/DENY per-user (DENY takes absolute precedence)\n\n**Critical**: `getGroupIdsForTenant(userId, tenantId)` (joins with groups table to scope by tenant). Never use `getGroupIds()` — it's cross-tenant.\n\n## Hierarchical Tenants\n\n- `TenantCollection.createChild()` auto-calculates hierarchy fields, enforces depth limit\n- `moveToParent()` updates tenant + ALL descendants' paths/levels\n- `cascadePermissions` (parent pushes down) + `inheritPermissions` (child accepts) — both must be true\n- `getTree(rootId?)` returns nested structure for UI\n\n## SvelteKit Integration\n\n```typescript\n// hooks.server.ts\nexport const handle = createSessionHandler({ db, ttl: 604800, skipPaths: ['/api/public'] });\n// Populates event.locals: { user, membership, permissions: string[], tenantId, sessionId }\n\n// +page.server.ts\nawait createSessionCookie(event, userId, tenantId, { db });\nawait destroySessionCookie(event, { db });\nawait switchSessionTenant(event, tenantId, { db });\n```\n\n## Security (S5 #1400)\n\n- **Generated REST/MCP surface is READ-ONLY for every RBAC/identity model.**\n User, Tenant, Group, Membership, MembershipOverride, Role, Permission,\n RolePermission, GroupRole, GroupMember, and TenantPermissionOverride generate\n `list`/`get` only — `create`/`update`/`delete` are intentionally NOT\n generated. The merged `requireRouteAuth` gate (#1540) enforces *authentication*,\n not *authorization*, and these models are not `@TenantScoped`, so an\n auto-generated mutating route would let any authenticated user self-grant a\n role/permission, flip a tenant's cascade flags, or change another user's auth\n identity. Mutate them through the permission-gated services (`TenantService`,\n collection helpers) or consumer-owned, permission-checked handlers. A\n structural regression test (`security-audit-1400.test.ts`) enumerates the\n registry to assert no authority model exposes a mutating op. (`cli` stays\n enabled — local-operator surface, outside the network/agent threat model.)\n- **`switchTenant` is fail-closed.** `SessionService.switchTenant` /\n `switchSessionTenant` verify the session's user has an ACTIVE membership in the\n target tenant before writing `session.tenantId` (the tenant-isolation key for\n every `@TenantScoped` query). A non-member switch returns `false` without\n mutating the session; `null` clears the context and is always allowed. The\n low-level `SessionCollection.setSessionTenant` is the UNGUARDED primitive —\n never call it with an untrusted tenant id.\n- **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`\n refuses to provision a user when the IdP explicitly returns\n `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An\n absent claim makes no assertion and is not enforced.\n\n## Gotchas\n\n- **seedSystemRoles() required**: call `RoleCollection.seedSystemRoles()` at app init (creates owner/admin/member/viewer)\n- **PermissionResolver casts `as any`**: collections have protected constructors — known framework limitation\n- **Session TTL in seconds**: `DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60` (not milliseconds)\n- **Users are cross-tenant**: one user, many tenants via Membership. Email globally unique.\n- **Batch permission queries**: resolver fetches all permission IDs in one query, then maps to slugs (avoids N+1)\n"
2743
+ "agentDoc": "# @happyvertical/smrt-users\n\nMulti-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.\n\n## Models (13)\n\n| Model | Key Pattern |\n|-------|-------------|\n| User | Auth identity. `profileId` is plain string (not FK) to smrt-profiles. Email auto-lowercased. |\n| Tenant | **STI** + hierarchical parent-child. `hierarchyPath` (materialized path), `hierarchyLevel`. Max depth 10. |\n| Session | Server-side. Secure UUID. TTL in **seconds** (not ms). Status auto-updates to EXPIRED on access. |\n| MagicLinkToken | Single-use email login token. Backed by `MagicLinkService`. |\n| Role | `tenantId = null` → system role (available to all tenants). `isSystem: true` blocks deletion. |\n| Permission | Slug format: `resource.action`. Parsed by PermissionResolver. |\n| Membership | User + Tenant + Role junction. UNIQUE(userId, tenantId). |\n| Group | Team within a tenant. Multiple roles via GroupRole. |\n| GroupMember, GroupRole, RolePermission | Join tables. |\n| MembershipOverride | Per-user permission grant/deny. **DENY always wins.** |\n| TenantPermissionOverride | Tenant-level cascade overrides. Effect: INHERIT/GRANT/DENY. |\n\n## Permission Resolution — Precedence (broad → specific, most-specific wins)\n\n`PermissionResolver.resolvePermissions` builds the effective set in this order;\neach later layer overrides earlier ones:\n\n1. **Tenant-inherited** — walk ancestors, apply each `TenantPermissionOverride`\n down the cascade (GRANT adds, DENY removes within the hierarchy)\n2. **Membership role** — base permissions from the user's role in the tenant\n3. **Group roles** — permissions from all groups the user belongs to **in that tenant**\n4. **Tenant-level DENY** *(removes; overrides role/group grants, tenant-wide)* a\n `TenantPermissionOverride` with effect `DENY` is a HARD, tenant-wide block: it\n subtracts the DENY'd slug even if a role or group granted it (steps 2–3). It\n sits just **above** the per-user membership overrides and **below** role/group.\n5. **Membership GRANT override** *(re-adds; most specific)* — a per-user GRANT can\n re-add a slug a tenant DENY'd in step 4, because it is more specific.\n6. **Membership DENY override** *(absolute; always wins)* — a per-user DENY removes\n the slug last and is never overridden.\n\nSo a permission a role grants but the tenant DENYs is **removed**, unless that\nexact user also has a membership-GRANT override for it. A membership-DENY always\nwins. Tenant-DENY of an inherited/cascade grant still blocks it (unchanged).\nThe hard block reflects the tenant cascade's **net** resolution, not an\nunconditional union of every DENY in the chain — so a more-specific tenant GRANT\n(e.g. a child sub-tenant re-granting a permission its parent DENYs) still wins.\n\n**Critical**: `getGroupIdsForTenant(userId, tenantId)` (joins with groups table to scope by tenant). Never use `getGroupIds()` — it's cross-tenant.\n\n## Hierarchical Tenants\n\n- `TenantCollection.createChild()` auto-calculates hierarchy fields, enforces depth limit\n- `moveToParent()` updates tenant + ALL descendants' paths/levels\n- `cascadePermissions` (parent pushes down) + `inheritPermissions` (child accepts) — both must be true\n- `getTree(rootId?)` returns nested structure for UI\n\n## SvelteKit Integration\n\n```typescript\n// hooks.server.ts\nexport const handle = createSessionHandler({ db, ttl: 604800, skipPaths: ['/api/public'] });\n// Populates event.locals: { user, membership, permissions: string[], tenantId, sessionId }\n\n// +page.server.ts\nawait createSessionCookie(event, userId, tenantId, { db });\nawait destroySessionCookie(event, { db });\nawait switchSessionTenant(event, tenantId, { db });\n```\n\n## Security (S5 #1400)\n\n- **Generated REST/MCP surface is READ-ONLY for every RBAC/identity model.**\n User, Tenant, Group, Membership, MembershipOverride, Role, Permission,\n RolePermission, GroupRole, GroupMember, and TenantPermissionOverride generate\n `list`/`get` only — `create`/`update`/`delete` are intentionally NOT\n generated. The merged `requireRouteAuth` gate (#1540) enforces *authentication*,\n not *authorization*, and these models are not `@TenantScoped`, so an\n auto-generated mutating route would let any authenticated user self-grant a\n role/permission, flip a tenant's cascade flags, or change another user's auth\n identity. Mutate them through the permission-gated services (`TenantService`,\n collection helpers) or consumer-owned, permission-checked handlers. A\n structural regression test (`security-audit-1400.test.ts`) enumerates the\n registry to assert no authority model exposes a mutating op. (`cli` stays\n enabled — local-operator surface, outside the network/agent threat model.)\n- **`switchTenant` is fail-closed AND rotates the session id.**\n `SessionService.switchTenant` / `switchSessionTenant` verify the session's user\n has an ACTIVE membership in the target tenant before any write (the tenant id\n is the isolation key for every `@TenantScoped` query). A non-member/unknown-\n session switch returns `{ switched: false, sessionId: null, ... }` and mutates\n nothing. On a successful switch into a NON-null tenant the session id is\n ROTATED: a fresh `Session` (new secure id, fresh TTL, same user, new tenant,\n device context carried over) is minted and the old session is REVOKED — so a\n captured pre-switch id immediately stops validating, shrinking the blast radius\n of a leaked id across a tenant boundary. `switchTenant` returns a\n `SwitchTenantResult` (`{ switched, sessionId, session, rotated }`); callers MUST\n persist the returned `sessionId`. `switchSessionTenant` does this for you by\n re-setting the session cookie (preserving httpOnly/secure/sameSite) to the new\n id. A `null` clear stays in place (no rotation, no cookie change). The\n low-level `SessionCollection.setSessionTenant` is the UNGUARDED primitive (used\n for the null-clear path) — never call it with an untrusted tenant id.\n- **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`\n refuses to provision a user when the IdP explicitly returns\n `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An\n absent claim makes no assertion and is not enforced.\n\n## Gotchas\n\n- **seedSystemRoles() required**: call `RoleCollection.seedSystemRoles()` at app init (creates owner/admin/member/viewer)\n- **PermissionResolver casts `as any`**: collections have protected constructors — known framework limitation\n- **Session TTL in seconds**: `DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60` (not milliseconds)\n- **Users are cross-tenant**: one user, many tenants via Membership. Email globally unique.\n- **Batch permission queries**: resolver fetches all permission IDs in one query, then maps to slugs (avoids N+1)\n"
2744
2744
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { RoleSelector } from '@happyvertical/smrt-ui';
3
+ import { Modal } from '@happyvertical/smrt-ui/feedback';
3
4
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
4
5
  import type { Role, Tenant } from '@happyvertical/smrt-users';
5
6
  import { M } from '../i18n.js';
@@ -28,6 +29,8 @@ const {
28
29
  loading = false,
29
30
  }: Props = $props();
30
31
 
32
+ const formId = $props.id();
33
+
31
34
  let email = $state('');
32
35
  let roleId = $state('');
33
36
  let sendEmail = $state(true);
@@ -65,171 +68,81 @@ function handleClose() {
65
68
  error = '';
66
69
  onclose();
67
70
  }
68
-
69
- function handleBackdrop(e: MouseEvent) {
70
- if (e.target === e.currentTarget) {
71
- handleClose();
72
- }
73
- }
74
-
75
- function handleKeydown(e: KeyboardEvent) {
76
- if (e.key === 'Escape' && open) {
77
- handleClose();
78
- }
79
- }
80
71
  </script>
81
72
 
82
- <svelte:window onkeydown={handleKeydown} />
83
-
84
- {#if open}
85
- <div class="modal-backdrop">
86
- <button
87
- type="button"
88
- class="modal-overlay"
89
- aria-label={t(M['users.invite_user_modal.close_invite_dialog'])}
90
- onclick={handleClose}
91
- ></button>
92
- <div class="modal" role="dialog" aria-modal="true" tabindex="-1">
93
- <div class="header">
94
- <h2>{t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}</h2>
95
- <button type="button" class="close-btn" onclick={handleClose} aria-label={t(M['users.invite_user_modal.close'])}>
96
- <svg viewBox="0 0 20 20" fill="currentColor">
97
- <path
98
- d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
99
- />
100
- </svg>
101
- </button>
102
- </div>
103
-
104
- <form onsubmit={handleSubmit}>
105
- <div class="body">
106
- {#if error}
107
- <div class="error">{error}</div>
108
- {/if}
109
-
110
- <div class="field">
111
- <label for="invite-email">{t(M['users.invite_user_modal.email_address'])}</label>
112
- <input
113
- id="invite-email"
114
- type="email"
115
- bind:value={email}
116
- placeholder={t(M['users.invite_user_modal.email_placeholder'])}
117
- disabled={loading}
118
- required
119
- />
120
- </div>
121
-
122
- <div class="field">
123
- <label for="invite-role">Role</label>
124
- <RoleSelector
125
- {roles}
126
- value={roleId}
127
- onchange={(id: string) => (roleId = id)}
128
- disabled={loading}
129
- showDescription
130
- />
131
- </div>
132
-
133
- <div class="checkbox-field">
134
- <input id="send-email" type="checkbox" bind:checked={sendEmail} disabled={loading} />
135
- <label for="send-email">{t(M['users.invite_user_modal.send_invitation_email'])}</label>
136
- </div>
137
-
138
- {#if !sendEmail}
139
- <div class="hint">
140
- {t(M['users.invite_user_modal.pending_hint'])}
141
- </div>
142
- {/if}
143
- </div>
144
-
145
- <div class="footer">
146
- <button type="button" class="btn-secondary" onclick={handleClose} disabled={loading}>
147
- Cancel
148
- </button>
149
- <button type="submit" class="btn-primary" disabled={loading}>
150
- {#if loading}
151
- {t(M['users.invite_user_modal.sending'])}
152
- {:else}
153
- {t(M['users.invite_user_modal.send_invite'])}
154
- {/if}
155
- </button>
156
- </div>
157
- </form>
73
+ <!--
74
+ Adopts the smrt-ui Modal (native <dialog>.showModal()) for the focus trap,
75
+ focus restore, top-layer rendering, and Escape handling that the previous
76
+ hand-rolled dialog lacked (#1399 a11y blocker).
77
+ -->
78
+ <Modal
79
+ {open}
80
+ onClose={handleClose}
81
+ size="sm"
82
+ title={t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}
83
+ ariaLabel={t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}
84
+ closeOnBackdrop={!loading}
85
+ closeOnEscape={!loading}
86
+ >
87
+ <form id={formId} onsubmit={handleSubmit}>
88
+ {#if error}
89
+ <div class="error">{error}</div>
90
+ {/if}
91
+
92
+ <div class="field">
93
+ <label for="invite-email">{t(M['users.invite_user_modal.email_address'])}</label>
94
+ <input
95
+ id="invite-email"
96
+ type="email"
97
+ bind:value={email}
98
+ placeholder={t(M['users.invite_user_modal.email_placeholder'])}
99
+ disabled={loading}
100
+ required
101
+ />
158
102
  </div>
159
- </div>
160
- {/if}
161
103
 
162
- <style>
163
- .modal-backdrop {
164
- position: fixed;
165
- inset: 0;
166
- display: flex;
167
- align-items: center;
168
- justify-content: center;
169
- padding: 1rem;
170
- z-index: var(--smrt-z-index-dialog, 1300);
171
- }
172
-
173
- .modal-overlay {
174
- position: absolute;
175
- inset: 0;
176
- border: none;
177
- background: var(--smrt-color-scrim, rgba(0, 0, 0, 0.5));
178
- cursor: pointer;
179
- }
180
-
181
- .modal {
182
- position: relative;
183
- background: var(--smrt-color-surface, white);
184
- border-radius: var(--smrt-radius-large, 0.5rem);
185
- box-shadow: var(--smrt-elevation-3, 0 20px 25px -5px rgba(0, 0, 0, 0.1));
186
- width: 100%;
187
- max-width: 28rem;
188
- max-height: 90vh;
189
- overflow: hidden;
190
- z-index: 1;
191
- }
192
-
193
- .header {
194
- display: flex;
195
- align-items: center;
196
- justify-content: space-between;
197
- padding: var(--smrt-spacing-md, 1rem) var(--smrt-spacing-lg, 1.5rem);
198
- border-bottom: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
199
- }
200
-
201
- h2 {
202
- margin: 0;
203
- font: var(--smrt-typography-title-large-font, 600 1.125rem / 1.25 sans-serif);
204
- color: var(--smrt-color-on-surface, #1a1c1e);
205
- }
206
-
207
- .close-btn {
208
- display: flex;
209
- align-items: center;
210
- justify-content: center;
211
- width: 2rem;
212
- height: 2rem;
213
- background: none;
214
- border: none;
215
- border-radius: var(--smrt-radius-medium, 0.5rem);
216
- cursor: pointer;
217
- color: var(--smrt-color-on-surface-variant, #43474e);
218
- transition: background-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
219
- }
104
+ <div class="field">
105
+ <label for="invite-role">Role</label>
106
+ <RoleSelector
107
+ {roles}
108
+ value={roleId}
109
+ onchange={(id: string) => (roleId = id)}
110
+ disabled={loading}
111
+ showDescription
112
+ />
113
+ </div>
220
114
 
221
- .close-btn:hover {
222
- background: var(--smrt-color-surface-container, #f3f4f6);
223
- color: var(--smrt-color-on-surface, #1a1c1e);
224
- }
115
+ <div class="checkbox-field">
116
+ <input id="send-email" type="checkbox" bind:checked={sendEmail} disabled={loading} />
117
+ <label for="send-email">{t(M['users.invite_user_modal.send_invitation_email'])}</label>
118
+ </div>
225
119
 
226
- .close-btn svg {
227
- width: 1.25rem;
228
- height: 1.25rem;
229
- }
120
+ {#if !sendEmail}
121
+ <div class="hint">
122
+ {t(M['users.invite_user_modal.pending_hint'])}
123
+ </div>
124
+ {/if}
125
+ </form>
126
+
127
+ {#snippet footer()}
128
+ <button type="button" class="btn-secondary" onclick={handleClose} disabled={loading}>
129
+ Cancel
130
+ </button>
131
+ <button type="submit" form={formId} class="btn-primary" disabled={loading}>
132
+ {#if loading}
133
+ {t(M['users.invite_user_modal.sending'])}
134
+ {:else}
135
+ {t(M['users.invite_user_modal.send_invite'])}
136
+ {/if}
137
+ </button>
138
+ {/snippet}
139
+ </Modal>
230
140
 
231
- .body {
232
- padding: var(--smrt-spacing-lg, 1.5rem);
141
+ <style>
142
+ /* The dialog chrome (backdrop, surface, header, footer bar, close button) is
143
+ supplied by the smrt-ui Modal. Only the form-content + footer-button styles
144
+ live here. */
145
+ form {
233
146
  display: flex;
234
147
  flex-direction: column;
235
148
  gap: var(--smrt-spacing-md, 1rem);
@@ -298,15 +211,6 @@ function handleKeydown(e: KeyboardEvent) {
298
211
  border-radius: var(--smrt-radius-small, 0.25rem);
299
212
  }
300
213
 
301
- .footer {
302
- display: flex;
303
- justify-content: flex-end;
304
- gap: var(--smrt-spacing-sm, 0.75rem);
305
- padding: var(--smrt-spacing-md, 1rem) var(--smrt-spacing-lg, 1.5rem);
306
- background: var(--smrt-color-surface-container-low, #f9fafb);
307
- border-top: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
308
- }
309
-
310
214
  button {
311
215
  padding: var(--smrt-spacing-sm, 0.5rem) var(--smrt-spacing-md, 1rem);
312
216
  border-radius: var(--smrt-radius-medium, 0.5rem);
@@ -343,8 +247,7 @@ function handleKeydown(e: KeyboardEvent) {
343
247
 
344
248
  @media (prefers-reduced-motion: reduce) {
345
249
  button,
346
- input[type='email'],
347
- .close-btn {
250
+ input[type='email'] {
348
251
  transition: none;
349
252
  }
350
253
  }
@@ -1 +1 @@
1
- {"version":3,"file":"InviteUserModal.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InviteUserModal.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAI9D,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,OAAO,CAAC;KACpB,KAAK,IAAI,CAAC;IACX,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAsID,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"InviteUserModal.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InviteUserModal.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAI9D,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,OAAO,CAAC;KACpB,KAAK,IAAI,CAAC;IACX,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AA4GD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -34,7 +34,8 @@ const statusClass = $derived.by(() => {
34
34
  return 'status-pending';
35
35
  case 'suspended':
36
36
  return 'status-error';
37
- case 'deactivated':
37
+ case 'inactive':
38
+ // `inactive` is the real UserStatus value (there is no `deactivated`).
38
39
  return 'status-disabled';
39
40
  default:
40
41
  return '';
@@ -1 +1 @@
1
- {"version":3,"file":"UserCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserCard.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAuDD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserCard.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwDD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -40,6 +40,14 @@ let status = $state(initialFormState.status);
40
40
  let appliedUser: User | null = initialFormState.user;
41
41
  let appliedProfile: Profile | null = initialFormState.profile;
42
42
 
43
+ // Drive the options from the enum so the form can never offer a value that
44
+ // isn't a real UserStatus (previously offered `deactivated`, which is not an
45
+ // enum member, and omitted `inactive`).
46
+ const statusOptions = Object.values(UserStatus).map((value) => ({
47
+ value,
48
+ label: value.charAt(0).toUpperCase() + value.slice(1),
49
+ }));
50
+
43
51
  $effect(() => {
44
52
  if (appliedUser === user && appliedProfile === profile) {
45
53
  return;
@@ -75,10 +83,9 @@ function handleSubmit(e: Event) {
75
83
  <div class="field">
76
84
  <label for="status">Status</label>
77
85
  <select id="status" bind:value={status} disabled={loading}>
78
- <option value="active">Active</option>
79
- <option value="pending">Pending</option>
80
- <option value="suspended">Suspended</option>
81
- <option value="deactivated">Deactivated</option>
86
+ {#each statusOptions as option (option.value)}
87
+ <option value={option.value}>{option.label}</option>
88
+ {/each}
82
89
  </select>
83
90
  </div>
84
91
 
@@ -1 +1 @@
1
- {"version":3,"file":"UserForm.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserForm.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAgGD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserForm.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserForm.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAuGD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -3,15 +3,18 @@
3
3
  * UserMenu - User profile menu dropdown
4
4
  * refactored for Material 3
5
5
  *
6
- * Accessibility:
6
+ * Accessibility (WAI-ARIA menu-button pattern):
7
7
  * - Proper ARIA attributes for menu state
8
- * - Keyboard navigation (Escape to close)
9
- * - Click outside to close
10
- * - Focus management
8
+ * - Roving tabindex over the menu items
9
+ * - Arrow Up/Down, Home/End to move between items; Escape closes + refocuses
10
+ * the trigger; Tab closes; click-outside dismisses
11
+ * - Focus management (first item focused on open, trigger refocused on close)
11
12
  */
13
+
12
14
  import type { Profile } from '@happyvertical/smrt-profiles';
13
15
  import { ripple } from '@happyvertical/smrt-ui';
14
16
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
17
+ import { tick } from 'svelte';
15
18
  import { M } from '../i18n.js';
16
19
 
17
20
  const { t } = useI18n();
@@ -46,26 +49,92 @@ const userEmail = $derived(profile?.email ?? user?.email);
46
49
 
47
50
  let open = $state(false);
48
51
  let triggerButton: HTMLButtonElement;
52
+ let itemEls = $state<Array<HTMLAnchorElement | null>>([]);
49
53
  const instanceId = $props.id();
50
54
  const menuId = `user-menu-${instanceId}`;
51
55
 
56
+ /** Focus a menu item by index, wrapping out-of-range values. */
57
+ function focusItem(index: number) {
58
+ const items = itemEls.filter((el): el is HTMLAnchorElement => el != null);
59
+ if (items.length === 0) return;
60
+ const wrapped = (index + items.length) % items.length;
61
+ items[wrapped]?.focus();
62
+ }
63
+
64
+ async function openMenu(focusLast = false) {
65
+ open = true;
66
+ await tick();
67
+ focusItem(focusLast ? -1 : 0);
68
+ }
69
+
52
70
  function toggle() {
53
- open = !open;
54
- if (!open && triggerButton) {
55
- triggerButton.focus();
71
+ if (open) {
72
+ close();
73
+ } else {
74
+ void openMenu();
56
75
  }
57
76
  }
58
77
 
59
- function close() {
78
+ function close(refocusTrigger = true) {
60
79
  if (open) {
61
80
  open = false;
62
- // Return focus to trigger button
63
- triggerButton?.focus();
81
+ if (refocusTrigger) {
82
+ // Return focus to trigger button
83
+ triggerButton?.focus();
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Roving-focus navigation inside the open menu. */
89
+ function handleMenuKeydown(event: KeyboardEvent) {
90
+ const items = itemEls.filter((el): el is HTMLAnchorElement => el != null);
91
+ // `document.activeElement` is `Element | null`; the cast satisfies indexOf's
92
+ // arg type (reference comparison is null-safe — a null active element yields
93
+ // -1). Kept as indexOf (not findIndex) so the biome formatter can't rewrite it.
94
+ const current = items.indexOf(document.activeElement as HTMLAnchorElement);
95
+
96
+ switch (event.key) {
97
+ case 'ArrowDown':
98
+ event.preventDefault();
99
+ focusItem(current + 1);
100
+ break;
101
+ case 'ArrowUp':
102
+ event.preventDefault();
103
+ focusItem(current - 1);
104
+ break;
105
+ case 'Home':
106
+ event.preventDefault();
107
+ focusItem(0);
108
+ break;
109
+ case 'End':
110
+ event.preventDefault();
111
+ focusItem(-1);
112
+ break;
113
+ case 'Escape':
114
+ event.preventDefault();
115
+ close();
116
+ break;
117
+ case 'Tab':
118
+ // Let focus leave naturally but collapse the menu.
119
+ close(false);
120
+ break;
121
+ }
122
+ }
123
+
124
+ /** Open the menu from the trigger via keyboard (ArrowDown/Up/Enter/Space). */
125
+ function handleTriggerKeydown(event: KeyboardEvent) {
126
+ if (open) return;
127
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
128
+ event.preventDefault();
129
+ void openMenu();
130
+ } else if (event.key === 'ArrowUp') {
131
+ event.preventDefault();
132
+ void openMenu(true);
64
133
  }
65
134
  }
66
135
 
67
136
  /**
68
- * Handle keyboard navigation
137
+ * Handle window-level Escape so it works even if focus has left the menu.
69
138
  */
70
139
  function handleKeydown(event: KeyboardEvent) {
71
140
  if (event.key === 'Escape' && open) {
@@ -81,7 +150,7 @@ function handleClickOutside(event: MouseEvent) {
81
150
  const target = event.target as Node;
82
151
  const menu = document.getElementById(menuId);
83
152
  if (menu && !menu.contains(target) && !triggerButton.contains(target)) {
84
- close();
153
+ close(false);
85
154
  }
86
155
  }
87
156
 
@@ -107,6 +176,7 @@ function getInitials(name: string): string {
107
176
  id="{menuId}-trigger"
108
177
  class="user-menu-trigger"
109
178
  onclick={toggle}
179
+ onkeydown={handleTriggerKeydown}
110
180
  type="button"
111
181
  use:ripple
112
182
  aria-haspopup="menu"
@@ -128,8 +198,10 @@ function getInitials(name: string): string {
128
198
  id="{menuId}-dropdown"
129
199
  class="dropdown"
130
200
  role="menu"
201
+ tabindex={-1}
131
202
  aria-orientation="vertical"
132
203
  aria-labelledby="{menuId}-trigger"
204
+ onkeydown={handleMenuKeydown}
133
205
  >
134
206
  {#if userEmail}
135
207
  <div class="user-info" role="none">
@@ -138,11 +210,12 @@ function getInitials(name: string): string {
138
210
  </div>
139
211
  <hr class="divider" />
140
212
  {/if}
141
-
142
- <a
143
- href={profileUrl}
144
- class="dropdown-item"
145
- onclick={close}
213
+
214
+ <a
215
+ bind:this={itemEls[0]}
216
+ href={profileUrl}
217
+ class="dropdown-item"
218
+ onclick={() => close(false)}
146
219
  use:ripple
147
220
  role="menuitem"
148
221
  tabindex="-1"
@@ -152,10 +225,11 @@ function getInitials(name: string): string {
152
225
  </svg>
153
226
  Profile
154
227
  </a>
155
- <a
156
- href={settingsUrl}
157
- class="dropdown-item"
158
- onclick={close}
228
+ <a
229
+ bind:this={itemEls[1]}
230
+ href={settingsUrl}
231
+ class="dropdown-item"
232
+ onclick={() => close(false)}
159
233
  use:ripple
160
234
  role="menuitem"
161
235
  tabindex="-1"
@@ -166,10 +240,11 @@ function getInitials(name: string): string {
166
240
  Settings
167
241
  </a>
168
242
  <hr class="divider" />
169
- <a
170
- href={signoutUrl}
171
- class="dropdown-item danger"
172
- onclick={close}
243
+ <a
244
+ bind:this={itemEls[2]}
245
+ href={signoutUrl}
246
+ class="dropdown-item danger"
247
+ onclick={() => close(false)}
173
248
  use:ripple
174
249
  role="menuitem"
175
250
  tabindex="-1"
@@ -2,11 +2,12 @@
2
2
  * UserMenu - User profile menu dropdown
3
3
  * refactored for Material 3
4
4
  *
5
- * Accessibility:
5
+ * Accessibility (WAI-ARIA menu-button pattern):
6
6
  * - Proper ARIA attributes for menu state
7
- * - Keyboard navigation (Escape to close)
8
- * - Click outside to close
9
- * - Focus management
7
+ * - Roving tabindex over the menu items
8
+ * - Arrow Up/Down, Home/End to move between items; Escape closes + refocuses
9
+ * the trigger; Tab closes; click-outside dismisses
10
+ * - Focus management (first item focused on open, trigger refocused on close)
10
11
  */
11
12
  import type { Profile } from '@happyvertical/smrt-profiles';
12
13
  /** Props for the UserMenu component */
@@ -1 +1 @@
1
- {"version":3,"file":"UserMenu.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserMenu.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAM5D,uCAAuC;AACvC,MAAM,WAAW,KAAK;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,uBAAuB;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+HD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserMenu.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserMenu.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAO5D,uCAAuC;AACvC,MAAM,WAAW,KAAK;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,uBAAuB;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAmMD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -51,4 +51,15 @@ describe('InviteUserModal', () => {
51
51
  await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
52
52
  expect(onclose).toHaveBeenCalledTimes(1);
53
53
  });
54
+ // Regression for #1399 blocker #2: the hand-rolled dialog had no focus
55
+ // trap/restore. Adopting the smrt-ui Modal renders a native <dialog> (top
56
+ // layer + trap + inert), which supplies the trap and Escape handling the
57
+ // previous markup lacked.
58
+ it('renders inside a native <dialog> for the focus trap', () => {
59
+ const { container } = render(InviteUserModal, { props: baseProps() });
60
+ const dialog = container.querySelector('dialog');
61
+ expect(dialog).not.toBeNull();
62
+ // The form lives inside the dialog, so focus is trapped within it.
63
+ expect(dialog?.querySelector('form')).not.toBeNull();
64
+ });
54
65
  });