hazo_auth 5.1.10 → 5.1.12

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.
@@ -10,6 +10,7 @@ import type {
10
10
  HazoAuthUser,
11
11
  HazoAuthOptions,
12
12
  ScopeAccessInfo,
13
+ ScopeDetails,
13
14
  } from "./auth_types";
14
15
  import { PermissionError, ScopeAccessError } from "./auth_types.js";
15
16
  import { get_auth_cache } from "./auth_cache.js";
@@ -72,11 +73,6 @@ function get_client_ip(request: NextRequest): string {
72
73
  return "unknown";
73
74
  }
74
75
 
75
- /**
76
- * Fetches user data and permissions from database
77
- * @param user_id - User ID
78
- * @returns Object with user, permissions, and role_ids
79
- */
80
76
  /**
81
77
  * CRUD service options for hazo_user_scopes table
82
78
  * This table uses a composite primary key (user_id, scope_id) and no 'id' column
@@ -86,10 +82,63 @@ const USER_SCOPES_CRUD_OPTIONS = {
86
82
  autoId: false as const,
87
83
  };
88
84
 
85
+ /**
86
+ * Fetches full scope details for user's scope assignments
87
+ * Joins hazo_user_scopes with hazo_scopes to get name, slug, branding
88
+ * @param user_id - User ID
89
+ * @returns Array of scope details with branding information
90
+ */
91
+ async function fetch_user_scope_details(user_id: string): Promise<ScopeDetails[]> {
92
+ const hazoConnect = get_hazo_connect_instance();
93
+ const user_scopes_service = createCrudService(
94
+ hazoConnect,
95
+ "hazo_user_scopes",
96
+ USER_SCOPES_CRUD_OPTIONS,
97
+ );
98
+ const scopes_service = createCrudService(hazoConnect, "hazo_scopes");
99
+
100
+ const user_scope_assignments = await user_scopes_service.findBy({ user_id });
101
+ if (!Array.isArray(user_scope_assignments) || user_scope_assignments.length === 0) {
102
+ return [];
103
+ }
104
+
105
+ const scope_details: ScopeDetails[] = [];
106
+ for (const assignment of user_scope_assignments) {
107
+ const scope_id = assignment.scope_id as string;
108
+ const role_id = assignment.role_id as string;
109
+
110
+ const scopes = await scopes_service.findBy({ id: scope_id });
111
+ if (Array.isArray(scopes) && scopes.length > 0) {
112
+ const scope = scopes[0];
113
+ scope_details.push({
114
+ id: scope_id,
115
+ name: scope.name as string,
116
+ slug: (scope.slug as string) || null,
117
+ level: scope.level as string,
118
+ parent_id: scope.parent_id as string | null,
119
+ role_id,
120
+ logo_url: (scope.logo_url as string) || null,
121
+ primary_color: (scope.primary_color as string) || null,
122
+ secondary_color: (scope.secondary_color as string) || null,
123
+ tagline: (scope.tagline as string) || null,
124
+ });
125
+ }
126
+ }
127
+
128
+ return scope_details;
129
+ }
130
+
131
+ /**
132
+ * Fetches user data and permissions from database
133
+ * @param user_id - User ID
134
+ * @returns Object with user, permissions, role_ids, and scopes
135
+ */
136
+
89
137
  async function fetch_user_data_from_db(user_id: string): Promise<{
90
138
  user: HazoAuthUser;
91
139
  permissions: string[];
92
140
  role_ids: string[];
141
+ scopes: ScopeDetails[];
93
142
  }> {
94
143
  const hazoConnect = get_hazo_connect_instance();
95
144
  const users_service = createCrudService(hazoConnect, "hazo_users");
@@ -186,7 +235,10 @@ async function fetch_user_data_from_db(user_id: string): Promise<{
186
235
 
187
236
  const permissions = Array.from(permissions_set);
188
237
 
189
- return { user, permissions, role_ids };
238
+ // v5.2: Fetch full scope details for caching
239
+ const scopes = await fetch_user_scope_details(user_id);
240
+
241
+ return { user, permissions, role_ids, scopes };
190
242
  }
191
243
 
192
244
  /**
@@ -430,8 +482,8 @@ export async function hazo_get_auth(
430
482
  permissions = user_data.permissions;
431
483
  role_ids = user_data.role_ids;
432
484
 
433
- // Update cache
434
- cache.set(user_id, user, permissions, role_ids);
485
+ // Update cache (v5.2: includes scope details for tenant auth)
486
+ cache.set(user_id, user, permissions, role_ids, user_data.scopes);
435
487
  } catch (error) {
436
488
  const error_message =
437
489
  error instanceof Error ? error.message : "Unknown error";
@@ -0,0 +1,265 @@
1
+ // file_description: tenant-aware authentication function that extracts scope from request headers/cookies
2
+ // section: imports
3
+ import { NextRequest } from "next/server";
4
+ import { hazo_get_auth } from "./hazo_get_auth.server.js";
5
+ import { get_auth_cache } from "./auth_cache.js";
6
+ import { get_scope_by_id } from "../services/scope_service.js";
7
+ import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
8
+ import { get_cookie_name } from "../cookies_config.server.js";
9
+ import { get_auth_utility_config } from "../auth_utility_config.server.js";
10
+ import type {
11
+ TenantAuthOptions,
12
+ TenantAuthResult,
13
+ RequiredTenantAuthResult,
14
+ TenantOrganization,
15
+ ScopeDetails,
16
+ } from "./auth_types";
17
+ import {
18
+ AuthenticationRequiredError,
19
+ TenantRequiredError,
20
+ TenantAccessDeniedError,
21
+ } from "./auth_types.js";
22
+
23
+ // section: constants
24
+
25
+ /**
26
+ * Default header name for scope ID
27
+ */
28
+ const DEFAULT_SCOPE_HEADER = "X-Hazo-Scope-Id";
29
+
30
+ /**
31
+ * Base cookie name for scope ID (will have prefix applied)
32
+ */
33
+ const BASE_SCOPE_COOKIE = "hazo_auth_scope_id";
34
+
35
+ // section: helpers
36
+
37
+ /**
38
+ * Extracts scope ID from request headers or cookies
39
+ * Priority: Header > Cookie
40
+ * @param request - NextRequest object
41
+ * @param options - TenantAuthOptions for customization
42
+ * @returns Scope ID if found, undefined otherwise
43
+ */
44
+ export function extract_scope_id_from_request(
45
+ request: NextRequest,
46
+ options: TenantAuthOptions,
47
+ ): string | undefined {
48
+ // Check header first
49
+ const header_name = options.scope_header_name || DEFAULT_SCOPE_HEADER;
50
+ const header_value = request.headers.get(header_name);
51
+ if (header_value) {
52
+ return header_value;
53
+ }
54
+
55
+ // Check cookie (with prefix)
56
+ const cookie_name = options.scope_cookie_name || get_cookie_name(BASE_SCOPE_COOKIE);
57
+ const cookie_value = request.cookies.get(cookie_name)?.value;
58
+ return cookie_value;
59
+ }
60
+
61
+ /**
62
+ * Builds TenantOrganization from scope details and access info
63
+ * @param scope_details - Full scope details from cache
64
+ * @param is_super_admin - Whether user is accessing as super admin
65
+ * @returns TenantOrganization object
66
+ */
67
+ function build_tenant_organization(
68
+ scope_details: ScopeDetails,
69
+ is_super_admin: boolean,
70
+ ): TenantOrganization {
71
+ return {
72
+ id: scope_details.id,
73
+ name: scope_details.name,
74
+ slug: scope_details.slug,
75
+ level: scope_details.level,
76
+ role_id: scope_details.role_id,
77
+ is_super_admin,
78
+ branding:
79
+ scope_details.logo_url || scope_details.primary_color
80
+ ? {
81
+ logo_url: scope_details.logo_url,
82
+ primary_color: scope_details.primary_color,
83
+ secondary_color: scope_details.secondary_color,
84
+ tagline: scope_details.tagline,
85
+ }
86
+ : undefined,
87
+ };
88
+ }
89
+
90
+ // section: main_functions
91
+
92
+ /**
93
+ * Tenant-aware authentication function
94
+ *
95
+ * Extracts tenant/scope context from request headers or cookies,
96
+ * validates access, and returns enriched result with organization info.
97
+ *
98
+ * Header priority: X-Hazo-Scope-Id > Cookie
99
+ *
100
+ * @param request - NextRequest object
101
+ * @param options - TenantAuthOptions for customization
102
+ * @returns TenantAuthResult with user, permissions, organization, and user_scopes
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * const auth = await hazo_get_tenant_auth(request);
107
+ * if (auth.authenticated && auth.organization) {
108
+ * // Access tenant-specific data
109
+ * const data = await getData(auth.organization.id);
110
+ * }
111
+ * ```
112
+ */
113
+ export async function hazo_get_tenant_auth(
114
+ request: NextRequest,
115
+ options: TenantAuthOptions = {},
116
+ ): Promise<TenantAuthResult> {
117
+ // Extract scope_id from request
118
+ const scope_id = extract_scope_id_from_request(request, options);
119
+
120
+ // Build hazo_get_auth options, passing through scope_id if present
121
+ const auth_options = {
122
+ ...options,
123
+ scope_id: scope_id || options.scope_id, // Allow explicit override via options
124
+ };
125
+
126
+ // Call base hazo_get_auth
127
+ const auth_result = await hazo_get_auth(request, auth_options);
128
+
129
+ // Handle unauthenticated case
130
+ if (!auth_result.authenticated) {
131
+ return {
132
+ authenticated: false,
133
+ user: null,
134
+ permissions: [],
135
+ permission_ok: false,
136
+ organization: null,
137
+ user_scopes: [],
138
+ scope_ok: false,
139
+ };
140
+ }
141
+
142
+ // Get user's scopes from cache (already populated by hazo_get_auth)
143
+ const config = get_auth_utility_config();
144
+ const cache = get_auth_cache(
145
+ config.cache_max_users,
146
+ config.cache_ttl_minutes,
147
+ config.cache_max_age_minutes,
148
+ );
149
+ const cached = cache.get(auth_result.user.id);
150
+
151
+ // User scopes from cache or empty array
152
+ const user_scopes: ScopeDetails[] = cached?.scopes || [];
153
+
154
+ // Build organization info if scope access was successful
155
+ let organization: TenantOrganization | null = null;
156
+
157
+ if (scope_id && auth_result.scope_ok && auth_result.scope_access_via) {
158
+ // Find the scope in user's scopes that matches the access_via scope
159
+ const access_scope = user_scopes.find(
160
+ (s) => s.id === auth_result.scope_access_via?.scope_id,
161
+ );
162
+
163
+ if (access_scope) {
164
+ organization = build_tenant_organization(
165
+ access_scope,
166
+ auth_result.scope_access_via.is_super_admin || false,
167
+ );
168
+ } else if (auth_result.scope_access_via.is_super_admin) {
169
+ // Super admin accessing scope they're not assigned to - fetch scope details
170
+ const hazoConnect = get_hazo_connect_instance();
171
+ const scope_result = await get_scope_by_id(hazoConnect, scope_id);
172
+ if (scope_result.success && scope_result.scope) {
173
+ organization = {
174
+ id: scope_result.scope.id,
175
+ name: scope_result.scope.name,
176
+ slug: null, // Could fetch from scope if slug column exists
177
+ level: scope_result.scope.level,
178
+ role_id: "", // Super admin doesn't have a role in the scope
179
+ is_super_admin: true,
180
+ branding: scope_result.scope.logo_url
181
+ ? {
182
+ logo_url: scope_result.scope.logo_url,
183
+ primary_color: scope_result.scope.primary_color,
184
+ secondary_color: scope_result.scope.secondary_color,
185
+ tagline: scope_result.scope.tagline,
186
+ }
187
+ : undefined,
188
+ };
189
+ }
190
+ }
191
+ }
192
+
193
+ return {
194
+ authenticated: true,
195
+ user: auth_result.user,
196
+ permissions: auth_result.permissions,
197
+ permission_ok: auth_result.permission_ok,
198
+ missing_permissions: auth_result.missing_permissions,
199
+ organization,
200
+ user_scopes,
201
+ scope_ok: auth_result.scope_ok,
202
+ scope_access_via: auth_result.scope_access_via,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Strict tenant authentication helper
208
+ *
209
+ * Wraps hazo_get_tenant_auth and throws appropriate errors:
210
+ * - AuthenticationRequiredError (401) if not authenticated
211
+ * - TenantRequiredError (403) if no tenant context in request
212
+ * - TenantAccessDeniedError (403) if user lacks access to requested tenant
213
+ *
214
+ * @param request - NextRequest object
215
+ * @param options - TenantAuthOptions for customization
216
+ * @returns RequiredTenantAuthResult with guaranteed non-null organization
217
+ * @throws AuthenticationRequiredError, TenantRequiredError, TenantAccessDeniedError
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * try {
222
+ * const auth = await require_tenant_auth(request);
223
+ * // auth.organization is guaranteed non-null here
224
+ * const data = await getData(auth.organization.id);
225
+ * } catch (error) {
226
+ * if (error instanceof HazoAuthError) {
227
+ * return NextResponse.json(
228
+ * { error: error.message, code: error.code },
229
+ * { status: error.status_code }
230
+ * );
231
+ * }
232
+ * throw error;
233
+ * }
234
+ * ```
235
+ */
236
+ export async function require_tenant_auth(
237
+ request: NextRequest,
238
+ options: TenantAuthOptions = {},
239
+ ): Promise<RequiredTenantAuthResult> {
240
+ const result = await hazo_get_tenant_auth(request, options);
241
+
242
+ // Check authentication
243
+ if (!result.authenticated) {
244
+ throw new AuthenticationRequiredError();
245
+ }
246
+
247
+ // Extract scope_id from request for error messages
248
+ const scope_id = extract_scope_id_from_request(request, options);
249
+
250
+ // Check if scope was requested but access denied
251
+ if (scope_id && !result.scope_ok) {
252
+ throw new TenantAccessDeniedError(scope_id, result.user_scopes);
253
+ }
254
+
255
+ // Check if organization context is required but missing
256
+ if (!result.organization) {
257
+ throw new TenantRequiredError(
258
+ "No organization context provided. Include X-Hazo-Scope-Id header or scope cookie.",
259
+ result.user_scopes,
260
+ );
261
+ }
262
+
263
+ // Type assertion: at this point we know organization is non-null
264
+ return result as RequiredTenantAuthResult;
265
+ }
@@ -11,6 +11,26 @@ export {
11
11
  } from "./auth_utils.server.js";
12
12
  export type { AuthResult, AuthUser } from "./auth_utils.server";
13
13
 
14
+ // section: tenant_auth_exports
15
+ export {
16
+ hazo_get_tenant_auth,
17
+ require_tenant_auth,
18
+ extract_scope_id_from_request,
19
+ } from "./hazo_get_tenant_auth.server.js";
20
+ export type {
21
+ ScopeDetails,
22
+ TenantOrganization,
23
+ TenantAuthOptions,
24
+ TenantAuthResult,
25
+ RequiredTenantAuthResult,
26
+ } from "./auth_types";
27
+ export {
28
+ HazoAuthError,
29
+ AuthenticationRequiredError,
30
+ TenantRequiredError,
31
+ TenantAccessDeniedError,
32
+ } from "./auth_types.js";
33
+
14
34
  // section: client_exports
15
35
  export { get_server_auth_user } from "./server_auth.js";
16
36
  export type { ServerAuthResult } from "./server_auth";
@@ -25,6 +25,7 @@ export const BASE_COOKIE_NAMES = {
25
25
  USER_EMAIL: "hazo_auth_user_email",
26
26
  SESSION: "hazo_auth_session",
27
27
  DEV_LOCK: "hazo_auth_dev_lock",
28
+ SCOPE_ID: "hazo_auth_scope_id", // v5.2: Tenant context cookie for multi-tenancy
28
29
  } as const;
29
30
 
30
31
  // section: main_function
@@ -1 +1 @@
1
- {"version":3,"file":"user_scopes_tab.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/user_management/components/user_scopes_tab.tsx"],"names":[],"mappings":"AAqDA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA4DF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE,kBAAkB,2CAqf9D"}
1
+ {"version":3,"file":"user_scopes_tab.d.ts","sourceRoot":"","sources":["../../../../../src/components/layouts/user_management/components/user_scopes_tab.tsx"],"names":[],"mappings":"AA4DA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAkEF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE,kBAAkB,2CA0jB9D"}
@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
11
11
  import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "../../../ui/alert-dialog.js";
12
12
  import { Input } from "../../../ui/input.js";
13
13
  import { Label } from "../../../ui/label.js";
14
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../../ui/select.js";
14
15
  import { Avatar, AvatarFallback, AvatarImage } from "../../../ui/avatar.js";
15
16
  import { TreeView } from "../../../ui/tree-view.js";
16
17
  import { Loader2, Plus, Trash2, Search, CircleCheck, CircleX, ChevronRight, Building2, FolderTree, } from "lucide-react";
@@ -56,6 +57,10 @@ export function UserScopesTab({ className }) {
56
57
  const [treeLoading, setTreeLoading] = useState(false);
57
58
  const [selectedTreeItem, setSelectedTreeItem] = useState();
58
59
  const [actionLoading, setActionLoading] = useState(false);
60
+ // Roles state
61
+ const [roles, setRoles] = useState([]);
62
+ const [rolesLoading, setRolesLoading] = useState(false);
63
+ const [selectedRoleId, setSelectedRoleId] = useState("");
59
64
  // Delete scope dialog state
60
65
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
61
66
  const [scopeToDelete, setScopeToDelete] = useState(null);
@@ -136,11 +141,37 @@ export function UserScopesTab({ className }) {
136
141
  setTreeLoading(false);
137
142
  }
138
143
  }, [apiBasePath]);
144
+ // Load roles for add dialog
145
+ const loadRoles = useCallback(async () => {
146
+ var _a;
147
+ setRolesLoading(true);
148
+ try {
149
+ const response = await fetch(`${apiBasePath}/user_management/roles`);
150
+ const data = await response.json();
151
+ if (data.success) {
152
+ setRoles(data.roles || []);
153
+ // Auto-select first role if available and none selected
154
+ if (!selectedRoleId && ((_a = data.roles) === null || _a === void 0 ? void 0 : _a.length) > 0) {
155
+ setSelectedRoleId(data.roles[0].id);
156
+ }
157
+ }
158
+ else {
159
+ setRoles([]);
160
+ }
161
+ }
162
+ catch (error) {
163
+ setRoles([]);
164
+ }
165
+ finally {
166
+ setRolesLoading(false);
167
+ }
168
+ }, [apiBasePath, selectedRoleId]);
139
169
  useEffect(() => {
140
170
  if (addDialogOpen) {
141
171
  void loadScopeTree();
172
+ void loadRoles();
142
173
  }
143
- }, [addDialogOpen, loadScopeTree]);
174
+ }, [addDialogOpen, loadScopeTree, loadRoles]);
144
175
  // Filter users by search
145
176
  const filteredUsers = users.filter((user) => {
146
177
  var _a;
@@ -174,6 +205,10 @@ export function UserScopesTab({ className }) {
174
205
  toast.error("Please select a scope from the tree");
175
206
  return;
176
207
  }
208
+ if (!selectedRoleId) {
209
+ toast.error("Please select a role");
210
+ return;
211
+ }
177
212
  const scope = selectedTreeItem.scopeData;
178
213
  setActionLoading(true);
179
214
  try {
@@ -183,6 +218,7 @@ export function UserScopesTab({ className }) {
183
218
  body: JSON.stringify({
184
219
  user_id: selectedUser.id,
185
220
  scope_id: scope.id,
221
+ role_id: selectedRoleId,
186
222
  }),
187
223
  });
188
224
  const data = await response.json();
@@ -190,6 +226,7 @@ export function UserScopesTab({ className }) {
190
226
  toast.success("Scope assigned successfully");
191
227
  setAddDialogOpen(false);
192
228
  setSelectedTreeItem(undefined);
229
+ setSelectedRoleId("");
193
230
  await loadUserScopes();
194
231
  }
195
232
  else {
@@ -245,7 +282,7 @@ export function UserScopesTab({ className }) {
245
282
  }, variant: "outline", size: "sm", children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), "Assign First Scope"] })] })) : (_jsxs(Table, { className: "w-full", children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Scope Name" }), _jsx(TableHead, { children: "Level" }), _jsx(TableHead, { children: "Scope ID" }), _jsx(TableHead, { children: "Assigned" }), _jsx(TableHead, { className: "text-right w-[80px]", children: "Actions" })] }) }), _jsx(TableBody, { children: userScopes.map((scope) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-medium", children: scope.scope_name || "Unknown" }), _jsx(TableCell, { className: "text-sm", children: scope.level || "-" }), _jsxs(TableCell, { className: "font-mono text-xs text-muted-foreground", children: [scope.scope_id.substring(0, 8), "..."] }), _jsx(TableCell, { className: "text-sm text-muted-foreground", children: new Date(scope.created_at).toLocaleDateString() }), _jsx(TableCell, { className: "text-right", children: _jsx(Button, { onClick: () => {
246
283
  setScopeToDelete(scope);
247
284
  setDeleteDialogOpen(true);
248
- }, variant: "outline", size: "sm", className: "text-destructive", children: _jsx(Trash2, { className: "h-4 w-4" }) }) })] }, scope.scope_id))) })] })) })] }), _jsx(Dialog, { open: addDialogOpen, onOpenChange: setAddDialogOpen, children: _jsxs(DialogContent, { className: "cls_user_scopes_add_dialog sm:max-w-[500px]", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add Scope Assignment" }), _jsxs(DialogDescription, { children: ["Select a scope from the tree to assign to", " ", (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.name) || (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.email_address), "."] })] }), _jsxs("div", { className: "flex flex-col gap-4 py-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Select Scope" }), treeLoading ? (_jsx("div", { className: "flex items-center justify-center p-8 border rounded-lg", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-slate-400" }) })) : scopeTree.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center p-6 border rounded-lg border-dashed", children: [_jsx(FolderTree, { className: "h-8 w-8 text-muted-foreground mb-2" }), _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "No scopes available. Create scopes in the Scope Hierarchy tab first." })] })) : (_jsx("div", { className: "border rounded-lg max-h-[300px] overflow-auto", children: _jsx(TreeView, { data: treeData, expandAll: true, defaultNodeIcon: Building2, defaultLeafIcon: Building2, onSelectChange: handleTreeSelectChange, initialSelectedItemId: selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.id, className: "w-full" }) }))] }), (selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.scopeData) && (_jsxs("div", { className: "p-3 border rounded-lg bg-muted/50", children: [_jsxs("p", { className: "text-sm", children: [_jsx("span", { className: "font-medium", children: "Selected:" }), " ", selectedTreeItem.scopeData.name] }), _jsxs("p", { className: "text-xs text-muted-foreground", children: [selectedTreeItem.scopeData.level, " - ID: ", selectedTreeItem.scopeData.id.substring(0, 8), "..."] })] }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { onClick: handleAddScope, disabled: actionLoading || !(selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.scopeData), variant: "default", children: actionLoading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }), "Assigning..."] })) : (_jsxs(_Fragment, { children: [_jsx(CircleCheck, { className: "h-4 w-4 mr-2" }), "Assign Scope"] })) }), _jsxs(Button, { onClick: () => setAddDialogOpen(false), variant: "outline", children: [_jsx(CircleX, { className: "h-4 w-4 mr-2" }), "Cancel"] })] })] }) }), _jsx(AlertDialog, { open: deleteDialogOpen, onOpenChange: setDeleteDialogOpen, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Remove Scope Assignment" }), _jsxs(AlertDialogDescription, { children: ["Are you sure you want to remove the scope \"", (scopeToDelete === null || scopeToDelete === void 0 ? void 0 : scopeToDelete.scope_name) || (scopeToDelete === null || scopeToDelete === void 0 ? void 0 : scopeToDelete.scope_id.substring(0, 8)), "\" from", " ", (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.name) || (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.email_address), "? This will also revoke access to any child scopes."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogAction, { onClick: handleRemoveScope, disabled: actionLoading, children: actionLoading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }), "Removing..."] })) : ("Remove") }), _jsx(AlertDialogCancel, { onClick: () => {
285
+ }, variant: "outline", size: "sm", className: "text-destructive", children: _jsx(Trash2, { className: "h-4 w-4" }) }) })] }, scope.scope_id))) })] })) })] }), _jsx(Dialog, { open: addDialogOpen, onOpenChange: setAddDialogOpen, children: _jsxs(DialogContent, { className: "cls_user_scopes_add_dialog sm:max-w-[500px]", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add Scope Assignment" }), _jsxs(DialogDescription, { children: ["Select a scope from the tree to assign to", " ", (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.name) || (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.email_address), "."] })] }), _jsxs("div", { className: "flex flex-col gap-4 py-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Select Scope" }), treeLoading ? (_jsx("div", { className: "flex items-center justify-center p-8 border rounded-lg", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-slate-400" }) })) : scopeTree.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center p-6 border rounded-lg border-dashed", children: [_jsx(FolderTree, { className: "h-8 w-8 text-muted-foreground mb-2" }), _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "No scopes available. Create scopes in the Scope Hierarchy tab first." })] })) : (_jsx("div", { className: "border rounded-lg max-h-[300px] overflow-auto", children: _jsx(TreeView, { data: treeData, expandAll: true, defaultNodeIcon: Building2, defaultLeafIcon: Building2, onSelectChange: handleTreeSelectChange, initialSelectedItemId: selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.id, className: "w-full" }) }))] }), (selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.scopeData) && (_jsxs("div", { className: "p-3 border rounded-lg bg-muted/50", children: [_jsxs("p", { className: "text-sm", children: [_jsx("span", { className: "font-medium", children: "Selected:" }), " ", selectedTreeItem.scopeData.name] }), _jsxs("p", { className: "text-xs text-muted-foreground", children: [selectedTreeItem.scopeData.level, " - ID: ", selectedTreeItem.scopeData.id.substring(0, 8), "..."] })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Assign Role" }), rolesLoading ? (_jsxs("div", { className: "flex items-center gap-2 p-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "Loading roles..."] })) : roles.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No roles available. Create roles in the Roles tab first." })) : (_jsxs(Select, { value: selectedRoleId, onValueChange: setSelectedRoleId, children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: "Select a role" }) }), _jsx(SelectContent, { children: roles.map((role) => (_jsxs(SelectItem, { value: role.id, children: [role.name, role.description && (_jsxs("span", { className: "text-muted-foreground ml-2", children: ["- ", role.description] }))] }, role.id))) })] }))] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { onClick: handleAddScope, disabled: actionLoading || !(selectedTreeItem === null || selectedTreeItem === void 0 ? void 0 : selectedTreeItem.scopeData) || !selectedRoleId, variant: "default", children: actionLoading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }), "Assigning..."] })) : (_jsxs(_Fragment, { children: [_jsx(CircleCheck, { className: "h-4 w-4 mr-2" }), "Assign Scope"] })) }), _jsxs(Button, { onClick: () => setAddDialogOpen(false), variant: "outline", children: [_jsx(CircleX, { className: "h-4 w-4 mr-2" }), "Cancel"] })] })] }) }), _jsx(AlertDialog, { open: deleteDialogOpen, onOpenChange: setDeleteDialogOpen, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Remove Scope Assignment" }), _jsxs(AlertDialogDescription, { children: ["Are you sure you want to remove the scope \"", (scopeToDelete === null || scopeToDelete === void 0 ? void 0 : scopeToDelete.scope_name) || (scopeToDelete === null || scopeToDelete === void 0 ? void 0 : scopeToDelete.scope_id.substring(0, 8)), "\" from", " ", (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.name) || (selectedUser === null || selectedUser === void 0 ? void 0 : selectedUser.email_address), "? This will also revoke access to any child scopes."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogAction, { onClick: handleRemoveScope, disabled: actionLoading, children: actionLoading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 mr-2 animate-spin" }), "Removing..."] })) : ("Remove") }), _jsx(AlertDialogCancel, { onClick: () => {
249
286
  setDeleteDialogOpen(false);
250
287
  setScopeToDelete(null);
251
288
  }, children: "Cancel" })] })] }) })] }));
@@ -1,12 +1,14 @@
1
- import type { HazoAuthUser } from "./auth_types";
1
+ import type { HazoAuthUser, ScopeDetails } from "./auth_types";
2
2
  /**
3
3
  * Cache entry structure
4
4
  * v5.x: role_ids are now string UUIDs (from hazo_user_scopes)
5
+ * v5.2: Added scopes with full details for multi-tenancy support
5
6
  */
6
7
  type CacheEntry = {
7
8
  user: HazoAuthUser;
8
9
  permissions: string[];
9
10
  role_ids: string[];
11
+ scopes: ScopeDetails[];
10
12
  timestamp: number;
11
13
  cache_version: number;
12
14
  };
@@ -35,8 +37,9 @@ declare class AuthCache {
35
37
  * @param user - User data
36
38
  * @param permissions - User permissions
37
39
  * @param role_ids - User role IDs (v5.x: string UUIDs)
40
+ * @param scopes - User scope details with full information (v5.2+)
38
41
  */
39
- set(user_id: string, user: HazoAuthUser, permissions: string[], role_ids: string[]): void;
42
+ set(user_id: string, user: HazoAuthUser, permissions: string[], role_ids: string[], scopes?: ScopeDetails[]): void;
40
43
  /**
41
44
  * Invalidates cache for a specific user
42
45
  * @param user_id - User ID to invalidate
@@ -52,6 +55,12 @@ declare class AuthCache {
52
55
  * Invalidates all cache entries
53
56
  */
54
57
  invalidate_all(): void;
58
+ /**
59
+ * Invalidates cache entries for users who have access to specific scopes
60
+ * Used when scope details change (name, branding, etc.)
61
+ * @param scope_ids - Array of scope IDs to invalidate
62
+ */
63
+ invalidate_by_scope_ids(scope_ids: string[]): void;
55
64
  /**
56
65
  * Gets the maximum cache version for a set of roles
57
66
  * Used to determine if cache entry is stale
@@ -1 +1 @@
1
- {"version":3,"file":"auth_cache.d.ts","sourceRoot":"","sources":["../../../src/lib/auth/auth_cache.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAIjD;;;GAGG;AACH,KAAK,UAAU,GAAG;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,cAAM,SAAS;IACb,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,gBAAgB,CAAsB;gBAG5C,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM;IASzB;;;;;OAKG;IACH,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IA+B5C;;;;;;;OAOG;IACH,GAAG,CACD,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,EAClB,WAAW,EAAE,MAAM,EAAE,EACrB,QAAQ,EAAE,MAAM,EAAE,GACjB,IAAI;IAyBP;;;OAGG;IACH,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAItC;;;;OAIG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI;IAqB7C;;OAEG;IACH,cAAc,IAAI,IAAI;IAItB;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAc5B;;;OAGG;IACH,SAAS,IAAI;QACX,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB;CAMF;AAMD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,GAAE,MAAc,EACxB,WAAW,GAAE,MAAW,EACxB,eAAe,GAAE,MAAW,GAC3B,SAAS,CAKX;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
1
+ {"version":3,"file":"auth_cache.d.ts","sourceRoot":"","sources":["../../../src/lib/auth/auth_cache.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAI/D;;;;GAIG;AACH,KAAK,UAAU,GAAG;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,cAAM,SAAS;IACb,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,gBAAgB,CAAsB;gBAG5C,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM;IASzB;;;;;OAKG;IACH,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IA+B5C;;;;;;;;OAQG;IACH,GAAG,CACD,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,EAClB,WAAW,EAAE,MAAM,EAAE,EACrB,QAAQ,EAAE,MAAM,EAAE,EAClB,MAAM,GAAE,YAAY,EAAO,GAC1B,IAAI;IA0BP;;;OAGG;IACH,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAItC;;;;OAIG;IACH,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI;IAqB7C;;OAEG;IACH,cAAc,IAAI,IAAI;IAItB;;;;OAIG;IACH,uBAAuB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI;IAalD;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAc5B;;;OAGG;IACH,SAAS,IAAI;QACX,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB;CAMF;AAMD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,GAAE,MAAc,EACxB,WAAW,GAAE,MAAW,EACxB,eAAe,GAAE,MAAW,GAC3B,SAAS,CAKX;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
@@ -47,8 +47,9 @@ class AuthCache {
47
47
  * @param user - User data
48
48
  * @param permissions - User permissions
49
49
  * @param role_ids - User role IDs (v5.x: string UUIDs)
50
+ * @param scopes - User scope details with full information (v5.2+)
50
51
  */
51
- set(user_id, user, permissions, role_ids) {
52
+ set(user_id, user, permissions, role_ids, scopes = []) {
52
53
  // Evict LRU entries if cache is full
53
54
  while (this.cache.size >= this.max_size) {
54
55
  const first_key = this.cache.keys().next().value;
@@ -65,6 +66,7 @@ class AuthCache {
65
66
  user,
66
67
  permissions,
67
68
  role_ids,
69
+ scopes,
68
70
  timestamp: Date.now(),
69
71
  cache_version,
70
72
  };
@@ -106,6 +108,23 @@ class AuthCache {
106
108
  invalidate_all() {
107
109
  this.cache.clear();
108
110
  }
111
+ /**
112
+ * Invalidates cache entries for users who have access to specific scopes
113
+ * Used when scope details change (name, branding, etc.)
114
+ * @param scope_ids - Array of scope IDs to invalidate
115
+ */
116
+ invalidate_by_scope_ids(scope_ids) {
117
+ const entries_to_remove = [];
118
+ for (const [user_id, entry] of this.cache.entries()) {
119
+ const has_scope = entry.scopes.some((s) => scope_ids.includes(s.id));
120
+ if (has_scope) {
121
+ entries_to_remove.push(user_id);
122
+ }
123
+ }
124
+ for (const user_id of entries_to_remove) {
125
+ this.cache.delete(user_id);
126
+ }
127
+ }
109
128
  /**
110
129
  * Gets the maximum cache version for a set of roles
111
130
  * Used to determine if cache entry is stale