auth + permission updates

This commit is contained in:
brooktewabe 2026-04-06 11:46:36 +03:00
parent e7f3709c11
commit 984efd5906
5 changed files with 159 additions and 1 deletions

View File

@ -19,6 +19,10 @@ interface AuthContextValue {
canManageCodes: boolean;
canRefund: boolean;
canEditBookings: boolean;
/** Point rules, raffles, menu — hotel OPERATE (not viewer-only). */
canManageLoyalty: boolean;
/** Full property point ledger (finance / admin). */
canViewFinanceLedger: boolean;
accessToken: string | null;
bootstrapped: boolean;
hasHotelProperty: boolean;
@ -64,6 +68,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
canRefund: role === "finance" || role === "ADMIN",
canEditBookings:
role === "front_desk" || role === "finance" || role === "ADMIN",
canManageLoyalty:
role === "front_desk" || role === "finance" || role === "ADMIN",
canViewFinanceLedger: role === "finance" || role === "ADMIN",
accessToken,
bootstrapped,
hasHotelProperty: properties.length > 0,

View File

@ -23,6 +23,7 @@ function shouldRewriteForHotel(path: string): boolean {
const first = path.split('?')[0];
if (first.startsWith('/auth')) return false;
if (first.startsWith('/properties')) return false;
if (first.startsWith('/admin')) return false;
return true;
}

View File

@ -15,6 +15,7 @@ function shouldRewriteForHotel(path: string): boolean {
const first = path.split("?")[0];
if (first.startsWith("/auth")) return false;
if (first.startsWith("/properties")) return false;
if (first.startsWith("/admin")) return false;
return true;
}
@ -89,6 +90,26 @@ export async function postLoginPhoneVerify(loginRequestToken: string, otp: strin
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
}
export async function postSendOtp(identifier: string) {
const res = await fetch(apiUrl("/auth/sendOtp"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: identifier.trim() }),
});
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<{ message: string }>;
}
export async function postHotelGuestLoginEmailOtp(email: string, otp: string) {
const res = await fetch(apiUrl("/auth/hotel-user/login-email-otp"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase(), otp: otp.trim() }),
});
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;

View File

@ -19,6 +19,8 @@ export function LoginPage() {
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
const requestHotelEmailOtp = useAuthStore((s) => s.requestHotelEmailOtp);
const verifyHotelEmailOtp = useAuthStore((s) => s.verifyHotelEmailOtp);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -28,6 +30,10 @@ export function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [phoneHint, setPhoneHint] = useState<string | null>(null);
const [guestEmail, setGuestEmail] = useState("");
const [emailOtp, setEmailOtp] = useState("");
const [emailOtpSent, setEmailOtpSent] = useState(false);
const [emailHint, setEmailHint] = useState<string | null>(null);
async function onEmailLogin(e: React.FormEvent) {
e.preventDefault();
@ -65,6 +71,36 @@ export function LoginPage() {
}
}
async function onRequestGuestEmailOtp(e: React.FormEvent) {
e.preventDefault();
setError(null);
setEmailHint(null);
setLoading(true);
try {
const res = await requestHotelEmailOtp(guestEmail);
setEmailHint(res.message);
setEmailOtpSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not send code");
} finally {
setLoading(false);
}
}
async function onVerifyGuestEmailOtp(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await verifyHotelEmailOtp(guestEmail, emailOtp);
navigate("/dashboard", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid code");
} finally {
setLoading(false);
}
}
async function onVerifyOtp(e: React.FormEvent) {
e.preventDefault();
if (!loginRequestToken) {
@ -94,10 +130,13 @@ export function LoginPage() {
</CardHeader>
<CardContent>
<Tabs defaultValue="email" className="w-full">
<TabsList className="grid w-full grid-cols-2 rounded-xl">
<TabsList className="grid w-full grid-cols-3 rounded-xl">
<TabsTrigger value="email" className="rounded-lg">
Email
</TabsTrigger>
<TabsTrigger value="email-otp" className="rounded-lg">
Email code
</TabsTrigger>
<TabsTrigger value="phone" className="rounded-lg">
Phone
</TabsTrigger>
@ -141,6 +180,80 @@ export function LoginPage() {
</form>
</TabsContent>
<TabsContent value="email-otp" className="mt-4 space-y-4">
<p className="text-xs text-muted-foreground">
For hotel guest accounts linked to this property: request a code to the email on file,
then sign in without a password.
</p>
{!emailOtpSent ? (
<form onSubmit={onRequestGuestEmailOtp} className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="guest-email">Email</Label>
<Input
id="guest-email"
type="email"
autoComplete="email"
required
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
placeholder="you@example.com"
className="rounded-xl"
/>
</div>
{emailHint ? (
<p className="text-sm text-muted-foreground">{emailHint}</p>
) : null}
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<Button type="submit" className="rounded-xl" disabled={loading}>
{loading ? "Sending…" : "Send code"}
</Button>
</form>
) : (
<form onSubmit={onVerifyGuestEmailOtp} className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="guest-otp">One-time code</Label>
<Input
id="guest-otp"
inputMode="numeric"
autoComplete="one-time-code"
required
minLength={4}
value={emailOtp}
onChange={(e) => setEmailOtp(e.target.value)}
placeholder="Code from email"
className="rounded-xl"
/>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button type="submit" className="rounded-xl" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</Button>
<Button
type="button"
variant="ghost"
className="rounded-xl"
onClick={() => {
setEmailOtpSent(false);
setEmailOtp("");
setError(null);
}}
>
Use different email
</Button>
</div>
</form>
)}
</TabsContent>
<TabsContent value="phone" className="mt-4 space-y-4">
<form onSubmit={onRequestOtp} className="grid gap-4">
<div className="space-y-2">

View File

@ -4,9 +4,11 @@ import { persist } from "zustand/middleware";
import {
getProperties,
getProfile,
postHotelGuestLoginEmailOtp,
postLogin,
postLoginPhoneRequest,
postLoginPhoneVerify,
postSendOtp,
registerHotelAuthApiContext,
type AuthUser,
type PropertyRow,
@ -36,6 +38,9 @@ export function mapBackendRoleToAdminRole(role: string): AdminRole {
return "ADMIN";
case "PROJECT_MANAGER":
return "viewer";
case "HOTEL_USER":
case "CUSTOMER":
return "viewer";
default:
return "viewer";
}
@ -62,6 +67,8 @@ type AuthActions = {
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
requestHotelEmailOtp: (email: string) => Promise<{ message: string }>;
verifyHotelEmailOtp: (email: string, otp: string) => Promise<void>;
bootstrap: () => Promise<void>;
setSelectedPropertyId: (id: string) => void;
logout: () => void;
@ -116,6 +123,15 @@ export const useAuthStore = create<AuthState & AuthActions>()(
await get().setSession(access_token, user);
},
requestHotelEmailOtp: async (email) => {
return postSendOtp(email.trim().toLowerCase());
},
verifyHotelEmailOtp: async (email, otp) => {
const { access_token, user } = await postHotelGuestLoginEmailOtp(email, otp);
await get().setSession(access_token, user);
},
bootstrap: async () => {
const token = get().accessToken;
if (!token) {