289 lines
9.3 KiB
TypeScript
289 lines
9.3 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
import { usePage } from "@inertiajs/react";
|
|
import { useTranslation } from "react-i18next";
|
|
import toast from "react-hot-toast";
|
|
import { LoaderCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import InputError from "@/components/input-error";
|
|
import TextLink from "@/components/text-link";
|
|
|
|
declare const route: (name: string, params?: Record<string, unknown>) => string;
|
|
|
|
export interface AuthUserPayload {
|
|
id?: number;
|
|
email?: string;
|
|
name?: string | null;
|
|
pending_purchase?: boolean;
|
|
}
|
|
|
|
interface LoginFormProps {
|
|
onSuccess?: (userData: AuthUserPayload | null) => void;
|
|
canResetPassword?: boolean;
|
|
locale?: string;
|
|
packageId?: number | null;
|
|
}
|
|
|
|
type SharedPageProps = {
|
|
locale?: string;
|
|
};
|
|
|
|
type FieldErrors = Record<string, string>;
|
|
|
|
const metaCsrfToken = () =>
|
|
(document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
|
|
|
|
const xsrfCookieToken = () => {
|
|
if (typeof document === "undefined") {
|
|
return "";
|
|
}
|
|
|
|
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
|
|
return match ? decodeURIComponent(match[1]) : "";
|
|
};
|
|
|
|
export default function LoginForm({ onSuccess, canResetPassword = true, locale, packageId }: LoginFormProps) {
|
|
const page = usePage<SharedPageProps>();
|
|
const { t } = useTranslation("auth");
|
|
const resolvedLocale = locale ?? page.props.locale ?? "de";
|
|
|
|
const loginEndpoint = '/checkout/login';
|
|
|
|
const canUseGoogle = typeof packageId === "number" && !Number.isNaN(packageId);
|
|
const googleHref = useMemo(() => {
|
|
if (!canUseGoogle) {
|
|
return "";
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
package_id: String(packageId),
|
|
locale: resolvedLocale,
|
|
});
|
|
|
|
return `/checkout/auth/google?${params.toString()}`;
|
|
}, [canUseGoogle, packageId, resolvedLocale]);
|
|
|
|
const [values, setValues] = useState({
|
|
identifier: "",
|
|
password: "",
|
|
remember: false,
|
|
});
|
|
const [errors, setErrors] = useState<FieldErrors>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
|
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
|
const [shouldFocusError, setShouldFocusError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!hasTriedSubmit) {
|
|
return;
|
|
}
|
|
|
|
const collected = Object.values(errors).filter(Boolean);
|
|
if (collected.length > 0) {
|
|
toast.error(collected.join(" \u2022 "));
|
|
}
|
|
}, [errors, hasTriedSubmit]);
|
|
|
|
useEffect(() => {
|
|
if (!hasTriedSubmit || !shouldFocusError) {
|
|
return;
|
|
}
|
|
|
|
const firstKey = Object.keys(errors).find((key) => Boolean(errors[key]));
|
|
if (!firstKey) {
|
|
return;
|
|
}
|
|
|
|
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
|
|
field?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
field?.focus();
|
|
setShouldFocusError(false);
|
|
}, [errors, hasTriedSubmit, shouldFocusError]);
|
|
|
|
const updateValue = (key: keyof typeof values, value: string | boolean) => {
|
|
setValues((current) => ({ ...current, [key]: value }));
|
|
setErrors((current) => ({ ...current, [key as string]: "" }));
|
|
setShouldFocusError(false);
|
|
};
|
|
|
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setHasTriedSubmit(true);
|
|
setErrors({});
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
const cookieToken = xsrfCookieToken();
|
|
const metaToken = metaCsrfToken();
|
|
|
|
const response = await fetch(loginEndpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
...(cookieToken
|
|
? { "X-XSRF-TOKEN": cookieToken }
|
|
: metaToken
|
|
? { "X-CSRF-TOKEN": metaToken }
|
|
: {}),
|
|
},
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
identifier: values.identifier,
|
|
password: values.password,
|
|
remember: values.remember,
|
|
locale: resolvedLocale,
|
|
package_id: packageId ?? null,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const payload = await response.json();
|
|
const nextUser: AuthUserPayload | null = payload?.user ?? payload?.data ?? null;
|
|
|
|
toast.success(t("login.success_toast", "Login erfolgreich"));
|
|
onSuccess?.(nextUser);
|
|
setValues((current) => ({ ...current, password: "" }));
|
|
setHasTriedSubmit(false);
|
|
return;
|
|
}
|
|
|
|
if (response.status === 422) {
|
|
const payload = await response.json();
|
|
const fieldErrors: FieldErrors = {};
|
|
const source = payload?.errors ?? {};
|
|
Object.entries(source).forEach(([key, message]) => {
|
|
if (Array.isArray(message) && message.length > 0) {
|
|
fieldErrors[key] = String(message[0]);
|
|
} else if (typeof message === "string") {
|
|
fieldErrors[key] = message;
|
|
}
|
|
});
|
|
|
|
setErrors(fieldErrors);
|
|
setShouldFocusError(true);
|
|
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
|
|
return;
|
|
}
|
|
|
|
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
|
|
setShouldFocusError(false);
|
|
} catch (error) {
|
|
console.error("Login request failed", error);
|
|
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleGoogleLogin = () => {
|
|
if (!googleHref) {
|
|
return;
|
|
}
|
|
|
|
setIsRedirectingToGoogle(true);
|
|
window.location.href = googleHref;
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={submit} className="flex flex-col gap-6" noValidate>
|
|
<div className="grid gap-6">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="identifier">{t("login.identifier") || t("login.email")}</Label>
|
|
<Input
|
|
id="identifier"
|
|
type="text"
|
|
name="identifier"
|
|
required
|
|
autoFocus
|
|
placeholder={t("login.identifier_placeholder") || t("login.email_placeholder")}
|
|
value={values.identifier}
|
|
onChange={(event) => updateValue("identifier", event.target.value)}
|
|
/>
|
|
<InputError message={errors.identifier || errors.email} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center">
|
|
<Label htmlFor="password">{t("login.password")}</Label>
|
|
{canResetPassword && (
|
|
<TextLink href={typeof route === "function" ? route("password.request") : "/forgot-password"} className="ml-auto text-sm">
|
|
{t("login.forgot")}
|
|
</TextLink>
|
|
)}
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
name="password"
|
|
required
|
|
placeholder={t("login.password_placeholder")}
|
|
value={values.password}
|
|
onChange={(event) => updateValue("password", event.target.value)}
|
|
/>
|
|
<InputError message={errors.password} />
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
<Checkbox
|
|
id="remember"
|
|
name="remember"
|
|
checked={values.remember}
|
|
onCheckedChange={(checked) => updateValue("remember", Boolean(checked))}
|
|
/>
|
|
<Label htmlFor="remember">{t("login.remember")}</Label>
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
|
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
|
{t("login.submit")}
|
|
</Button>
|
|
</div>
|
|
|
|
{canUseGoogle && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
|
|
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
|
|
{t("login.oauth_divider", "oder")}
|
|
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleGoogleLogin}
|
|
disabled={isSubmitting || isRedirectingToGoogle}
|
|
className="flex w-full items-center justify-center gap-2"
|
|
>
|
|
{isRedirectingToGoogle ? (
|
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<GoogleIcon className="h-4 w-4" />
|
|
)}
|
|
<span>{t("login.google_cta")}</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{Object.values(errors).some(Boolean) && (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
|
<p className="text-sm text-red-800">{Object.values(errors).filter(Boolean).join(" \u2022 ")}</p>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function GoogleIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
|
<path
|
|
fill="#EA4335"
|
|
d="M12 11.999v4.8h6.7c-.3 1.7-2 4.9-6.7 4.9-4 0-7.4-3.3-7.4-7.4S8 6 12 6c2.3 0 3.9 1 4.8 1.9l3.3-3.2C18.1 2.6 15.3 1 12 1 5.9 1 1 5.9 1 12s4.9 11 11 11c6.3 0 10.5-4.4 10.5-10.6 0-.7-.1-1.2-.2-1.8H12z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|