auth + permission updates
This commit is contained in:
parent
e7f3709c11
commit
984efd5906
|
|
@ -19,6 +19,10 @@ interface AuthContextValue {
|
||||||
canManageCodes: boolean;
|
canManageCodes: boolean;
|
||||||
canRefund: boolean;
|
canRefund: boolean;
|
||||||
canEditBookings: 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;
|
accessToken: string | null;
|
||||||
bootstrapped: boolean;
|
bootstrapped: boolean;
|
||||||
hasHotelProperty: boolean;
|
hasHotelProperty: boolean;
|
||||||
|
|
@ -64,6 +68,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
canRefund: role === "finance" || role === "ADMIN",
|
canRefund: role === "finance" || role === "ADMIN",
|
||||||
canEditBookings:
|
canEditBookings:
|
||||||
role === "front_desk" || role === "finance" || role === "ADMIN",
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
|
canManageLoyalty:
|
||||||
|
role === "front_desk" || role === "finance" || role === "ADMIN",
|
||||||
|
canViewFinanceLedger: role === "finance" || role === "ADMIN",
|
||||||
accessToken,
|
accessToken,
|
||||||
bootstrapped,
|
bootstrapped,
|
||||||
hasHotelProperty: properties.length > 0,
|
hasHotelProperty: properties.length > 0,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ function shouldRewriteForHotel(path: string): boolean {
|
||||||
const first = path.split('?')[0];
|
const first = path.split('?')[0];
|
||||||
if (first.startsWith('/auth')) return false;
|
if (first.startsWith('/auth')) return false;
|
||||||
if (first.startsWith('/properties')) return false;
|
if (first.startsWith('/properties')) return false;
|
||||||
|
if (first.startsWith('/admin')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ function shouldRewriteForHotel(path: string): boolean {
|
||||||
const first = path.split("?")[0];
|
const first = path.split("?")[0];
|
||||||
if (first.startsWith("/auth")) return false;
|
if (first.startsWith("/auth")) return false;
|
||||||
if (first.startsWith("/properties")) return false;
|
if (first.startsWith("/properties")) return false;
|
||||||
|
if (first.startsWith("/admin")) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +90,26 @@ export async function postLoginPhoneVerify(loginRequestToken: string, otp: strin
|
||||||
return res.json() as Promise<{ access_token: string; user: AuthUser }>;
|
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 = {
|
export type PropertyRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export function LoginPage() {
|
||||||
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
|
const loginWithEmailPassword = useAuthStore((s) => s.loginWithEmailPassword);
|
||||||
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
|
const requestPhoneOtp = useAuthStore((s) => s.requestPhoneOtp);
|
||||||
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
|
const verifyPhoneOtp = useAuthStore((s) => s.verifyPhoneOtp);
|
||||||
|
const requestHotelEmailOtp = useAuthStore((s) => s.requestHotelEmailOtp);
|
||||||
|
const verifyHotelEmailOtp = useAuthStore((s) => s.verifyHotelEmailOtp);
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -28,6 +30,10 @@ export function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [phoneHint, setPhoneHint] = 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) {
|
async function onEmailLogin(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
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) {
|
async function onVerifyOtp(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loginRequestToken) {
|
if (!loginRequestToken) {
|
||||||
|
|
@ -94,10 +130,13 @@ export function LoginPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="email" className="w-full">
|
<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">
|
<TabsTrigger value="email" className="rounded-lg">
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="email-otp" className="rounded-lg">
|
||||||
|
Email code
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="phone" className="rounded-lg">
|
<TabsTrigger value="phone" className="rounded-lg">
|
||||||
Phone
|
Phone
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -141,6 +180,80 @@ export function LoginPage() {
|
||||||
</form>
|
</form>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="phone" className="mt-4 space-y-4">
|
||||||
<form onSubmit={onRequestOtp} className="grid gap-4">
|
<form onSubmit={onRequestOtp} className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { persist } from "zustand/middleware";
|
||||||
import {
|
import {
|
||||||
getProperties,
|
getProperties,
|
||||||
getProfile,
|
getProfile,
|
||||||
|
postHotelGuestLoginEmailOtp,
|
||||||
postLogin,
|
postLogin,
|
||||||
postLoginPhoneRequest,
|
postLoginPhoneRequest,
|
||||||
postLoginPhoneVerify,
|
postLoginPhoneVerify,
|
||||||
|
postSendOtp,
|
||||||
registerHotelAuthApiContext,
|
registerHotelAuthApiContext,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type PropertyRow,
|
type PropertyRow,
|
||||||
|
|
@ -36,6 +38,9 @@ export function mapBackendRoleToAdminRole(role: string): AdminRole {
|
||||||
return "ADMIN";
|
return "ADMIN";
|
||||||
case "PROJECT_MANAGER":
|
case "PROJECT_MANAGER":
|
||||||
return "viewer";
|
return "viewer";
|
||||||
|
case "HOTEL_USER":
|
||||||
|
case "CUSTOMER":
|
||||||
|
return "viewer";
|
||||||
default:
|
default:
|
||||||
return "viewer";
|
return "viewer";
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +67,8 @@ type AuthActions = {
|
||||||
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
|
loginWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||||
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
|
requestPhoneOtp: (phone: string) => Promise<{ loginRequestToken?: string; message: string }>;
|
||||||
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
|
verifyPhoneOtp: (loginRequestToken: string, otp: string) => Promise<void>;
|
||||||
|
requestHotelEmailOtp: (email: string) => Promise<{ message: string }>;
|
||||||
|
verifyHotelEmailOtp: (email: string, otp: string) => Promise<void>;
|
||||||
bootstrap: () => Promise<void>;
|
bootstrap: () => Promise<void>;
|
||||||
setSelectedPropertyId: (id: string) => void;
|
setSelectedPropertyId: (id: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
@ -116,6 +123,15 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
await get().setSession(access_token, user);
|
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 () => {
|
bootstrap: async () => {
|
||||||
const token = get().accessToken;
|
const token = get().accessToken;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user