stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -1,42 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { completeLogin } = useAuth();
|
||||
export default function AuthCallbackPage(): JSX.Element {
|
||||
const { status } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const hasHandledRef = React.useRef(false);
|
||||
const [redirected, setRedirected] = React.useState(false);
|
||||
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []);
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
|
||||
const fallback = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const destination = React.useMemo(() => {
|
||||
if (rawReturnTo) {
|
||||
const decoded = decodeReturnTo(rawReturnTo);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveReturnTarget(null, fallback).finalTarget;
|
||||
}, [fallback, rawReturnTo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasHandledRef.current) {
|
||||
if (status !== 'authenticated' || redirected) {
|
||||
return;
|
||||
}
|
||||
hasHandledRef.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
completeLogin(params)
|
||||
.then((redirectTo) => {
|
||||
navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth] Callback processing failed', err);
|
||||
if (isAuthError(err) && err.code === 'token_exchange_failed') {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else if (isAuthError(err) && err.code === 'invalid_state') {
|
||||
setError('Ungültiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Login.');
|
||||
}
|
||||
});
|
||||
}, [completeLogin, navigate]);
|
||||
setRedirected(true);
|
||||
navigate(destination, { replace: true });
|
||||
}, [destination, navigate, redirected, status]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
|
||||
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet …</span>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
Bitte warte einen Moment. Wir richten dein Dashboard ein.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
|
||||
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||
|
||||
@@ -60,12 +66,12 @@ export default function BillingPage() {
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadAll = React.useCallback(async () => {
|
||||
const loadAll = React.useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||
getTenantPackagesOverview(),
|
||||
getTenantPackagesOverview(force ? { force: true } : undefined),
|
||||
getTenantPaddleTransactions().catch((err) => {
|
||||
console.warn('Failed to load Paddle transactions', err);
|
||||
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||
@@ -125,8 +131,8 @@ export default function BillingPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => void loadAll()}
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => void loadAll(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
@@ -136,8 +142,7 @@ export default function BillingPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign(packagesHref)}
|
||||
>
|
||||
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
|
||||
|
||||
@@ -21,7 +21,13 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { TenantHeroCard, TenantOnboardingChecklistCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
TenantOnboardingChecklistCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import type { ChecklistStep } from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
@@ -399,7 +405,7 @@ export default function DashboardPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
if (readiness.hasEvent) {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
@@ -414,8 +420,7 @@ export default function DashboardPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
>
|
||||
{marketingDashboardLabel}
|
||||
|
||||
@@ -7,7 +7,13 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import { TasksSection } from './TasksPage';
|
||||
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
||||
import { EmotionsSection } from './EmotionsPage';
|
||||
@@ -54,7 +60,7 @@ export default function EngagementPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
>
|
||||
{t('engagement.hero.actions.tasks', 'Zu Aufgaben wechseln')}
|
||||
@@ -63,8 +69,7 @@ export default function EngagementPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => handleTabChange('collections')}
|
||||
>
|
||||
{t('engagement.hero.actions.collections', 'Kollektionen ansehen')}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
@@ -93,14 +99,14 @@ export default function EventsPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(adminPath('/events/new'))}
|
||||
>
|
||||
{t('events.list.actions.create', 'Neues Event')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button size="sm" variant="outline" className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white" asChild>
|
||||
<Button size="sm" className={tenantHeroSecondaryButtonClass} asChild>
|
||||
<Link to={ADMIN_SETTINGS_PATH}>
|
||||
{t('events.list.actions.settings', 'Einstellungen')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -1,242 +1,181 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
encodeReturnTo,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
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() {
|
||||
const { status, login } = useAuth();
|
||||
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 oauthError = searchParams.get('error');
|
||||
const oauthErrorDescription = searchParams.get('error_description');
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const state = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const composed = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(composed)) {
|
||||
return composed;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [state]);
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
|
||||
const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
const { finalTarget } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
[rawReturnTo, fallbackTarget]
|
||||
);
|
||||
|
||||
const resolvedErrorMessage = React.useMemo(() => {
|
||||
if (!oauthError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
login_required: t('login.oauth_errors.login_required'),
|
||||
invalid_request: t('login.oauth_errors.invalid_request'),
|
||||
invalid_client: t('login.oauth_errors.invalid_client'),
|
||||
invalid_redirect: t('login.oauth_errors.invalid_redirect'),
|
||||
invalid_scope: t('login.oauth_errors.invalid_scope'),
|
||||
tenant_mismatch: t('login.oauth_errors.tenant_mismatch'),
|
||||
google_failed: t('login.oauth_errors.google_failed'),
|
||||
google_no_match: t('login.oauth_errors.google_no_match'),
|
||||
};
|
||||
|
||||
return errorMap[oauthError] ?? oauthErrorDescription ?? oauthError;
|
||||
}, [oauthError, oauthErrorDescription, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(finalTarget, { replace: true });
|
||||
}
|
||||
}, [finalTarget, navigate, status]);
|
||||
|
||||
const redirectTarget = React.useMemo(() => {
|
||||
if (finalTarget) {
|
||||
return finalTarget;
|
||||
}
|
||||
const [login, setLogin] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const path = `${from.pathname}${search}${hash}`;
|
||||
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 });
|
||||
},
|
||||
});
|
||||
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [finalTarget, state]);
|
||||
|
||||
const shouldOpenAccountLogin = oauthError === 'login_required';
|
||||
const isLoading = status === 'loading';
|
||||
const hasAutoStartedRef = React.useRef(false);
|
||||
const oauthStartPath = React.useMemo(
|
||||
() => buildAdminOAuthStartPath(redirectTarget, encodedFinal),
|
||||
[encodedFinal, redirectTarget]
|
||||
);
|
||||
const marketingLoginUrl = React.useMemo(() => buildMarketingLoginUrl(oauthStartPath), [oauthStartPath]);
|
||||
|
||||
const hasRedirectedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!shouldOpenAccountLogin || hasRedirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRedirectedRef.current = true;
|
||||
window.location.replace(marketingLoginUrl);
|
||||
}, [marketingLoginUrl, shouldOpenAccountLogin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'unauthenticated' || oauthError || hasAutoStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutoStartedRef.current = true;
|
||||
storeLastDestination(redirectTarget);
|
||||
login(redirectTarget);
|
||||
}, [login, oauthError, redirectTarget, status]);
|
||||
|
||||
const googleHref = React.useMemo(() => {
|
||||
const target = new URL('/event-admin/auth/google', window.location.origin);
|
||||
target.searchParams.set('return_to', encodeReturnTo(oauthStartPath));
|
||||
|
||||
return target.toString();
|
||||
}, [oauthStartPath]);
|
||||
mutation.mutate({
|
||||
login,
|
||||
password,
|
||||
return_to: rawReturnTo,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 px-4 py-16 text-white">
|
||||
<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 items-center gap-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<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">{t('login.panel_title', t('login.title', 'Event Admin'))}</h1>
|
||||
<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 access. OAuth 2.1 and clear role permissions keep your account protected.')}
|
||||
{t('login.panel_copy', 'Sign in with your Fotospiel admin credentials to manage your events and galleries.')}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{resolvedErrorMessage ? (
|
||||
<Alert className="w-full border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{resolvedErrorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<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="w-full space-y-4 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="space-y-2 text-left">
|
||||
<h2 className="text-xl font-semibold">{t('login.actions_title', 'Choose your sign-in method')}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-300">
|
||||
{t('login.actions_copy', 'Access the tenant dashboard securely with OAuth or your Google account.')}
|
||||
<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>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (shouldOpenAccountLogin) {
|
||||
window.location.href = marketingLoginUrl;
|
||||
return;
|
||||
}
|
||||
{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}
|
||||
|
||||
storeLastDestination(redirectTarget);
|
||||
login(redirectTarget);
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{shouldOpenAccountLogin
|
||||
? t('login.open_account_login')
|
||||
: t('login.cta', 'Continue with Fotospiel login')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-full border-slate-200 bg-white text-slate-900 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-50 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
window.location.href = googleHref;
|
||||
}}
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
{t('login.google_cta', 'Continue with Google')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-300">
|
||||
{t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{redirectTarget !== ADMIN_DEFAULT_AFTER_LOGIN_PATH ? (
|
||||
<p className="text-center text-xs text-white/50">{t('login.return_hint')}</p>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ADMIN_LOGIN_PATH } from '../constants';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginStartPage() {
|
||||
const { status, login } = useAuth();
|
||||
export default function LoginStartPage(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const locationState = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
const from = locationState?.from;
|
||||
if (from) {
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const combined = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(combined)) {
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [locationState]);
|
||||
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
);
|
||||
|
||||
const [hasStarted, setHasStarted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(finalTarget, { replace: true });
|
||||
return;
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const returnTo = params.get('return_to');
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
if (returnTo) {
|
||||
target.searchParams.set('return_to', returnTo);
|
||||
}
|
||||
|
||||
if (hasStarted || status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasStarted(true);
|
||||
storeLastDestination(finalTarget);
|
||||
login(finalTarget);
|
||||
}, [finalTarget, hasStarted, login, navigate, status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status !== 'unauthenticated' || !hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
|
||||
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
|
||||
window.location.replace(marketingLoginUrl);
|
||||
}, [encodedFinal, finalTarget, hasStarted, status]);
|
||||
navigate(`${target.pathname}${target.search}`, { replace: true });
|
||||
}, [location.search, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 p-6 text-center text-white/70">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden />
|
||||
<p className="text-sm font-medium">Melde dich an …</p>
|
||||
<p className="max-w-xs text-xs text-white/50">Wir verbinden dich automatisch mit deinem Event-Dashboard.</p>
|
||||
<p className="text-sm font-medium">Weiterleitung zum Login …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
FrostedCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
} from '../components/tenant';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { encodeReturnTo } from '../lib/returnTo';
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
@@ -43,7 +49,7 @@ export default function SettingsPage() {
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_PROFILE_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
|
||||
@@ -52,8 +58,7 @@ export default function SettingsPage() {
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
|
||||
@@ -79,10 +84,10 @@ export default function SettingsPage() {
|
||||
);
|
||||
|
||||
function handleLogout() {
|
||||
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
||||
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
||||
marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1';
|
||||
logout({ redirect: marketingUrl });
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
target.searchParams.set('reset-auth', '1');
|
||||
target.searchParams.set('return_to', encodeReturnTo(ADMIN_EVENTS_PATH));
|
||||
logout({ redirect: `${target.pathname}${target.search}` });
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
@@ -37,10 +37,9 @@ export default function WelcomeTeaserPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawReturnTo = params.get('return_to');
|
||||
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
|
||||
const oauthStartPath = buildAdminOAuthStartPath(finalTarget, encodedFinal);
|
||||
const marketingLoginUrl = buildMarketingLoginUrl(oauthStartPath);
|
||||
|
||||
window.location.href = marketingLoginUrl;
|
||||
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
|
||||
target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget));
|
||||
window.location.href = `${target.pathname}${target.search}`;
|
||||
}, [isRedirecting]);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user