diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 9e277a9..79ee6db 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -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, diff --git a/src/lib/api.ts b/src/lib/api.ts index 78e20df..d30f27c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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; } diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts index adb3909..ca9cab7 100644 --- a/src/lib/auth-api.ts +++ b/src/lib/auth-api.ts @@ -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; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index c3bdb1b..66699ed 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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(null); const [phoneHint, setPhoneHint] = useState(null); + const [guestEmail, setGuestEmail] = useState(""); + const [emailOtp, setEmailOtp] = useState(""); + const [emailOtpSent, setEmailOtpSent] = useState(false); + const [emailHint, setEmailHint] = useState(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() { - + Email + + Email code + Phone @@ -141,6 +180,80 @@ export function LoginPage() { + +

+ For hotel guest accounts linked to this property: request a code to the email on file, + then sign in without a password. +

+ {!emailOtpSent ? ( +
+
+ + setGuestEmail(e.target.value)} + placeholder="you@example.com" + className="rounded-xl" + /> +
+ {emailHint ? ( +

{emailHint}

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+ ) : ( +
+
+ + setEmailOtp(e.target.value)} + placeholder="Code from email" + className="rounded-xl" + /> +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ )} +
+
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 6cfd329..7cac221 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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; requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>; verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise; + requestHotelEmailOtp: (email: string) => Promise<{ message: string }>; + verifyHotelEmailOtp: (email: string, otp: string) => Promise; bootstrap: () => Promise; setSelectedPropertyId: (id: string) => void; logout: () => void; @@ -116,6 +123,15 @@ export const useAuthStore = create()( 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) {