182 lines
7.3 KiB
TypeScript
182 lines
7.3 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
import AppLogoIcon from '@/components/app-logo-icon';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
import { useAuth } from '../auth/context';
|
|
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
|
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
|
|
type LoginResponse = {
|
|
token: string;
|
|
token_type: string;
|
|
abilities: string[];
|
|
};
|
|
|
|
async function performLogin(payload: { login: string; password: string; return_to?: string | null }): Promise<LoginResponse> {
|
|
const response = await fetch('/api/v1/tenant-auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
...payload,
|
|
remember: true,
|
|
}),
|
|
});
|
|
|
|
if (response.status === 422) {
|
|
const data = await response.json();
|
|
const errors = data.errors ?? {};
|
|
const flattened = Object.values(errors).flat();
|
|
throw new Error(flattened.join(' ') || 'Validation failed');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Login failed.');
|
|
}
|
|
|
|
return (await response.json()) as LoginResponse;
|
|
}
|
|
|
|
export default function LoginPage(): JSX.Element {
|
|
const { status, applyToken } = useAuth();
|
|
const { t } = useTranslation('auth');
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
|
const rawReturnTo = searchParams.get('return_to');
|
|
|
|
const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
|
const { finalTarget } = React.useMemo(
|
|
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
|
[rawReturnTo, fallbackTarget]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (status === 'authenticated') {
|
|
navigate(finalTarget, { replace: true });
|
|
}
|
|
}, [finalTarget, navigate, status]);
|
|
|
|
const [login, setLogin] = React.useState('');
|
|
const [password, setPassword] = React.useState('');
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
const mutation = useMutation({
|
|
mutationKey: ['tenantAdminLogin'],
|
|
mutationFn: performLogin,
|
|
onError: (err: unknown) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
},
|
|
onSuccess: async (data) => {
|
|
setError(null);
|
|
await applyToken(data.token, data.abilities ?? []);
|
|
navigate(finalTarget, { replace: true });
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setError(null);
|
|
|
|
mutation.mutate({
|
|
login,
|
|
password,
|
|
return_to: rawReturnTo,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
|
|
|
|
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col gap-10 px-6 py-16">
|
|
<header className="flex flex-col items-center gap-3 text-center">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/20">
|
|
<AppLogoIcon className="h-7 w-7 text-white" />
|
|
</span>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
{t('login.panel_title', t('login.title', 'Event Admin Login'))}
|
|
</h1>
|
|
<p className="max-w-sm text-sm text-white/70">
|
|
{t('login.panel_copy', 'Sign in with your Fotospiel admin credentials to manage your events and galleries.')}
|
|
</p>
|
|
</header>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5 rounded-3xl border border-white/10 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="login" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
{t('login.username_or_email', 'E-Mail oder Benutzername')}
|
|
</Label>
|
|
<Input
|
|
id="login"
|
|
name="login"
|
|
type="text"
|
|
autoComplete="username"
|
|
required
|
|
value={login}
|
|
onChange={(event) => setLogin(event.target.value)}
|
|
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
|
|
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="password" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
{t('login.password', 'Passwort')}
|
|
</Label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
|
|
placeholder={t('login.password_placeholder', '••••••••')}
|
|
/>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{t('login.remember_hint', 'Wir halten dich automatisch angemeldet, solange du dieses Gerät nutzt.')}
|
|
</p>
|
|
</div>
|
|
|
|
{error ? (
|
|
<Alert variant="destructive" className="rounded-2xl border-rose-400/50 bg-rose-50/90 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Button
|
|
type="submit"
|
|
className="mt-2 h-12 w-full justify-center rounded-xl bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
|
disabled={mutation.isLoading}
|
|
>
|
|
{mutation.isLoading ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : null}
|
|
{mutation.isLoading ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}
|
|
</Button>
|
|
</form>
|
|
|
|
<footer className="text-center text-xs text-white/50">
|
|
{t('login.support', "Brauchen Sie Hilfe? Schreiben Sie uns an support@fotospiel.de")}
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|