From 893fdc1669f1a0648bc2bc3fd3b50b6d8e7b0122 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Wed, 1 Apr 2026 11:26:54 +0300 Subject: [PATCH] auth and role based layout setup --- src/components/layout/AppHeader.tsx | 66 ++++++--- src/components/ui/button.tsx | 21 ++- src/components/ui/spinner.tsx | 22 +++ src/context/AuthContext.tsx | 80 +++++++---- src/lib/api.ts | 123 +++++++++++++--- src/lib/auth-api.ts | 144 +++++++++++++++++++ src/pages/LoginPage.tsx | 205 +++++++++++++++++++++++---- src/store/authStore.ts | 209 ++++++++++++++++++++++++++++ 8 files changed, 768 insertions(+), 102 deletions(-) create mode 100644 src/components/ui/spinner.tsx create mode 100644 src/lib/auth-api.ts create mode 100644 src/store/authStore.ts diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index 0e54ddd..0d53b39 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,7 +1,6 @@ -import { format } from "date-fns"; -import { LogOut, Sparkles } from "lucide-react"; +import { LogOut } from "lucide-react"; import { Link } from "react-router-dom"; - +import { format } from "date-fns"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -20,35 +19,58 @@ import { SelectValue, } from "@/components/ui/select"; import { useAuth } from "@/context/AuthContext"; +import { useAuthStore } from "@/store/authStore"; export function AppHeader() { - const { name, property, setProperty, logout, role } = useAuth(); + const { name, selectedPropertyId, logout, role } = useAuth(); + const properties = useAuthStore((s) => s.properties); + const setSelectedPropertyId = useAuthStore((s) => s.setSelectedPropertyId); return (
- -

- {format(new Date(), "MMMM d, yyyy")} -

+ {role === "ADMIN" ? ( + <>

+ {format(new Date(), "MMMM d, yyyy")} +

+ ) : ( + <>
+ + {useAuthStore + .getState() + .properties.find((p) => p.id === selectedPropertyId)?.name || + "No property"} + +
+

+ {format(new Date(), "MMMM d, yyyy")} +

+ + )}
- + */} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 6770418..5d662d5 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -2,6 +2,8 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; +import { Loader2 } from "lucide-react"; + import { cn } from "@/lib/utils"; const buttonVariants = cva( @@ -36,18 +38,29 @@ const buttonVariants = cva( export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean; -} + asChild?: boolean; + loading?: boolean; + } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( + > + {asChild ? ( + children + ) : ( + <> + {loading && } + {children} + + )} + ); } ); diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..44468d3 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,22 @@ +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SpinnerProps extends React.HTMLAttributes { + size?: number; +} + +export function Spinner({ size = 16, className, ...props }: SpinnerProps) { + return ( +
+ + Loading... +
+ ); +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index cb8460e..9e277a9 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,61 +1,87 @@ import { createContext, - useCallback, useContext, + useEffect, useMemo, - useState, type ReactNode, } from "react"; import type { AdminRole } from "@/lib/types"; +import { useAuthStore } from "@/store/authStore"; -interface AuthState { +interface AuthContextValue { role: AdminRole | null; name: string; - property: string; -} - -interface AuthContextValue extends AuthState { - setRole: (r: AdminRole) => void; - setProperty: (p: string) => void; + selectedPropertyId: string | null; + selectedPropertyName: string; + setSelectedPropertyId: (id: string) => void; logout: () => void; canManageCodes: boolean; canRefund: boolean; canEditBookings: boolean; + accessToken: string | null; + bootstrapped: boolean; + hasHotelProperty: boolean; } const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [role, setRoleState] = useState(null); - const [name] = useState("Sophia Mitchell"); - const [property, setProperty] = useState("Shitaye Suite Hotel"); + const accessToken = useAuthStore((s) => s.accessToken); + const user = useAuthStore((s) => s.user); + const adminRole = useAuthStore((s) => s.adminRole); + const properties = useAuthStore((s) => s.properties); + const selectedPropertyId = useAuthStore((s) => s.selectedPropertyId); + const bootstrapped = useAuthStore((s) => s.bootstrapped); + const setSelectedPropertyId = useAuthStore((s) => s.setSelectedPropertyId); + const logout = useAuthStore((s) => s.logout); - const setRole = useCallback((r: AdminRole) => setRoleState(r), []); + useEffect(() => { + const finish = () => { + void useAuthStore.getState().bootstrap(); + }; + const unsub = useAuthStore.persist.onFinishHydration(finish); + if (useAuthStore.persist.hasHydrated()) finish(); + return unsub; + }, []); - const logout = useCallback(() => setRoleState(null), []); + const selectedPropertyName = useMemo(() => { + if (!selectedPropertyId) return "No property"; + return properties.find((p) => p.id === selectedPropertyId)?.name ?? "Property"; + }, [properties, selectedPropertyId]); + + const role = adminRole; const value = useMemo( () => ({ role, - name, - property, - setRole, - setProperty, + name: user?.name ?? "", + selectedPropertyId, + selectedPropertyName, + setSelectedPropertyId, logout, - canManageCodes: role === "finance" || role === "superadmin", - canRefund: role === "finance" || role === "superadmin", + canManageCodes: role === "finance" || role === "ADMIN", + canRefund: role === "finance" || role === "ADMIN", canEditBookings: - role === "front_desk" || - role === "finance" || - role === "superadmin", + role === "front_desk" || role === "finance" || role === "ADMIN", + accessToken, + bootstrapped, + hasHotelProperty: properties.length > 0, }), - [role, name, property, setRole, setProperty, logout] + [ + role, + user?.name, + selectedPropertyId, + selectedPropertyName, + setSelectedPropertyId, + logout, + accessToken, + bootstrapped, + properties.length, + ] ); - return ( - {children} - ); + return {children}; } export function useAuth() { diff --git a/src/lib/api.ts b/src/lib/api.ts index 497a372..78e20df 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,33 +1,110 @@ -export async function apiGet(path: string): Promise { - const res = await fetch(`/api${path}`); - if (!res.ok) throw new Error(await res.text()); - return res.json() as Promise; +import { parseApiError } from '@/lib/auth-api'; + +const API_PREFIX = '/api'; + +function getBaseUrl(): string { + return import.meta.env.VITE_API_BASE_URL ?? ''; } -export async function apiPost(path: string, body: unknown): Promise { - const res = await fetch(`/api${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const t = await res.text(); - throw new Error(t); +let getToken: () => string | null = () => null; +let getPropertyId: () => string | null = () => null; + +/** Register token + property scope for hotel API paths (call once from the auth store module). */ +export function registerHotelApiContext(ctx: { + getToken: () => string | null; + getPropertyId: () => string | null; +}) { + getToken = ctx.getToken; + getPropertyId = ctx.getPropertyId; +} + +/** Paths that stay unscoped (mock-only or non-hotel). */ +function shouldRewriteForHotel(path: string): boolean { + const first = path.split('?')[0]; + if (first.startsWith('/auth')) return false; + if (first.startsWith('/properties')) return false; + return true; +} + +function rewriteHotelPath(path: string): string { + const pid = getPropertyId(); + if (!pid || !shouldRewriteForHotel(path)) return path; + + const [pathname, query] = path.split('?'); + const q = query ? `?${query}` : ''; + + if (pathname === '/dashboard') { + return `/properties/${pid}/hotel/dashboard/summary${q}`; } - return res.json() as Promise; + + return `/properties/${pid}/hotel${pathname}${q}`; } -export async function apiPatch(path: string, body: unknown): Promise { - const res = await fetch(`/api${path}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), +async function request( + method: string, + path: string, + body?: unknown, + init?: RequestInit, +): Promise { + const token = getToken(); + const resolved = rewriteHotelPath(path); + const headers: Record = { + ...(init?.headers as Record), + }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers.Authorization = `Bearer ${token}`; + + const url = `${getBaseUrl()}${API_PREFIX}${resolved}`; + return fetch(url, { + ...init, + method, + headers, + body: body !== undefined ? JSON.stringify(body) : init?.body, }); - if (!res.ok) throw new Error(await res.text()); +} + +export async function apiGet(path: string, init?: RequestInit): Promise { + const res = await request('GET', path, undefined, init); + if (!res.ok) throw new Error(await parseApiError(res)); return res.json() as Promise; } -export async function apiDelete(path: string): Promise { - const res = await fetch(`/api${path}`, { method: "DELETE" }); - if (!res.ok) throw new Error(await res.text()); +export async function apiPost(path: string, body: unknown, init?: RequestInit): Promise { + const res = await request('POST', path, body, init); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function apiPatch(path: string, body: unknown, init?: RequestInit): Promise { + const res = await request('PATCH', path, body, init); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function apiDelete(path: string, init?: RequestInit): Promise { + const res = await request('DELETE', path, undefined, init); + if (!res.ok) throw new Error(await parseApiError(res)); +} + +/** Authenticated binary download (e.g. CSV export). */ +export async function apiDownloadBlob( + path: string, + init?: RequestInit, +): Promise<{ blob: Blob; filename: string | undefined }> { + const token = getToken(); + const resolved = rewriteHotelPath(path); + const headers: Record = { ...(init?.headers as Record) }; + if (token) headers.Authorization = `Bearer ${token}`; + + const url = `${getBaseUrl()}${API_PREFIX}${resolved}`; + const res = await fetch(url, { ...init, method: 'GET', headers }); + if (!res.ok) throw new Error(await parseApiError(res)); + + const cd = res.headers.get('Content-Disposition'); + let filename: string | undefined; + const m = cd?.match(/filename="?([^";]+)"?/); + if (m) filename = m[1]; + + const blob = await res.blob(); + return { blob, filename }; } diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts new file mode 100644 index 0000000..adb3909 --- /dev/null +++ b/src/lib/auth-api.ts @@ -0,0 +1,144 @@ +import type { RegisterHotelStaffDto, StaffAccess } from "@/lib/types"; + +const API_ROOT = "/api"; + +let getPropertyId: () => string | null = () => null; + +/** Register property scope for hotel auth-API paths (e.g. staff management). */ +export function registerHotelAuthApiContext(ctx: { + getPropertyId: () => string | null; +}) { + getPropertyId = ctx.getPropertyId; +} + +function shouldRewriteForHotel(path: string): boolean { + const first = path.split("?")[0]; + if (first.startsWith("/auth")) return false; + if (first.startsWith("/properties")) return false; + return true; +} + +function rewriteHotelPath(path: string): string { + const pid = getPropertyId(); + if (!pid || !shouldRewriteForHotel(path)) return path; + + const [pathname, query] = path.split("?"); + const q = query ? `?${query}` : ""; + return `/properties/${pid}/hotel${pathname}${q}`; +} + +function apiUrl(path: string): string { + const base = import.meta.env.VITE_API_BASE_URL ?? ""; + const resolved = rewriteHotelPath(path); + return `${base}${API_ROOT}${resolved}`; +} + +export async function parseApiError(res: Response): Promise { + const t = await res.text(); + try { + const j = JSON.parse(t) as { message?: string | string[]; error?: string }; + if (Array.isArray(j.message)) return j.message.join(", "); + if (typeof j.message === "string") return j.message; + if (j.error) return j.error; + } catch { + /* ignore */ + } + return t || res.statusText; +} + +export type AuthUser = { + id: string; + name: string; + email?: string | null; + phone?: string | null; + role: string; + status?: string; + propertyId?: string | null; +}; + +export async function postLogin(identifier: string, password: string) { + const res = await fetch(apiUrl("/auth/login"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier, password }), + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise<{ access_token: string; user: AuthUser }>; +} + +export async function postLoginPhoneRequest(phone: string) { + const res = await fetch(apiUrl("/auth/login-phone-request"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone }), + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise<{ + message: string; + loginRequestToken?: string; + }>; +} + +export async function postLoginPhoneVerify(loginRequestToken: string, otp: string) { + const res = await fetch(apiUrl("/auth/login-phone-verify"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ loginRequestToken, otp }), + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise<{ access_token: string; user: AuthUser }>; +} + +export type PropertyRow = { + id: string; + name: string; + accommodationMode?: string; +}; + +export async function getProfile(token: string) { + const res = await fetch(apiUrl("/auth/profile"), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise>; +} + +export async function getProperties(token: string) { + const res = await fetch(apiUrl("/properties/hotels"), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function getStaffAccess(token: string) { + const res = await fetch(apiUrl("/staff-access"), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await parseApiError(res)); + const data = await res.json(); + return data.data || data as StaffAccess[]; +} + + +export async function postStaff(token: string, dto: RegisterHotelStaffDto) { + const res = await fetch(apiUrl("/staff"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(dto), + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function deleteStaffAccess(token: string, accessId: string) { + const res = await fetch(apiUrl(`/staff-access/${accessId}`), { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await parseApiError(res)); + return; +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index b610e95..c3bdb1b 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -8,45 +9,197 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { useAuth } from "@/context/AuthContext"; -import type { AdminRole } from "@/lib/types"; - -const roles: { id: AdminRole; label: string }[] = [ - { id: "viewer", label: "Viewer (read-only)" }, - { id: "front_desk", label: "Front desk" }, - { id: "finance", label: "Finance" }, - { id: "superadmin", label: "Super admin" }, -]; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { normalizeInternationalPhone, useAuthStore } from "@/store/authStore"; export function LoginPage() { - const { setRole } = useAuth(); const navigate = useNavigate(); + const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword); + const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp); + const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp); - function pick(role: AdminRole) { - setRole(role); - navigate("/dashboard", { replace: true }); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [phone, setPhone] = useState(""); + const [otp, setOtp] = useState(""); + const [loginRequestToken, setLoginRequestToken] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [phoneHint, setPhoneHint] = useState(null); + + async function onEmailLogin(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await loginWithEmailPassword(email, password); + navigate("/dashboard", { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign-in failed"); + } finally { + setLoading(false); + } + } + + async function onRequestOtp(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setPhoneHint(null); + setLoginRequestToken(null); + setLoading(true); + try { + const normalized = normalizeInternationalPhone(phone); + const res = await requestPhoneOtp(normalized); + setPhoneHint(res.message); + if (res.loginRequestToken) setLoginRequestToken(res.loginRequestToken); + else + setError( + "No verification code was issued. Check the number or contact your administrator." + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not send code"); + } finally { + setLoading(false); + } + } + + async function onVerifyOtp(e: React.FormEvent) { + e.preventDefault(); + if (!loginRequestToken) { + setError("Request a code first."); + return; + } + setError(null); + setLoading(true); + try { + await verifyPhoneOtp(loginRequestToken, otp); + navigate("/dashboard", { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid code"); + } finally { + setLoading(false); + } } return (
- Yaltopia Hotels Admin + Yaltopia Hotels - Mock sign-in — choose a role to explore RBAC (no password). + Sign in with the account your administrator created for this property. - - {roles.map((r) => ( - - ))} + + + + + Email + + + Phone + + + + +
+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + className="rounded-xl" + /> +
+
+ + setPassword(e.target.value)} + className="rounded-xl" + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+ + +
+
+ + setPhone(e.target.value)} + placeholder="+251911234567" + className="rounded-xl" + /> +

+ Use international format with country code (e.g. +251…). +

+
+ {phoneHint ? ( +

{phoneHint}

+ ) : null} + {error && !loginRequestToken ? ( +

+ {error} +

+ ) : null} + +
+ + {loginRequestToken ? ( +
+
+ + setOtp(e.target.value)} + placeholder="6-digit code" + className="rounded-xl" + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ) : null} +
+
diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..6cfd329 --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,209 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import { + getProperties, + getProfile, + postLogin, + postLoginPhoneRequest, + postLoginPhoneVerify, + registerHotelAuthApiContext, + type AuthUser, + type PropertyRow, +} from "@/lib/auth-api"; +import { registerHotelApiContext } from "@/lib/api"; +import type { AdminRole } from "@/lib/types"; + +/** Normalize to E.164-style `+` + digits so it matches DB values stored as full international (e.g. +251…). */ +export function normalizeInternationalPhone(raw: string): string { + const trimmed = raw.trim().replace(/\s+/g, ""); + if (!trimmed) return trimmed; + const digits = trimmed.replace(/\D/g, ""); + if (!digits) return trimmed; + return `+${digits}`; +} + +export function mapBackendRoleToAdminRole(role: string): AdminRole { + switch (role) { + case "HOTEL_VIEWER": + return "viewer"; + case "HOTEL_FRONT_DESK": + return "front_desk"; + case "HOTEL_FINANCE": + return "finance"; + case "ADMIN": + case "SUPER_ADMIN": + return "ADMIN"; + case "PROJECT_MANAGER": + return "viewer"; + default: + return "viewer"; + } +} + +function hotelEligibleProperties(rows: PropertyRow[]): PropertyRow[] { + return rows.filter( + (p) => p.accommodationMode === "HOTEL" || p.accommodationMode === "MIXED" + ); +} + +type AuthState = { + accessToken: string | null; + user: AuthUser | null; + adminRole: AdminRole | null; + properties: PropertyRow[]; + selectedPropertyId: string | null; + bootstrapped: boolean; + bootstrapError: string | null; +}; + +type AuthActions = { + setSession: (access_token: string, user: AuthUser) => Promise; + loginWithEmailPassword: (email: string, password: string) => Promise; + requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>; + verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise; + bootstrap: () => Promise; + setSelectedPropertyId: (id: string) => void; + logout: () => void; +}; + +export const useAuthStore = create()( + persist( + (set, get) => ({ + accessToken: null, + user: null, + adminRole: null, + properties: [], + selectedPropertyId: null, + bootstrapped: false, + bootstrapError: null, + + setSession: async (access_token, user) => { + const adminRole = mapBackendRoleToAdminRole(user.role); + set({ + accessToken: access_token, + user, + adminRole, + bootstrapError: null, + }); + const props = await getProperties(access_token); + const hotelProps = hotelEligibleProperties(props); + set({ properties: hotelProps }); + const cur = get().selectedPropertyId; + const nextId = + user.propertyId || + (cur && hotelProps.some((p) => p.id === cur) + ? cur + : hotelProps[0]?.id ?? null); + set({ selectedPropertyId: nextId, bootstrapped: true }); + }, + + loginWithEmailPassword: async (email, password) => { + const { access_token, user } = await postLogin(email.trim(), password); + await get().setSession(access_token, user); + }, + + requestPhoneOtp: async (phone) => { + const normalized = normalizeInternationalPhone(phone); + return postLoginPhoneRequest(normalized); + }, + + verifyPhoneOtp: async (loginRequestToken, otp) => { + const { access_token, user } = await postLoginPhoneVerify( + loginRequestToken, + otp.trim() + ); + await get().setSession(access_token, user); + }, + + bootstrap: async () => { + const token = get().accessToken; + if (!token) { + set({ bootstrapped: true, properties: [], selectedPropertyId: null }); + return; + } + try { + const profile = await getProfile(token); + const adminRole = mapBackendRoleToAdminRole(profile.role); + const props = await getProperties(token); + const hotelProps = hotelEligibleProperties(props); + const cur = get().selectedPropertyId; + const nextId = + profile.propertyId || + (cur && hotelProps.some((p) => p.id === cur) + ? cur + : hotelProps[0]?.id ?? null); + set({ + user: { + id: profile.id, + name: profile.name, + email: profile.email, + phone: profile.phone, + role: profile.role, + status: profile.status, + propertyId: profile.propertyId as string | undefined, + }, + adminRole, + properties: hotelProps, + selectedPropertyId: nextId, + bootstrapError: null, + bootstrapped: true, + }); + } catch { + set({ + accessToken: null, + user: null, + adminRole: null, + properties: [], + selectedPropertyId: null, + bootstrapError: "Session expired. Please sign in again.", + bootstrapped: true, + }); + } + }, + + setSelectedPropertyId: (id) => set({ selectedPropertyId: id }), + + logout: () => + set({ + accessToken: null, + user: null, + adminRole: null, + properties: [], + selectedPropertyId: null, + bootstrapError: null, + bootstrapped: true, + }), + }), + { + name: "yaltopia-hotels-auth", + partialize: (s) => ({ + accessToken: s.accessToken, + selectedPropertyId: s.selectedPropertyId, + }), + } + ) +); + +registerHotelApiContext({ + getToken: () => useAuthStore.getState().accessToken, + getPropertyId: () => { + const state = useAuthStore.getState(); + if (!state.bootstrapped && state.accessToken) return null; + if (state.adminRole === "ADMIN") { + return state.selectedPropertyId; + } + return state.user?.propertyId || state.selectedPropertyId || null; + }, +}); + +registerHotelAuthApiContext({ + getPropertyId: () => { + const state = useAuthStore.getState(); + if (!state.bootstrapped && state.accessToken) return null; + if (state.adminRole === "ADMIN") { + return state.selectedPropertyId; + } + return state.user?.propertyId || state.selectedPropertyId || null; + }, +});