auth and role based layout setup
This commit is contained in:
parent
4f11503167
commit
893fdc1669
|
|
@ -1,7 +1,6 @@
|
||||||
import { format } from "date-fns";
|
import { LogOut } from "lucide-react";
|
||||||
import { LogOut, Sparkles } from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,35 +19,58 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function AppHeader() {
|
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 (
|
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">
|
<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">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<Select value={property} onValueChange={setProperty}>
|
{role === "ADMIN" ? (
|
||||||
|
<><Select
|
||||||
|
value={selectedPropertyId ?? undefined}
|
||||||
|
onValueChange={setSelectedPropertyId}
|
||||||
|
disabled={properties.length === 0}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
|
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
|
||||||
<SelectValue />
|
<SelectValue
|
||||||
|
placeholder={properties.length === 0
|
||||||
|
? "No hotel property"
|
||||||
|
: "Select property"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Shitaye Suite Hotel">
|
{properties.map((p) => (
|
||||||
Shitaye Suite Hotel
|
<SelectItem key={p.id} value={p.id}>
|
||||||
</SelectItem>
|
{p.name}
|
||||||
<SelectItem value="Serenity Cove (demo)">
|
|
||||||
Serenity Cove (demo)
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<p className="hidden text-sm text-muted-foreground md:block">
|
||||||
{format(new Date(), "MMMM d, yyyy")}
|
{format(new Date(), "MMMM d, yyyy")}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<Sparkles className="size-4" />
|
||||||
AI assistant
|
AI assistant
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button size="sm" className="hidden sm:inline-flex" asChild>
|
<Button size="sm" className="hidden sm:inline-flex" asChild>
|
||||||
<Link to="/bookings/new">+ New reservation</Link>
|
<Link to="/bookings/new">+ New reservation</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
|
|
@ -37,17 +39,28 @@ export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
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";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={loading || disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{asChild ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
22
src/components/ui/spinner.tsx
Normal file
22
src/components/ui/spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,87 @@
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { AdminRole } from "@/lib/types";
|
import type { AdminRole } from "@/lib/types";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthContextValue {
|
||||||
role: AdminRole | null;
|
role: AdminRole | null;
|
||||||
name: string;
|
name: string;
|
||||||
property: string;
|
selectedPropertyId: string | null;
|
||||||
}
|
selectedPropertyName: string;
|
||||||
|
setSelectedPropertyId: (id: string) => void;
|
||||||
interface AuthContextValue extends AuthState {
|
|
||||||
setRole: (r: AdminRole) => void;
|
|
||||||
setProperty: (p: string) => void;
|
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
canManageCodes: boolean;
|
canManageCodes: boolean;
|
||||||
canRefund: boolean;
|
canRefund: boolean;
|
||||||
canEditBookings: boolean;
|
canEditBookings: boolean;
|
||||||
|
accessToken: string | null;
|
||||||
|
bootstrapped: boolean;
|
||||||
|
hasHotelProperty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [role, setRoleState] = useState<AdminRole | null>(null);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const [name] = useState("Sophia Mitchell");
|
const user = useAuthStore((s) => s.user);
|
||||||
const [property, setProperty] = useState("Shitaye Suite Hotel");
|
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>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
role,
|
role,
|
||||||
name,
|
name: user?.name ?? "",
|
||||||
property,
|
selectedPropertyId,
|
||||||
setRole,
|
selectedPropertyName,
|
||||||
setProperty,
|
setSelectedPropertyId,
|
||||||
logout,
|
logout,
|
||||||
canManageCodes: role === "finance" || role === "superadmin",
|
canManageCodes: role === "finance" || role === "ADMIN",
|
||||||
canRefund: role === "finance" || role === "superadmin",
|
canRefund: role === "finance" || role === "ADMIN",
|
||||||
canEditBookings:
|
canEditBookings:
|
||||||
role === "front_desk" ||
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
role === "finance" ||
|
accessToken,
|
||||||
role === "superadmin",
|
bootstrapped,
|
||||||
|
hasHotelProperty: properties.length > 0,
|
||||||
}),
|
}),
|
||||||
[role, name, property, setRole, setProperty, logout]
|
[
|
||||||
|
role,
|
||||||
|
user?.name,
|
||||||
|
selectedPropertyId,
|
||||||
|
selectedPropertyName,
|
||||||
|
setSelectedPropertyId,
|
||||||
|
logout,
|
||||||
|
accessToken,
|
||||||
|
bootstrapped,
|
||||||
|
properties.length,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|
|
||||||
121
src/lib/api.ts
121
src/lib/api.ts
|
|
@ -1,33 +1,110 @@
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
import { parseApiError } from '@/lib/auth-api';
|
||||||
const res = await fetch(`/api${path}`);
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
const API_PREFIX = '/api';
|
||||||
return res.json() as Promise<T>;
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
let getToken: () => string | null = () => null;
|
||||||
const res = await fetch(`/api${path}`, {
|
let getPropertyId: () => string | null = () => null;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
/** Register token + property scope for hotel API paths (call once from the auth store module). */
|
||||||
body: JSON.stringify(body),
|
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 `/properties/${pid}/hotel${pathname}${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
|
||||||
const t = await res.text();
|
|
||||||
throw new Error(t);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
export async function apiPost<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await request('POST', path, body, init);
|
||||||
method: "PATCH",
|
if (!res.ok) throw new Error(await parseApiError(res));
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path: string): Promise<void> {
|
export async function apiPatch<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, { method: "DELETE" });
|
const res = await request('PATCH', path, body, init);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
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
144
src/lib/auth-api.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,45 +9,197 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { AdminRole } from "@/lib/types";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
const roles: { id: AdminRole; label: string }[] = [
|
import { normalizeInternationalPhone, useAuthStore } from "@/store/authStore";
|
||||||
{ id: "viewer", label: "Viewer (read-only)" },
|
|
||||||
{ id: "front_desk", label: "Front desk" },
|
|
||||||
{ id: "finance", label: "Finance" },
|
|
||||||
{ id: "superadmin", label: "Super admin" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { setRole } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
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) {
|
const [email, setEmail] = useState("");
|
||||||
setRole(role);
|
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 });
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
<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">
|
<Card className="w-full max-w-md rounded-2xl shadow-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Yaltopia Hotels Admin</CardTitle>
|
<CardTitle className="text-2xl">Yaltopia Hotels</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Mock sign-in — choose a role to explore RBAC (no password).
|
Sign in with the account your administrator created for this property.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2">
|
<CardContent>
|
||||||
{roles.map((r) => (
|
<Tabs defaultValue="email" className="w-full">
|
||||||
<Button
|
<TabsList className="grid w-full grid-cols-2 rounded-xl">
|
||||||
key={r.id}
|
<TabsTrigger value="email" className="rounded-lg">
|
||||||
variant="outline"
|
Email
|
||||||
className="h-12 justify-start rounded-xl"
|
</TabsTrigger>
|
||||||
onClick={() => pick(r.id)}
|
<TabsTrigger value="phone" className="rounded-lg">
|
||||||
>
|
Phone
|
||||||
{r.label}
|
</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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
209
src/store/authStore.ts
Normal file
209
src/store/authStore.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user