Files
service-finder/frontend/src/stores/auth.ts

761 lines
24 KiB
TypeScript

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<string, any> | null
ice_contact: Record<string, any> | 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<string, boolean>
// RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
org_capabilities?: Record<string, Record<string, boolean>>
}
export const useAuthStore = defineStore('auth', () => {
// ────────────────────────────── State ──────────────────────────────
const user = ref<UserProfile | null>(null)
const token = ref<string | null>(localStorage.getItem('access_token'))
const isLoading = ref(false)
const error = ref<string | null>(null)
const myOrganizations = ref<OrganizationItem[]>([])
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<string, boolean> {
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<string, string> = {
'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<string, any> = { 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<string, any>): Promise<boolean> {
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<string, any>): Promise<boolean> {
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<string, string> = {}
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<void> {
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,
}
})