auth and role based layout setup

This commit is contained in:
brooktewabe 2026-04-01 11:26:54 +03:00
parent 4f11503167
commit 893fdc1669
8 changed files with 768 additions and 102 deletions

View File

@ -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 (
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b bg-card/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-card/80 lg:px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Select value={property} onValueChange={setProperty}>
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Shitaye Suite Hotel">
Shitaye Suite Hotel
</SelectItem>
<SelectItem value="Serenity Cove (demo)">
Serenity Cove (demo)
</SelectItem>
</SelectContent>
</Select>
<p className="hidden text-sm text-muted-foreground md:block">
{format(new Date(), "MMMM d, yyyy")}
</p>
{role === "ADMIN" ? (
<><Select
value={selectedPropertyId ?? undefined}
onValueChange={setSelectedPropertyId}
disabled={properties.length === 0}
>
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
<SelectValue
placeholder={properties.length === 0
? "No hotel property"
: "Select property"} />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select><p className="hidden text-sm text-muted-foreground md:block">
{format(new Date(), "MMMM d, yyyy")}
</p></>
) : (
<><div className="h-9 px-3 py-2 text-sm border rounded-md bg-muted/50 cursor-default select-none flex items-center gap-2">
<span>
{useAuthStore
.getState()
.properties.find((p) => p.id === selectedPropertyId)?.name ||
"No property"}
</span>
</div>
<p className="hidden text-sm text-muted-foreground md:block">
{format(new Date(), "MMMM d, yyyy")}
</p>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="hidden sm:inline-flex">
{/* <Button variant="outline" size="sm" className="hidden sm:inline-flex">
<Sparkles className="size-4" />
AI assistant
</Button>
</Button> */}
<Button size="sm" className="hidden sm:inline-flex" asChild>
<Link to="/bookings/new">+ New reservation</Link>
</Button>

View File

@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={loading || disabled}
{...props}
/>
>
{asChild ? (
children
) : (
<>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</>
)}
</Comp>
);
}
);

View File

@ -0,0 +1,22 @@
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: number;
}
export function Spinner({ size = 16, className, ...props }: SpinnerProps) {
return (
<div
role="status"
className={cn("flex items-center justify-center", className)}
{...props}
>
<Loader2
className="animate-spin text-muted-foreground"
size={size}
/>
<span className="sr-only">Loading...</span>
</div>
);
}

View File

@ -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<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [role, setRoleState] = useState<AdminRole | null>(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<AuthContextValue>(
() => ({
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 (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {

View File

@ -1,33 +1,110 @@
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`/api${path}`);
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<T>;
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<T>(path: string, body: unknown): Promise<T> {
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<T>;
return `/properties/${pid}/hotel${pathname}${q}`;
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
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<Response> {
const token = getToken();
const resolved = rewriteHotelPath(path);
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
};
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<T>(path: string, init?: RequestInit): Promise<T> {
const res = await request('GET', path, undefined, init);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<T>;
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(`/api${path}`, { method: "DELETE" });
if (!res.ok) throw new Error(await res.text());
export async function apiPost<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await request('POST', path, body, init);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<T>;
}
export async function apiPatch<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await request('PATCH', path, body, init);
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<T>;
}
export async function apiDelete(path: string, init?: RequestInit): Promise<void> {
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<string, string> = { ...(init?.headers as Record<string, string>) };
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 };
}

144
src/lib/auth-api.ts Normal file
View File

@ -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<string> {
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<AuthUser & Record<string, unknown>>;
}
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<PropertyRow[]>;
}
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<StaffAccess>;
}
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;
}

View File

@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [phoneHint, setPhoneHint] = useState<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
<Card className="w-full max-w-md rounded-2xl shadow-md">
<CardHeader>
<CardTitle className="text-2xl">Yaltopia Hotels Admin</CardTitle>
<CardTitle className="text-2xl">Yaltopia Hotels</CardTitle>
<CardDescription>
Mock sign-in choose a role to explore RBAC (no password).
Sign in with the account your administrator created for this property.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2">
{roles.map((r) => (
<Button
key={r.id}
variant="outline"
className="h-12 justify-start rounded-xl"
onClick={() => pick(r.id)}
>
{r.label}
</Button>
))}
<CardContent>
<Tabs defaultValue="email" className="w-full">
<TabsList className="grid w-full grid-cols-2 rounded-xl">
<TabsTrigger value="email" className="rounded-lg">
Email
</TabsTrigger>
<TabsTrigger value="phone" className="rounded-lg">
Phone
</TabsTrigger>
</TabsList>
<TabsContent value="email" className="mt-4 space-y-4">
<form onSubmit={onEmailLogin} className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="rounded-xl"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-xl"
/>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<Button type="submit" className="rounded-xl" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</Button>
</form>
</TabsContent>
<TabsContent value="phone" className="mt-4 space-y-4">
<form onSubmit={onRequestOtp} className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
autoComplete="tel"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+251911234567"
className="rounded-xl"
/>
<p className="text-xs text-muted-foreground">
Use international format with country code (e.g. +251).
</p>
</div>
{phoneHint ? (
<p className="text-sm text-muted-foreground">{phoneHint}</p>
) : null}
{error && !loginRequestToken ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<Button type="submit" variant="secondary" className="rounded-xl" disabled={loading}>
{loading ? "Sending…" : "Send code"}
</Button>
</form>
{loginRequestToken ? (
<form onSubmit={onVerifyOtp} className="grid gap-4 border-t pt-4">
<div className="space-y-2">
<Label htmlFor="otp">One-time code</Label>
<Input
id="otp"
inputMode="numeric"
autoComplete="one-time-code"
required
minLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value)}
placeholder="6-digit code"
className="rounded-xl"
/>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<Button type="submit" className="rounded-xl" disabled={loading}>
{loading ? "Verifying…" : "Verify and sign in"}
</Button>
</form>
) : null}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>

209
src/store/authStore.ts Normal file
View File

@ -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<void>;
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
bootstrap: () => Promise<void>;
setSelectedPropertyId: (id: string) => void;
logout: () => void;
};
export const useAuthStore = create<AuthState & AuthActions>()(
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;
},
});