import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { User, License } from '@/types' /** * Decode the permissions object from a JWT access token payload. * JWTs are base64url-encoded — the payload is not secret, just signed. * The backend JWT strategy embeds `permissions: role?.permissions || {}` * which is a JSONB object from the Role entity (e.g. { 'server.view': true }). * Returns an empty object if the token is missing or malformed. */ function decodeJwtPermissions(token: string): Record { try { const payloadB64 = token.split('.')[1] if (!payloadB64) return {} const json = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')) const payload = JSON.parse(json) return payload.permissions && typeof payload.permissions === 'object' ? payload.permissions : {} } catch { return {} } } export const useAuthStore = defineStore('auth', () => { const user = ref(null) const license = ref(null) const accessToken = ref(null) const refreshToken = ref(null) // Permissions decoded from the JWT payload — reflects the user's actual role permissions. // Stored separately so hasPermission() works after page reload (token is persisted). const permissions = ref>({}) const isAuthenticated = computed(() => !!accessToken.value) const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false) const hasLicense = computed(() => !!license.value) const isLicenseActive = computed(() => license.value?.status === 'active') function setAuth(data: { access_token: string; refresh_token: string; user: User }) { accessToken.value = data.access_token refreshToken.value = data.refresh_token user.value = data.user // Decode permissions from the JWT so hasPermission() reflects the real role, // not a hardcoded list. Custom roles work automatically this way. permissions.value = decodeJwtPermissions(data.access_token) } function setLicense(data: License) { license.value = data } function logout() { user.value = null license.value = null accessToken.value = null refreshToken.value = null permissions.value = {} } /** * Validate the persisted session against the API on app boot. Without this, * a stale/revoked token renders the full panel chrome and only collapses on * the first real API call. useApi's 401 path (refresh → retry → logout) * does the heavy lifting; any non-auth failure (network, 5xx) keeps the * session — never log users out because the API blipped. * Dynamic import avoids a static auth-store ↔ useApi module cycle. */ async function validateSession(): Promise { if (!accessToken.value) return try { const { useApi } = await import('@/composables/useApi') const me = await useApi().get>('/auth/me') if (user.value && me && typeof me === 'object') { user.value = { ...user.value, ...me } } } catch { // 401 → refresh → logout/redirect already handled inside useApi. } } function hasModule(moduleSlug: string): boolean { return license.value?.modules_enabled?.includes(moduleSlug) ?? false } function hasPermission(permission: string): boolean { // Super admin has all permissions if (isSuperAdmin.value) return true // Check the permissions decoded from the JWT payload. // The backend embeds role permissions as a JSONB object { 'resource.action': true }. // If permissions is empty (e.g. after a page reload before re-auth), fall back to // re-decoding from the persisted token so the user isn't locked out. const perms = Object.keys(permissions.value).length > 0 ? permissions.value : accessToken.value ? decodeJwtPermissions(accessToken.value) : {} return perms[permission] === true } return { user, license, accessToken, refreshToken, permissions, isAuthenticated, isSuperAdmin, hasLicense, isLicenseActive, setAuth, setLicense, logout, validateSession, hasModule, hasPermission, } }, { persist: { pick: ['accessToken', 'refreshToken', 'user', 'license'], }, })