import { defineStore } from 'pinia' import { ref, computed } from 'vue' import router from '../router' import api from '../api/axios' import type { OrganizationItem } from '../types/organization' /** * Backend now sends address fields directly on the person object * with address_ prefix (e.g. address_city, address_zip, address_floor). * This interface is kept for backward compatibility but the actual * data comes as flat fields on PersonData. */ export interface AddressData { zip: string | null city: string | null street_name: string | null street_type: string | null house_number: string | null stairwell: string | null floor: string | null door: string | null parcel_id: string | null full_address_text: string | null } export interface PersonData { id: number first_name: string | null last_name: string | null phone: string | null mothers_last_name: string | null mothers_first_name: string | null birth_place: string | null birth_date: string | null identity_docs: Record | null ice_contact: Record | null is_active: boolean /** @deprecated Backend now sends address_* fields flat on person */ address: AddressData | null // Flat address fields (new backend format) address_zip: string | null address_city: string | null address_street_name: string | null address_street_type: string | null address_house_number: string | null address_stairwell: string | null address_floor: string | null address_door: string | null address_hrsz: string | null address_parcel_id: string | null address_full_text: string | null } export interface UserProfile { id: number email: string first_name: string | null last_name: string | null is_active: boolean role: string region_code: string subscription_plan: string scope_level: string scope_id: string | null ui_mode: string active_organization_id: number | null preferred_language: string // person_id is set when KYC is complete (Person record exists) person_id: number | null // Nested person data with address person: PersonData | null // RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból) system_capabilities?: Record // RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool) org_capabilities?: Record> } export const useAuthStore = defineStore('auth', () => { // ────────────────────────────── State ────────────────────────────── const user = ref(null) const token = ref(localStorage.getItem('access_token')) const isLoading = ref(false) const error = ref(null) const myOrganizations = ref([]) const isInitialized = ref(false) // ────────────────────────────── Getters ──────────────────────────── const isAuthenticated = computed(() => !!token.value && !!user.value) const isKycComplete = computed(() => { // KYC is complete when the user has a linked Person record (person_id is set) return !!user.value?.person_id }) const userRole = computed(() => user.value?.role ?? 'guest') /** * Ellenőrzi, hogy a felhasználó rendelkezik-e admin jogosultsággal. * A SUPERADMIN és ADMIN (és egyéb adminisztratív) szerepköröket ismeri fel. */ const isAdmin = computed(() => { const role = user.value?.role if (!role) return false // CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR) // Must use toUpperCase() to ensure case-insensitive matching const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR'] return adminRoles.includes(role.toUpperCase()) }) const userName = computed(() => { if (!user.value) return '' const { first_name, last_name } = user.value if (first_name && last_name) return `${first_name} ${last_name}` return first_name || last_name || user.value.email }) const userId = computed(() => user.value?.id ?? null) // ── RBAC Phase 3: Capability Helpers ─────────────────────────────── /** * Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel. * A képességek a backend /auth/me vagy /users/me végpontról érkeznek * a SYSTEM_CAPABILITIES_MATRIX alapján. * @param capability - A képesség neve (pl. 'can_manage_users') * @returns true, ha a képesség elérhető a user szerepköréhez */ function hasSystemCapability(capability: string): boolean { return user.value?.system_capabilities?.[capability] === true } /** * Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel. * A szervezeti képességek az OrgRole.permissions JSONB mezőből származnak. * @param orgId - A szervezet azonosítója (string vagy number) * @param capability - A képesség neve (pl. 'can_approve_expense') * @returns true, ha a képesség elérhető a user számára az adott szervezetben */ function hasOrgCapability(orgId: string | number, capability: string): boolean { const orgCaps = user.value?.org_capabilities?.[String(orgId)] if (!orgCaps) return false return orgCaps[capability] === true } /** * Visszaadja az összes szervezeti képességet egy adott szervezethez. * @param orgId - A szervezet azonosítója * @returns A képességek objektuma, vagy üres objektum, ha nincs */ function getOrgCapabilities(orgId: string | number): Record { return user.value?.org_capabilities?.[String(orgId)] || {} } // ────────────────────────────── Helpers ──────────────────────────── /** * Extract the raw error code from the backend response. * Backend returns codes like "[AUTH.INVALID_CREDENTIALS]" or plain strings. * Strips brackets and returns the clean code, or null if not a code. */ function extractErrorCode(err: any): string | null { const detail = err.response?.data?.detail || err.response?.data?.message || '' if (!detail) return null // Strip surrounding brackets: [AUTH.INVALID_CREDENTIALS] -> AUTH.INVALID_CREDENTIALS const match = detail.match(/^\[?([A-Z]+\.[A-Z_]+)\]?$/) return match ? match[1] : null } /** * Map a backend error code to a user-friendly i18n key path. * Returns the i18n key (e.g. 'auth.invalid_credentials') or null if unmapped. */ function mapErrorCodeToI18nKey(code: string): string | null { const map: Record = { 'AUTH.INVALID_CREDENTIALS': 'auth.invalid_credentials', 'AUTH.USER_INACTIVE': 'auth.user_inactive', 'AUTH.USER_NOT_FOUND': 'auth.invalid_credentials', 'AUTH.EMAIL_EXISTS': 'auth.email_exists', 'AUTH.INVALID_TOKEN': 'auth.invalid_token', 'AUTH.TOKEN_EXPIRED': 'auth.token_expired', 'AUTH.RATE_LIMIT': 'auth.rate_limit', } return map[code] || null } /** * Get a user-friendly error message from an API error response. * Returns an i18n key path (e.g. 'auth.invalid_credentials') if the error * matches a known code, or the raw detail string as fallback. */ function getErrorMessage(err: any, fallback: string): string { const code = extractErrorCode(err) if (code) { const i18nKey = mapErrorCodeToI18nKey(code) if (i18nKey) return i18nKey } // Fallback to raw detail or message return err.response?.data?.detail || err.response?.data?.message || fallback } // ────────────────────────────── Actions ──────────────────────────── /** * Login with email + password. * The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm, * which expects application/x-www-form-urlencoded with `username` and `password`. * Supports optional device_fingerprint for Device-Hub tracking. */ async function login(email: string, password: string, deviceFingerprint?: string) { isLoading.value = true error.value = null try { const body = new URLSearchParams() body.append('username', email) // FastAPI OAuth2 form uses `username` key body.append('password', password) if (deviceFingerprint) { body.append('device_fingerprint', deviceFingerprint) } const res = await api.post('/auth/login', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) const data = res.data as { access_token: string refresh_token?: string token_type: string is_active: boolean } // Persist token localStorage.setItem('access_token', data.access_token) if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token) } token.value = data.access_token // Immediately fetch the user profile await fetchUser() // Fetch organizations after successful login so the header can show them await fetchMyOrganizations() // Clear pending verification email after successful login localStorage.removeItem('pending_verification_email') // If KYC is not complete, redirect to KYC page if (!isKycComplete.value) { router.push('/complete-kyc') } } catch (err: any) { error.value = getErrorMessage(err, 'auth.loginFailed') throw err } finally { isLoading.value = false } } /** * Fetch the current user profile from /auth/me. */ async function fetchUser() { if (!token.value) return try { const res = await api.get('/auth/me') user.value = res.data as UserProfile } catch (err: any) { // If the token is invalid, clear everything if (err.response?.status === 401) { logout() } throw err } } /** * Register a new user. * The /auth/register endpoint expects JSON with UserLiteRegister schema. * Default hidden values (region_code, lang, timezone) are appended here. */ async function register(data: { email: string password: string first_name: string last_name: string }) { isLoading.value = true error.value = null try { const payload = { ...data, region_code: 'HU', lang: 'hu', timezone: 'Europe/Budapest', } const res = await api.post('/auth/register', payload) // Save email to localStorage for verification page auto-fill localStorage.setItem('pending_verification_email', data.email) return res.data as { status: string message: string user_id: number email: string } } catch (err: any) { error.value = getErrorMessage(err, 'auth.registerFailed') throw err } finally { isLoading.value = false } } /** * Resend the verification email. * POST /auth/resend-verification with { email }. */ async function resendVerification(email: string) { isLoading.value = true error.value = null try { const res = await api.post('/auth/resend-verification', { email }) return res.data as { status: string; message: string } } catch (err: any) { error.value = getErrorMessage(err, 'auth.resendFailed') throw err } finally { isLoading.value = false } } /** * Request a password reset email. */ async function forgotPassword(email: string) { isLoading.value = true error.value = null try { const res = await api.post('/auth/forgot-password', { email }) return res.data as { status: string; message: string } } catch (err: any) { const code = extractErrorCode(err) if (code === 'AUTH.USER_INACTIVE') { error.value = 'auth.user_inactive' } else { error.value = getErrorMessage(err, 'auth.forgotFailed') } throw err } finally { isLoading.value = false } } /** * Reset password using token from email. * POST /auth/reset-password with { email, token, new_password }. */ async function resetPassword(email: string, token: string, newPassword: string) { isLoading.value = true error.value = null try { const res = await api.post('/auth/reset-password', { email, token, new_password: newPassword, }) return res.data as { status: string; message: string } } catch (err: any) { error.value = getErrorMessage(err, 'auth.resetPasswordFailed') throw err } finally { isLoading.value = false } } /** * Logout — clear token, user state, and localStorage. */ function logout() { token.value = null user.value = null error.value = null localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') // Redirect to the landing page router.push('/') } /** * Initialize the store on app load — if a token exists, fetch the user. */ async function init() { if (token.value) { try { await fetchUser() // Load organizations so the header switcher has data immediately await fetchMyOrganizations() } catch { // Token invalid — already cleared by fetchUser → logout } } // Mark initialization as complete so the router guard can await it isInitialized.value = true } /** * Verify email account using the token from the activation link (Magic Link). * POST /auth/verify-email with { token, device_fingerprint }. * Now returns JWT tokens on success, automatically logging the user in. * Supports optional device_fingerprint for Device-Hub tracking. */ async function verifyAccount(verificationToken: string, deviceFingerprint?: string) { isLoading.value = true error.value = null try { const payload: Record = { token: verificationToken } if (deviceFingerprint) { payload.device_fingerprint = deviceFingerprint } const res = await api.post('/auth/verify-email', payload) const data = res.data as { access_token?: string refresh_token?: string token_type?: string is_active?: boolean status?: string message?: string } // Magic Link: Ha a backend JWT tokent küld vissza, automatikus bejelentkezés if (data.access_token) { // Persist token localStorage.setItem('access_token', data.access_token) if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token) } token.value = data.access_token // Immediately fetch the user profile await fetchUser() // Clear pending verification email after successful verification + login localStorage.removeItem('pending_verification_email') } return data } catch (err: any) { error.value = getErrorMessage(err, 'auth.verificationFailed') throw err } finally { isLoading.value = false } } /** * Complete KYC (Know Your Customer) — full profile submission. * POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile. * Returns true on success, throws on failure. */ async function completeKyc(kycData: Record): Promise { isLoading.value = true error.value = null try { await api.post('/auth/complete-kyc', kycData) // Refresh the user profile so isKycComplete getter updates await fetchUser() return true } catch (err: any) { error.value = getErrorMessage(err, 'auth.kycFailed') throw err } finally { isLoading.value = false } } /** * Update the user's Person record (profile data + address). * PUT /users/me/person with PersonUpdate schema. * On success, refreshes the local user state with the response. */ async function updatePerson(personData: Record): Promise { isLoading.value = true error.value = null try { const res = await api.put('/users/me/person', personData) // Update the local user state with the fresh response user.value = res.data as UserProfile return true } catch (err: any) { error.value = getErrorMessage(err, 'auth.kycFailed') throw err } finally { isLoading.value = false } } /** * Change the current user's password. * PUT /users/me/password with { current_password, new_password }. * On success, returns { status: 'ok', message: string }. */ async function changePassword(currentPassword: string, newPassword: string): Promise<{ status: string; message: string }> { isLoading.value = true error.value = null try { const res = await api.put('/users/me/password', { current_password: currentPassword, new_password: newPassword, }) return res.data as { status: string; message: string } } catch (err: any) { error.value = getErrorMessage(err, 'A jelszó módosítása sikertelen.') throw err } finally { isLoading.value = false } } /** * Delete the current user's account (soft delete). * DELETE /users/me with optional reason as query parameter. * On success, clears all local state and redirects to landing page. * If the backend returns is_last_admin=true, the response includes * a warning detail that the caller can display. */ async function deleteAccount(reason?: string): Promise<{ is_last_admin?: boolean }> { isLoading.value = true error.value = null try { const params: Record = {} if (reason) { params.reason = reason } const res = await api.delete('/users/me', { params }) // Check if backend returned is_last_admin warning const isLastAdmin = res.data?.is_last_admin === true // Clear all local state — same as logout but without API call token.value = null user.value = null myOrganizations.value = [] localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') router.push('/') return { is_last_admin: isLastAdmin } } catch (err: any) { // If the error response contains is_last_admin, pass it through if (err.response?.data?.is_last_admin) { error.value = err.response?.data?.detail || 'profile.deleteError' throw { ...err, is_last_admin: true } } error.value = err?.response?.data?.detail || 'profile.deleteError' throw err } finally { isLoading.value = false } } /** * Request account restoration code (Step 1 of Account Restore). * POST /auth/restore/request with { email }. * Sends a 6-digit OTP to the user's email. */ async function requestRestore(email: string): Promise<{ status: string; message: string }> { isLoading.value = true error.value = null try { const res = await api.post('/auth/restore/request', { email }) return res.data as { status: string; message: string } } catch (err: any) { error.value = getErrorMessage(err, 'auth.restoreError') throw err } finally { isLoading.value = false } } /** * Verify account restoration code and set new password (Step 2 of Account Restore). * POST /auth/restore/verify with { email, otp, new_password }. * On success, returns JWT tokens and auto-logs in the user. */ async function verifyRestore(email: string, otp: string, newPassword: string): Promise { isLoading.value = true error.value = null try { const res = await api.post('/auth/restore/verify', { email, otp, new_password: newPassword, }) const data = res.data as { access_token: string refresh_token?: string token_type: string } // Persist token localStorage.setItem('access_token', data.access_token) if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token) } token.value = data.access_token // Fetch user profile and organizations await fetchUser() await fetchMyOrganizations() } catch (err: any) { error.value = getErrorMessage(err, 'auth.restoreError') throw err } finally { isLoading.value = false } } /** * Clear any stored error message. */ function clearError() { error.value = null } // ──────────────────────── Organization Actions ────────────────────── /** * Fetch the current user's organizations from GET /api/v1/organizations/my. * Stores them in myOrganizations state. */ async function fetchMyOrganizations() { try { const res = await api.get('/organizations/my') myOrganizations.value = res.data as OrganizationItem[] return myOrganizations.value } catch (err: any) { console.error('Failed to fetch organizations:', err) myOrganizations.value = [] return [] } } /** * Get the user's first business organization (org_type === 'business' or similar). * Returns the organization item or null if none found. */ const businessOrganization = computed(() => { return myOrganizations.value.find( (org) => org.org_type === 'business' || org.org_type === 'fleet_owner' ) || null }) /** * Check if the user has any business organization. */ const hasBusinessOrganization = computed(() => { return !!businessOrganization.value }) /** * Check if the user is currently in corporate mode * (active_organization_id is set and matches a business org). */ const isCorporateMode = computed(() => { return !!user.value?.active_organization_id }) /** * Switch the user's active organization. * PATCH /api/v1/users/me/active-organization with { organization_id }. * On success, updates the token and user state, then navigates. */ async function switchOrganization(orgId: number | null) { isLoading.value = true error.value = null try { const res = await api.patch('/users/me/active-organization', { organization_id: orgId, }) const data = res.data as { user: UserProfile access_token: string token_type: string } // Update the stored token localStorage.setItem('access_token', data.access_token) token.value = data.access_token // Update the user state with the fresh profile user.value = data.user } catch (err: any) { error.value = err.response?.data?.detail || err.response?.data?.message || 'Failed to switch organization' throw err } finally { isLoading.value = false } } return { // State user, token, isLoading, error, myOrganizations, isInitialized, // Getters isAuthenticated, isAdmin, isKycComplete, userRole, userName, userId, businessOrganization, hasBusinessOrganization, isCorporateMode, // RBAC Phase 3: Capability Helpers hasSystemCapability, hasOrgCapability, getOrgCapabilities, // Actions login, fetchUser, register, resendVerification, verifyAccount, forgotPassword, resetPassword, completeKyc, updatePerson, changePassword, deleteAccount, requestRestore, verifyRestore, logout, init, clearError, fetchMyOrganizations, switchOrganization, } })