1 line
30 KiB
JSON
1 line
30 KiB
JSON
{"file":"index-D2n0yf-2.js","mappings":"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","names":["_unref","_ssrRenderClass","_ssrInterpolate","_ssrRenderList","_ssrIncludeBooleanAttr"],"sources":["../../../../pages/permissions/index.vue"],"sourcesContent":["<template>\n <div>\n <!-- Page Header -->\n <div class=\"mb-8\">\n <h1 class=\"text-2xl font-bold text-white\">{{ $t('permissions.title') }}</h1>\n <p class=\"text-slate-400 mt-1\">{{ $t('permissions.subtitle') }}</p>\n </div>\n\n <!-- Loading State -->\n <div v-if=\"loading\" class=\"flex items-center justify-center py-20\">\n <svg class=\"animate-spin h-8 w-8 text-indigo-400\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\" />\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n </svg>\n <span class=\"ml-3 text-slate-400\">{{ $t('permissions.loading') }}</span>\n </div>\n\n <!-- Error State -->\n <div v-else-if=\"error\" class=\"bg-red-500/10 border border-red-500/20 rounded-xl p-6 mb-8\">\n <div class=\"flex items-center gap-3\">\n <svg class=\"w-6 h-6 text-red-400 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n </svg>\n <div>\n <h3 class=\"text-red-300 font-medium\">{{ $t('permissions.error_title') }}</h3>\n <p class=\"text-red-400/80 text-sm mt-1\">{{ error }}</p>\n </div>\n </div>\n <button @click=\"fetchAll\" class=\"mt-4 px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition\">\n {{ $t('permissions.retry') }}\n </button>\n </div>\n\n <!-- Main Content -->\n <template v-else>\n <!-- Save Notification -->\n <div v-if=\"saveNotification\" class=\"mb-6 px-4 py-3 rounded-lg text-sm font-medium\"\n :class=\"saveNotification.type === 'success'\n ? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'\n : 'bg-red-500/10 border border-red-500/20 text-red-300'\">\n {{ saveNotification.message }}\n </div>\n\n <!-- ── DUAL FILTERING: Domain (Row) Filter + Role (Column) Filter ── -->\n <div class=\"bg-slate-800/60 border border-slate-700 rounded-xl p-4 mb-6\">\n <div class=\"flex flex-col sm:flex-row sm:items-center gap-4\">\n <!-- Filter 1: Domain/Row Filter (Tab Group) -->\n <div class=\"flex-1\">\n <label class=\"block text-xs font-semibold uppercase tracking-wider text-slate-400 mb-2\">\n {{ $t('permissions.domain_filter') }}\n </label>\n <div class=\"flex flex-wrap gap-1.5\">\n <button\n v-for=\"domain in domainOptions\"\n :key=\"domain.value\"\n @click=\"activeDomain = domain.value\"\n class=\"px-3 py-1.5 text-xs font-medium rounded-lg transition\"\n :class=\"activeDomain === domain.value\n ? 'bg-indigo-600 text-white shadow-sm'\n : 'bg-slate-700/60 text-slate-400 hover:bg-slate-700 hover:text-white'\"\n >\n {{ domain.label }}\n </button>\n </div>\n </div>\n\n <!-- Filter 2: Role/Column Filter (Multi-select Pills) -->\n <div class=\"flex-1\">\n <label class=\"block text-xs font-semibold uppercase tracking-wider text-slate-400 mb-2\">\n {{ $t('permissions.role_filter') }}\n </label>\n <div class=\"flex flex-wrap gap-1.5\">\n <button\n v-for=\"role in roles\"\n :key=\"role.id\"\n @click=\"toggleRoleFilter(role.id)\"\n class=\"px-3 py-1.5 text-xs font-medium rounded-lg transition flex items-center gap-1.5\"\n :class=\"filteredRoleIds.has(role.id)\n ? role.rank >= 100\n ? 'bg-purple-600/30 text-purple-300 border border-purple-500/30'\n : 'bg-indigo-600/30 text-indigo-300 border border-indigo-500/30'\n : 'bg-slate-700/60 text-slate-500 hover:bg-slate-700 hover:text-slate-300 border border-transparent'\"\n >\n <!-- Check icon when selected -->\n <svg v-if=\"filteredRoleIds.has(role.id)\" class=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" d=\"M5 13l4 4L19 7\" />\n </svg>\n <span>{{ role.name }}</span>\n </button>\n </div>\n <!-- Quick toggle all -->\n <div class=\"mt-2 flex gap-2\">\n <button @click=\"selectAllRoles\" class=\"text-xs text-indigo-400 hover:text-indigo-300 transition\">\n {{ $t('permissions.select_all') }}\n </button>\n <button @click=\"deselectAllRoles\" class=\"text-xs text-slate-500 hover:text-slate-400 transition\">\n {{ $t('permissions.deselect_all') }}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <!-- ── STICKY SAVE BUTTON ── -->\n <div v-if=\"hasModifications\" class=\"sticky top-0 z-20 -mx-4 px-4 py-3 bg-slate-800/95 backdrop-blur-sm border-b border-slate-700 mb-4 flex items-center gap-3 rounded-t-xl\">\n <button @click=\"savePermissions\"\n class=\"px-6 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg shadow-lg shadow-emerald-600/20 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center gap-2\">\n <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4\" />\n </svg>\n {{ $t('permissions.save_changes') }}\n </button>\n <button @click=\"discardChanges\"\n class=\"px-4 py-2.5 bg-slate-700 hover:bg-slate-600 text-slate-300 font-medium rounded-lg transition\">\n {{ $t('permissions.discard') }}\n </button>\n <span class=\"text-sm text-slate-400 ml-auto\">\n {{ $t('permissions.modified_count', { count: modifiedRoles.size }) }}\n </span>\n </div>\n\n <!-- ── 2D PERMISSION MATRIX TABLE ── -->\n <div class=\"overflow-x-auto rounded-xl border border-slate-700 bg-slate-800/40\">\n <table class=\"w-full text-sm\">\n <!-- Table Header: Roles as Columns -->\n <thead>\n <tr class=\"border-b border-slate-700\">\n <!-- Top-left blank cell -->\n <th class=\"sticky left-0 z-10 bg-slate-800/95 min-w-[200px] p-3 text-left\">\n <div class=\"flex items-center gap-2\">\n <svg class=\"w-4 h-4 text-slate-500\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n </svg>\n <span class=\"text-xs font-semibold uppercase tracking-wider text-slate-400\">\n {{ $t('permissions.permission') }}\n </span>\n </div>\n </th>\n <!-- Role columns -->\n <th\n v-for=\"role in filteredRoles\"\n :key=\"role.id\"\n class=\"min-w-[120px] p-3 text-center border-l border-slate-700\"\n :class=\"role.rank >= 100 ? 'bg-purple-900/10' : ''\"\n >\n <div class=\"flex flex-col items-center gap-1\">\n <span\n class=\"text-xs font-semibold px-2 py-0.5 rounded-full\"\n :class=\"roleHeaderClass(role)\"\n >\n {{ role.name }}\n </span>\n <span class=\"text-[10px] text-slate-500\">Rank {{ role.rank }}</span>\n <span v-if=\"role.is_system\" class=\"text-[10px] text-indigo-400\">system</span>\n </div>\n </th>\n </tr>\n </thead>\n\n <!-- Table Body: Permissions as Rows -->\n <tbody>\n <!-- Domain group separator -->\n <template v-for=\"domain in filteredDomains\" :key=\"domain\">\n <!-- Domain header row -->\n <tr class=\"border-b border-slate-700/50\">\n <td\n colspan=\"1\"\n class=\"sticky left-0 z-10 bg-slate-800/95 px-3 py-2 text-xs font-bold uppercase tracking-wider\"\n :class=\"domainColorClass(domain)\"\n >\n <div class=\"flex items-center gap-2\">\n <span>{{ domain }}</span>\n <span class=\"text-[10px] text-slate-500 font-normal normal-case\">\n ({{ getDomainPermissions(domain).length }})\n </span>\n </div>\n </td>\n <!-- Fill remaining columns with the same background -->\n <td\n v-for=\"role in filteredRoles\"\n :key=\"'hdr-' + role.id\"\n class=\"border-l border-slate-700/50 bg-slate-800/60 px-3 py-2\"\n ></td>\n </tr>\n\n <!-- Permission rows for this domain -->\n <tr\n v-for=\"perm in getDomainPermissions(domain)\"\n :key=\"perm.id\"\n class=\"border-b border-slate-700/30 hover:bg-slate-700/20 transition\"\n >\n <!-- Permission label (sticky left column) -->\n <td class=\"sticky left-0 z-10 bg-slate-800/95 p-3 border-r border-slate-700/50\">\n <div class=\"flex flex-col min-w-0 max-w-[200px]\">\n <span class=\"text-sm text-slate-200 truncate font-medium\">{{ formatPermissionLabel(perm) }}</span>\n <span class=\"text-xs text-slate-500 truncate font-mono\">{{ perm.code }}</span>\n <span v-if=\"perm.description\" class=\"text-[11px] text-slate-500 truncate mt-0.5\">{{ perm.description }}</span>\n </div>\n </td>\n\n <!-- Checkbox cells for each role -->\n <td\n v-for=\"role in filteredRoles\"\n :key=\"'cell-' + role.id + '-' + perm.id\"\n class=\"p-3 text-center border-l border-slate-700/30\"\n :class=\"getCellClass(role, perm)\"\n >\n <label\n class=\"relative inline-flex items-center justify-center cursor-pointer\"\n :class=\"{ 'cursor-not-allowed opacity-40': isRoleLocked(role) }\"\n >\n <input\n type=\"checkbox\"\n :checked=\"isPermEnabled(role.id, perm.id)\"\n class=\"sr-only peer\"\n :disabled=\"isRoleLocked(role)\"\n @change=\"handleToggle(role.id, perm.id, $event)\"\n />\n <div\n class=\"w-9 h-5 rounded-full peer peer-focus:outline-none peer-focus:ring-2 transition-all after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all\"\n :class=\"isRoleLocked(role)\n ? 'bg-slate-600'\n : isPermEnabled(role.id, perm.id)\n ? 'bg-emerald-600 peer-focus:ring-emerald-500/50 peer-checked:after:translate-x-full peer-checked:after:border-white'\n : 'bg-slate-600 peer-focus:ring-indigo-500/50 peer-checked:after:translate-x-full peer-checked:after:border-white peer-checked:bg-indigo-600'\"\n ></div>\n </label>\n </td>\n </tr>\n </template>\n\n <!-- Empty state -->\n <tr v-if=\"filteredPermissions.length === 0\">\n <td :colspan=\"filteredRoles.length + 1\" class=\"p-8 text-center text-slate-500 text-sm\">\n {{ $t('permissions.no_permissions') }}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Bottom Save Button -->\n <div v-if=\"hasModifications\" class=\"mt-6 flex items-center gap-3 pb-8\">\n <button @click=\"savePermissions\"\n class=\"px-6 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg shadow-lg shadow-emerald-600/20 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center gap-2\">\n <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4\" />\n </svg>\n {{ $t('permissions.save_changes') }}\n </button>\n <button @click=\"discardChanges\"\n class=\"px-4 py-2.5 bg-slate-700 hover:bg-slate-600 text-slate-300 font-medium rounded-lg transition\">\n {{ $t('permissions.discard') }}\n </button>\n <span class=\"text-sm text-slate-400\">\n {{ $t('permissions.modified_count', { count: modifiedRoles.size }) }}\n </span>\n </div>\n </template>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nconst { t: $t } = useI18n()\n\nimport { ref, computed, onMounted } from 'vue'\n\ndefinePageMeta({\n middleware: 'auth',\n})\n\n// ── Types ──────────────────────────────────────────────────────────\n\ninterface SystemRole {\n id: number\n name: string\n description: string | null\n rank: number\n is_system: boolean\n is_active: boolean\n permission_ids: number[]\n}\n\ninterface SystemPermission {\n id: number\n code: string\n domain: string\n action: string\n description: string | null\n is_system: boolean\n}\n\ninterface DomainOption {\n value: string\n label: string\n}\n\n// ── State ──────────────────────────────────────────────────────────\n\nconst loading = ref(true)\nconst error = ref<string | null>(null)\n\nconst roles = ref<SystemRole[]>([])\nconst permissions = ref<SystemPermission[]>([])\n\n// ── Filter State ───────────────────────────────────────────────────\n\nconst activeDomain = ref<string>('__all__')\nconst filteredRoleIds = ref<Set<number>>(new Set())\n\n// ── Editing State ──────────────────────────────────────────────────\n\nconst modifiedRoles = ref<Map<number, Set<number>>>(new Map())\nconst originalPermissions = ref<Map<number, number[]>>(new Map())\nconst saveNotification = ref<{ type: 'success' | 'error'; message: string } | null>(null)\n\n// ── Computed: Domain Options ───────────────────────────────────────\n\nconst domainOptions = computed<DomainOption[]>(() => {\n const domains = new Set(permissions.value.map(p => p.domain))\n const options: DomainOption[] = [\n { value: '__all__', label: $t('permissions.all_domains') },\n ]\n for (const domain of Array.from(domains).sort()) {\n options.push({ value: domain, label: domain.charAt(0).toUpperCase() + domain.slice(1) })\n }\n return options\n})\n\n// ── Computed: Filtered Domains (for table section headers) ─────────\n\nconst filteredDomains = computed<string[]>(() => {\n const domains = new Set(permissions.value.map(p => p.domain))\n const sorted = Array.from(domains).sort()\n if (activeDomain.value === '__all__') return sorted\n return sorted.filter(d => d === activeDomain.value)\n})\n\n// ── Computed: Filtered Permissions ─────────────────────────────────\n\nconst filteredPermissions = computed<SystemPermission[]>(() => {\n if (activeDomain.value === '__all__') return permissions.value\n return permissions.value.filter(p => p.domain === activeDomain.value)\n})\n\n// ── Computed: Filtered Roles ───────────────────────────────────────\n\nconst filteredRoles = computed<SystemRole[]>(() => {\n if (filteredRoleIds.value.size === 0) return roles.value\n return roles.value.filter(r => filteredRoleIds.value.has(r.id))\n})\n\n// ── Computed: Has Modifications ────────────────────────────────────\n\nconst hasModifications = computed(() => {\n return modifiedRoles.value.size > 0\n})\n\n// ── Methods: Filter Helpers ────────────────────────────────────────\n\nfunction toggleRoleFilter(roleId: number) {\n const newSet = new Set(filteredRoleIds.value)\n if (newSet.has(roleId)) {\n newSet.delete(roleId)\n } else {\n newSet.add(roleId)\n }\n filteredRoleIds.value = newSet\n}\n\nfunction selectAllRoles() {\n filteredRoleIds.value = new Set(roles.value.map(r => r.id))\n}\n\nfunction deselectAllRoles() {\n filteredRoleIds.value = new Set()\n}\n\n// ── Methods: UI Helpers ────────────────────────────────────────────\n\nfunction roleHeaderClass(role: SystemRole): string {\n if (role.rank >= 100) return 'bg-purple-500/20 text-purple-300'\n if (role.rank >= 80) return 'bg-indigo-500/20 text-indigo-300'\n if (role.rank >= 50) return 'bg-cyan-500/20 text-cyan-300'\n return 'bg-slate-500/20 text-slate-300'\n}\n\nfunction domainColorClass(domain: string): string {\n const colorMap: Record<string, string> = {\n fleet: 'text-emerald-400',\n user: 'text-sky-400',\n finance: 'text-amber-400',\n permissions: 'text-purple-400',\n system: 'text-rose-400',\n service: 'text-teal-400',\n organization: 'text-orange-400',\n vehicle: 'text-cyan-400',\n subscription: 'text-pink-400',\n moderation: 'text-violet-400',\n }\n return colorMap[domain] || 'text-slate-400'\n}\n\nfunction getCellClass(role: SystemRole, perm: SystemPermission): string {\n if (isRoleLocked(role)) return 'bg-purple-900/5'\n if (isPermEnabled(role.id, perm.id)) return 'bg-emerald-900/10'\n return ''\n}\n\nfunction formatPermissionLabel(perm: SystemPermission): string {\n return perm.code\n .replace(/:/g, ' ')\n .replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\nfunction getDomainPermissions(domain: string): SystemPermission[] {\n return filteredPermissions.value.filter(p => p.domain === domain)\n}\n\n// ── Methods: Permission Toggle Logic ───────────────────────────────\n\nfunction isPermEnabled(roleId: number, permissionId: number): boolean {\n if (modifiedRoles.value.has(roleId)) {\n const rolePerms = modifiedRoles.value.get(roleId)!\n return rolePerms.has(permissionId)\n }\n const role = roles.value.find(r => r.id === roleId)\n return role ? role.permission_ids.includes(permissionId) : false\n}\n\nfunction isRoleLocked(role: SystemRole): boolean {\n return role.is_system && role.rank >= 100\n}\n\nfunction handleToggle(roleId: number, permissionId: number, event: Event) {\n const target = event.target as HTMLInputElement\n const newValue = target.checked\n\n if (!originalPermissions.value.has(roleId)) {\n const role = roles.value.find(r => r.id === roleId)\n if (role) {\n originalPermissions.value.set(roleId, [...role.permission_ids])\n }\n }\n\n let rolePerms = modifiedRoles.value.get(roleId)\n if (!rolePerms) {\n const original = originalPermissions.value.get(roleId) || []\n rolePerms = new Set(original)\n } else {\n rolePerms = new Set(rolePerms)\n }\n\n if (newValue) {\n rolePerms.add(permissionId)\n } else {\n rolePerms.delete(permissionId)\n }\n\n const original = originalPermissions.value.get(roleId) || []\n const originalSet = new Set(original)\n\n if (setsEqual(rolePerms, originalSet)) {\n modifiedRoles.value.delete(roleId)\n originalPermissions.value.delete(roleId)\n } else {\n modifiedRoles.value.set(roleId, rolePerms)\n }\n\n modifiedRoles.value = new Map(modifiedRoles.value)\n}\n\nfunction setsEqual(a: Set<number>, b: Set<number>): boolean {\n if (a.size !== b.size) return false\n for (const item of a) {\n if (!b.has(item)) return false\n }\n return true\n}\n\nfunction clearSaveNotification() {\n setTimeout(() => {\n saveNotification.value = null\n }, 5000)\n}\n\n// ── Methods: Save / Discard ────────────────────────────────────────\n\nasync function savePermissions() {\n saveNotification.value = null\n\n try {\n const tokenCookie = useCookie('access_token')\n const token = tokenCookie.value\n if (!token) {\n saveNotification.value = { type: 'error', message: 'Not authenticated' }\n clearSaveNotification()\n return\n }\n\n if (modifiedRoles.value.size === 0) {\n saveNotification.value = { type: 'error', message: 'No changes to save' }\n clearSaveNotification()\n return\n }\n\n const updates: { roleId: number; permissionIds: number[] }[] = []\n modifiedRoles.value.forEach((permSet, roleId) => {\n updates.push({\n roleId,\n permissionIds: Array.from(permSet).sort((a, b) => a - b),\n })\n })\n\n const results = await Promise.allSettled(\n updates.map(update =>\n $fetch(`/api/v1/admin/permissions/roles/${update.roleId}/permissions`, {\n method: 'PUT',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n body: {\n permission_ids: update.permissionIds,\n },\n })\n )\n )\n\n const failures = results.filter(r => r.status === 'rejected')\n if (failures.length > 0) {\n const errors = failures.map(f => {\n const reason = (f as PromiseRejectedResult).reason\n return reason?.data?.detail || reason?.message || 'Unknown error'\n })\n saveNotification.value = {\n type: 'error',\n message: `Failed to save ${failures.length} role(s): ${errors.join('; ')}`,\n }\n clearSaveNotification()\n return\n }\n\n for (const update of updates) {\n const role = roles.value.find(r => r.id === update.roleId)\n if (role) {\n role.permission_ids = update.permissionIds\n }\n }\n\n modifiedRoles.value = new Map()\n originalPermissions.value = new Map()\n saveNotification.value = {\n type: 'success',\n message: `Successfully saved permissions for ${updates.length} role(s).`,\n }\n clearSaveNotification()\n } catch (err: any) {\n console.error('[Permissions] Save failed:', err)\n saveNotification.value = {\n type: 'error',\n message: err?.data?.detail || err?.message || 'Failed to save permissions',\n }\n clearSaveNotification()\n }\n}\n\nfunction discardChanges() {\n modifiedRoles.value = new Map()\n originalPermissions.value = new Map()\n saveNotification.value = null\n}\n\n// ── Data Fetching ──────────────────────────────────────────────────\n\nasync function fetchRoles() {\n try {\n const tokenCookie = useCookie('access_token')\n const token = tokenCookie.value\n if (!token) {\n console.warn('[Permissions] No access token found for roles')\n return\n }\n\n const data = await $fetch<SystemRole[]>('/api/v1/admin/permissions/roles', {\n headers: { Authorization: `Bearer ${token}` },\n })\n roles.value = data\n } catch (err: any) {\n console.error('[Permissions] Roles fetch failed:', err?.message || err)\n throw err\n }\n}\n\nasync function fetchPermissions() {\n try {\n const tokenCookie = useCookie('access_token')\n const token = tokenCookie.value\n if (!token) {\n console.warn('[Permissions] No access token found for permissions')\n return\n }\n\n const data = await $fetch<SystemPermission[]>('/api/v1/admin/permissions', {\n headers: { Authorization: `Bearer ${token}` },\n })\n permissions.value = data\n } catch (err: any) {\n console.error('[Permissions] Permissions fetch failed:', err?.message || err)\n throw err\n }\n}\n\nasync function fetchAll() {\n loading.value = true\n error.value = null\n try {\n await Promise.all([fetchRoles(), fetchPermissions()])\n // Initialize: show all roles by default\n filteredRoleIds.value = new Set(roles.value.map(r => r.id))\n } catch (err: any) {\n console.error('[Permissions] fetchAll failed:', err?.message || err)\n error.value = err?.data?.detail || err?.message || 'Failed to load permissions data'\n } finally {\n loading.value = false\n }\n}\n\nonMounted(() => {\n fetchAll()\n})\n</script>\n"],"version":3} |