die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.

This commit is contained in:
Codex Agent
2025-11-04 16:14:17 +01:00
parent 92e64c361a
commit fe380689fb
63 changed files with 4239 additions and 1142 deletions

View File

@@ -5,6 +5,24 @@ import i18n from './i18n';
type JsonValue = Record<string, unknown>;
export type TenantAccountProfile = {
id: number;
name: string;
email: string;
preferred_locale: string | null;
email_verified: boolean;
email_verified_at: string | null;
};
export type UpdateTenantProfilePayload = {
name: string;
email: string;
preferred_locale?: string | null;
current_password?: string;
password?: string;
password_confirmation?: string;
};
export type EventQrInviteLayout = {
id: string;
name: string;
@@ -353,10 +371,16 @@ async function jsonOrThrow<T>(response: Response, message: string, options: Json
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
? errorPayload.code
: undefined;
const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
? errorPayload.meta as Record<string, unknown>
: undefined;
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
errorMeta = {
errors: body.errors as Record<string, unknown>,
};
}
if (!options.suppressToast) {
emitApiErrorEvent({ message: errorMessage, status, code: errorCode, meta: errorMeta });
}
@@ -1102,6 +1126,49 @@ export async function getNotificationPreferences(): Promise<NotificationPreferen
};
}
type ProfileResponse = {
data?: TenantAccountProfile;
message?: string;
};
export async function fetchTenantProfile(): Promise<TenantAccountProfile> {
const response = await authorizedFetch('/api/v1/tenant/profile');
const payload = await jsonOrThrow<ProfileResponse>(
response,
i18n.t('settings.profile.errors.load', 'Profil konnte nicht geladen werden.'),
{ suppressToast: true }
);
if (!payload.data) {
throw new Error('Profilantwort war leer.');
}
return payload.data;
}
export async function updateTenantProfile(payload: UpdateTenantProfilePayload): Promise<TenantAccountProfile> {
const response = await authorizedFetch('/api/v1/tenant/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
const json = await jsonOrThrow<ProfileResponse>(
response,
i18n.t('settings.profile.errors.update', 'Profil konnte nicht aktualisiert werden.'),
{ suppressToast: true }
);
if (!json.data) {
throw new Error('Profilantwort war leer.');
}
return json.data;
}
export async function updateNotificationPreferences(
preferences: NotificationPreferences
): Promise<NotificationPreferenceResponse> {

View File

@@ -8,7 +8,7 @@ import {
registerAuthFailureHandler,
startOAuthFlow,
} from './tokens';
import { ADMIN_LOGIN_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
@@ -86,17 +86,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [handleAuthFailure, refreshProfile]);
const login = React.useCallback((redirectPath?: string) => {
const target = redirectPath ?? window.location.pathname + window.location.search;
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
startOAuthFlow(target);
}, []);
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
clearTokens();
clearOAuthSession();
setUser(null);
setStatus('unauthenticated');
if (redirect) {
window.location.href = redirect;
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
try {
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
await fetch('/logout', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
credentials: 'same-origin',
});
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to notify backend about logout', error);
}
} finally {
clearTokens();
clearOAuthSession();
setUser(null);
setStatus('unauthenticated');
if (redirect) {
window.location.href = redirect;
}
}
}, []);

View File

@@ -4,10 +4,13 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
export const ADMIN_HOME_PATH = adminPath('/dashboard');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/events');
export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;

View File

@@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json';
import enOnboarding from './locales/en/onboarding.json';
import deManagement from './locales/de/management.json';
import enManagement from './locales/en/management.json';
import deSettings from './locales/de/settings.json';
import enSettings from './locales/en/settings.json';
import deAuth from './locales/de/auth.json';
import enAuth from './locales/en/auth.json';
@@ -21,6 +23,7 @@ const resources = {
dashboard: deDashboard,
onboarding: deOnboarding,
management: deManagement,
settings: deSettings,
auth: deAuth,
},
en: {
@@ -28,6 +31,7 @@ const resources = {
dashboard: enDashboard,
onboarding: enOnboarding,
management: enManagement,
settings: enSettings,
auth: enAuth,
},
} as const;

View File

@@ -13,10 +13,25 @@
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
"panel_title": "Melde dich an",
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
"actions_title": "Wähle deine Anmeldemethode",
"actions_copy": "Greife sicher per OAuth oder mit deinem Google-Konto auf das Tenant-Dashboard zu.",
"cta": "Mit Fotospiel-Login fortfahren",
"google_cta": "Mit Google anmelden",
"open_account_login": "Konto-Login öffnen",
"loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"oauth_errors": {
"login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.",
"invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.",
"invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
"invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.",
"invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.",
"tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.",
"google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.",
"google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
},
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
"appearance_label": "Darstellung"
}

View File

@@ -0,0 +1,51 @@
{
"profile": {
"title": "Profil",
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
"sections": {
"account": {
"heading": "Account-Informationen",
"description": "Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an."
},
"password": {
"heading": "Passwort ändern",
"description": "Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.",
"hint": "Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit."
}
},
"fields": {
"name": "Anzeigename",
"email": "E-Mail-Adresse",
"locale": "Bevorzugte Sprache",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"passwordConfirmation": "Passwort bestätigen"
},
"placeholders": {
"name": "z. B. Hochzeitsplanung Schmidt",
"locale": "Systemsprache verwenden"
},
"locale": {
"auto": "Automatisch"
},
"status": {
"emailVerified": "E-Mail bestätigt",
"emailNotVerified": "Bestätigung erforderlich",
"verifiedHint": "Bestätigt am {{date}}.",
"unverifiedHint": "Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung."
},
"actions": {
"save": "Speichern",
"updatePassword": "Passwort aktualisieren",
"openProfile": "Profil bearbeiten"
},
"toasts": {
"updated": "Profil wurde aktualisiert.",
"passwordChanged": "Passwort wurde aktualisiert."
},
"errors": {
"load": "Profil konnte nicht geladen werden.",
"update": "Profil konnte nicht aktualisiert werden."
}
}
}

View File

@@ -13,10 +13,25 @@
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
"panel_title": "Sign in",
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
"actions_title": "Choose your sign-in method",
"actions_copy": "Access the tenant dashboard securely with OAuth or your Google account.",
"cta": "Continue with Fotospiel login",
"google_cta": "Continue with Google",
"open_account_login": "Open account login",
"loading": "Signing you in …",
"oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}",
"oauth_errors": {
"login_required": "Please sign in to your Fotospiel account before continuing.",
"invalid_request": "The login request was invalid. Please try again.",
"invalid_client": "We couldnt find the linked tenant app. Please contact support if this persists.",
"invalid_redirect": "The redirect address is not registered for this app.",
"invalid_scope": "The app asked for permissions it cannot receive.",
"tenant_mismatch": "You dont have access to the tenant that requested this login.",
"google_failed": "Google sign-in was not successful. Please try again or use another method.",
"google_no_match": "We couldnt link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
},
"return_hint": "After signing in youll be brought back automatically.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
"appearance_label": "Appearance"
}

View File

@@ -0,0 +1,51 @@
{
"profile": {
"title": "Profile",
"subtitle": "Manage your account details and credentials.",
"sections": {
"account": {
"heading": "Account information",
"description": "Update your name, email address, and interface language."
},
"password": {
"heading": "Change password",
"description": "Choose a strong password to protect admin access.",
"hint": "Use at least 8 characters and mix letters and numbers for higher security."
}
},
"fields": {
"name": "Display name",
"email": "Email address",
"locale": "Preferred language",
"currentPassword": "Current password",
"newPassword": "New password",
"passwordConfirmation": "Confirm password"
},
"placeholders": {
"name": "e.g. Event Planning Smith",
"locale": "Use system language"
},
"locale": {
"auto": "Automatic"
},
"status": {
"emailVerified": "Email verified",
"emailNotVerified": "Verification required",
"verifiedHint": "Verified on {{date}}.",
"unverifiedHint": "We'll send another verification email when you change the address."
},
"actions": {
"save": "Save",
"updatePassword": "Update password",
"openProfile": "Edit profile"
},
"toasts": {
"updated": "Profile updated successfully.",
"passwordChanged": "Password updated."
},
"errors": {
"load": "Unable to load your profile.",
"update": "Could not update your profile."
}
}
}

View File

@@ -0,0 +1,172 @@
import { ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
function ensureLeadingSlash(target: string): string {
if (!target) {
return '/';
}
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(target)) {
return target;
}
return target.startsWith('/') ? target : `/${target}`;
}
function base64UrlEncode(value: string): string {
const encoder = new TextEncoder();
const bytes = encoder.encode(value);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '');
}
function base64UrlDecode(value: string): string | null {
try {
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=');
const normalized = padded.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(normalized);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const decoder = new TextDecoder();
return decoder.decode(bytes);
} catch (error) {
console.warn('[Auth] Failed to decode return_to parameter', error);
return null;
}
}
export function encodeReturnTo(value: string): string {
const trimmed = value.trim();
if (trimmed === '') {
return '';
}
return base64UrlEncode(trimmed);
}
export function decodeReturnTo(value: string | null): string | null {
if (!value) {
return null;
}
return base64UrlDecode(value);
}
export interface ReturnTargetResolution {
finalTarget: string;
encodedFinal: string;
}
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
const normalizedFallback = ensureLeadingSlash(fallback);
if (!raw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
const decodedPrimary = decodeReturnTo(raw);
if (!decodedPrimary) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
const normalizedPrimary = decodedPrimary.trim();
const wrapperPaths = [ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_PATH];
for (const wrapper of wrapperPaths) {
if (normalizedPrimary.startsWith(wrapper)) {
try {
const url = new URL(normalizedPrimary, window.location.origin);
const innerRaw = url.searchParams.get('return_to');
if (!innerRaw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
return resolveReturnTarget(innerRaw, normalizedFallback);
} catch (error) {
console.warn('[Auth] Failed to parse return_to chain', error);
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
}
}
}
const finalTarget = ensureLeadingSlash(normalizedPrimary);
const encodedFinal = encodeReturnTo(finalTarget);
return { finalTarget, encodedFinal };
}
export function buildAdminOAuthStartPath(targetPath: string, encodedTarget?: string): string {
const sanitizedTarget = ensureLeadingSlash(targetPath);
const encoded = encodedTarget ?? encodeReturnTo(sanitizedTarget);
const url = new URL(ADMIN_LOGIN_START_PATH, window.location.origin);
url.searchParams.set('return_to', encoded);
return `${url.pathname}${url.search}`;
}
function resolveLocale(): string {
const raw = document.documentElement.lang || 'de';
const normalized = raw.toLowerCase();
if (normalized.includes('-')) {
return normalized.split('-')[0] || 'de';
}
return normalized || 'de';
}
export function buildMarketingLoginUrl(returnPath: string): string {
const sanitizedPath = ensureLeadingSlash(returnPath);
const encoded = encodeReturnTo(sanitizedPath);
const locale = resolveLocale();
const loginPath = `/${locale}/login`;
const url = new URL(loginPath, window.location.origin);
url.searchParams.set('return_to', encoded);
return url.toString();
}
export function storeLastDestination(path: string): void {
if (typeof window === 'undefined') {
return;
}
const sanitized = ensureLeadingSlash(path.trim());
try {
window.sessionStorage.setItem(LAST_DESTINATION_KEY, sanitized);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to store last destination', error);
}
}
}
export function consumeLastDestination(): string | null {
if (typeof window === 'undefined') {
return null;
}
try {
const value = window.sessionStorage.getItem(LAST_DESTINATION_KEY);
if (value) {
window.sessionStorage.removeItem(LAST_DESTINATION_KEY);
return ensureLeadingSlash(value);
}
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to read last destination', error);
}
}
return null;
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
import { isAuthError } from '../auth/tokens';
import { ADMIN_HOME_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
export default function AuthCallbackPage() {
const { completeLogin } = useAuth();
@@ -19,7 +19,7 @@ export default function AuthCallbackPage() {
const params = new URLSearchParams(window.location.search);
completeLogin(params)
.then((redirectTo) => {
navigate(redirectTo ?? ADMIN_HOME_PATH, { replace: true });
navigate(redirectTo ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH, { replace: true });
})
.catch((err) => {
console.error('[Auth] Callback processing failed', err);
@@ -40,4 +40,3 @@ export default function AuthCallbackPage() {
</div>
);
}

View File

@@ -1,204 +1,223 @@
import React from 'react';
import { Location, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles, ShieldCheck, Images, ArrowRight, Loader2 } from 'lucide-react';
import { ArrowRight, Loader2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import AppLogoIcon from '@/components/app-logo-icon';
import { useAuth } from '../auth/context';
import { ADMIN_HOME_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
interface LocationState {
from?: Location;
}
const featureIcons = [Sparkles, ShieldCheck, Images];
export default function LoginPage(): JSX.Element {
const { status, login } = 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 { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
);
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(ADMIN_HOME_PATH, { replace: true });
navigate(finalTarget, { replace: true });
}
}, [status, navigate]);
}, [finalTarget, navigate, status]);
const redirectTarget = React.useMemo(() => {
if (finalTarget) {
return finalTarget;
}
const state = location.state as LocationState | null;
if (state?.from) {
const from = state.from;
const search = from.search ?? '';
const hash = from.hash ?? '';
return `${from.pathname}${search}${hash}`;
const path = `${from.pathname}${search}${hash}`;
return path.startsWith('/') ? path : `/${path}`;
}
return ADMIN_HOME_PATH;
}, [location.state]);
const featureList = React.useMemo(() => {
const raw = t('login.features', { returnObjects: true }) as unknown;
if (!Array.isArray(raw)) {
return [] as Array<{ text: string; Icon: typeof Sparkles }>;
}
return (raw as string[]).map((entry, index) => ({
text: entry,
Icon: featureIcons[index % featureIcons.length],
}));
}, [t]);
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}, [finalTarget, location.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]);
return (
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div className="relative min-h-svh overflow-hidden bg-slate-950 px-4 py-16 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 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
<AppLogoIcon className="h-7 w-7 text-white" />
</span>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
<p className="text-lg font-semibold">Fotospiel</p>
</div>
</div>
<AppearanceToggleDropdown />
</header>
<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">
<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>
<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.')}
</p>
</div>
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
<div className="mb-10 space-y-5 text-center md:hidden">
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
{heroTagline}
</span>
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
{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}
<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.')}
</p>
</div>
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
</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;
}
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
<Sparkles className="h-4 w-4" aria-hidden />
<span className="font-sans-marketing">{heroTagline}</span>
</div>
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>
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
{featureList.length ? (
<ul className="space-y-4">
{featureList.map(({ text, Icon }, index) => (
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="space-y-1">
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
</span>
</li>
))}
</ul>
) : null}
</div>
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
<ArrowRight className="h-4 w-4" aria-hidden />
{leadCopy}
</p>
</section>
<section className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="space-y-3">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
{t('login.badge', 'Fotospiel Event Admin')}
</span>
<div className="space-y-1">
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
</div>
</div>
{oauthError ? (
<Alert className="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>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<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={() => login(redirectTarget)}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading', 'Signing you in …')}
</>
) : (
<>
{t('login.cta', 'Continue with Fotospiel login')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
<p>{leadCopy}</p>
<p>{supportCopy}</p>
</div>
</div>
</section>
<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>
{featureList.length ? (
<div className="mt-10 grid gap-4 md:hidden">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-mobile-${index}`}
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
>
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
<Icon className="h-4 w-4" aria-hidden />
</span>
<p>{text}</p>
</div>
))}
</div>
) : null}
</main>
<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}
</div>
</div>
);
}
function GoogleIcon({ className }: { className?: string }): JSX.Element {
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>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
export default function LoginStartPage(): JSX.Element {
const { status, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
);
const [hasStarted, setHasStarted] = React.useState(false);
React.useEffect(() => {
if (status === 'authenticated') {
navigate(finalTarget, { replace: true });
return;
}
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]);
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>
</div>
);
}

View File

@@ -0,0 +1,421 @@
import React from 'react';
import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import {
fetchTenantProfile,
updateTenantProfile,
type TenantAccountProfile,
type UpdateTenantProfilePayload,
} from '../api';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
type FieldErrors = Record<string, string>;
function extractFieldErrors(error: unknown): FieldErrors {
if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') {
const entries = error.meta.errors as Record<string, unknown>;
const mapped: FieldErrors = {};
Object.entries(entries).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') {
mapped[key] = value[0];
}
});
return mapped;
}
return {};
}
const DEFAULT_LOCALES = ['de', 'en'];
const AUTO_LOCALE_OPTION = '__auto__';
export default function ProfilePage(): JSX.Element {
const { t } = useTranslation(['settings', 'common']);
const { refreshProfile } = useAuth();
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [infoForm, setInfoForm] = React.useState({
name: '',
email: '',
preferred_locale: '',
});
const [passwordForm, setPasswordForm] = React.useState({
current_password: '',
password: '',
password_confirmation: '',
});
const [infoErrors, setInfoErrors] = React.useState<FieldErrors>({});
const [passwordErrors, setPasswordErrors] = React.useState<FieldErrors>({});
const [savingInfo, setSavingInfo] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const availableLocales = React.useMemo(() => {
const candidates = new Set(DEFAULT_LOCALES);
if (typeof document !== 'undefined') {
const lang = document.documentElement.lang;
if (lang) {
const short = lang.toLowerCase().split('-')[0];
candidates.add(short);
}
}
if (profile?.preferred_locale) {
candidates.add(profile.preferred_locale.toLowerCase());
}
return Array.from(candidates).sort();
}, [profile?.preferred_locale]);
const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION;
React.useEffect(() => {
let cancelled = false;
async function loadProfile(): Promise<void> {
setLoading(true);
try {
const data = await fetchTenantProfile();
if (cancelled) {
return;
}
setProfile(data);
setInfoForm({
name: data.name ?? '',
email: data.email ?? '',
preferred_locale: data.preferred_locale ?? '',
});
} catch (error) {
if (!cancelled) {
toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, [t]);
const handleInfoSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInfoErrors({});
setSavingInfo(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.'));
setInfoForm({
name: updated.name ?? '',
email: updated.email ?? '',
preferred_locale: updated.preferred_locale ?? '',
});
setPasswordForm((prev) => ({ ...prev, current_password: '' }));
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setInfoErrors(fieldErrors);
}
} finally {
setSavingInfo(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t]
);
const handlePasswordSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPasswordErrors({});
setSavingPassword(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
current_password: passwordForm.current_password || undefined,
password: passwordForm.password || undefined,
password_confirmation: passwordForm.password_confirmation || undefined,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setPasswordErrors(fieldErrors);
}
} finally {
setSavingPassword(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t]
);
if (loading) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
{t('common:loading', 'Wird geladen …')}
</div>
</div>
</AdminLayout>
);
}
if (!profile) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="rounded-3xl border border-rose-200/60 bg-rose-50/70 p-8 text-center text-sm text-rose-600">
{t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')}
</div>
</AdminLayout>
);
}
return (
<AdminLayout
title={t('settings:profile.title', 'Profil')}
subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}
>
<Card className="border-0 bg-white/90 shadow-xl shadow-rose-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<UserIcon className="h-5 w-5 text-rose-500" />
{t('settings:profile.sections.account.heading', 'Account-Informationen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInfoSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<UserIcon className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.name', 'Anzeigename')}
</Label>
<Input
id="profile-name"
value={infoForm.name}
onChange={(event) => setInfoForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
/>
{infoErrors.name && <p className="text-sm text-rose-500">{infoErrors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Mail className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.email', 'E-Mail-Adresse')}
</Label>
<Input
id="profile-email"
type="email"
value={infoForm.email}
onChange={(event) => setInfoForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="admin@example.com"
/>
{infoErrors.email && <p className="text-sm text-rose-500">{infoErrors.email}</p>}
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-locale" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Globe className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.locale', 'Bevorzugte Sprache')}
</Label>
<Select
value={selectedLocale}
onValueChange={(value) =>
setInfoForm((prev) => ({ ...prev, preferred_locale: value === AUTO_LOCALE_OPTION ? '' : value }))
}
>
<SelectTrigger id="profile-locale" aria-label={t('settings:profile.fields.locale', 'Bevorzugte Sprache')}>
<SelectValue placeholder={t('settings:profile.placeholders.locale', 'Systemsprache verwenden')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_LOCALE_OPTION}>{t('settings:profile.locale.auto', 'Automatisch')}</SelectItem>
{availableLocales.map((locale) => (
<SelectItem key={locale} value={locale} className="capitalize">
{locale}
</SelectItem>
))}
</SelectContent>
</Select>
{infoErrors.preferred_locale && <p className="text-sm text-rose-500">{infoErrors.preferred_locale}</p>}
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-semibold text-slate-800">
{profile.email_verified ? (
<>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
{t('settings:profile.status.emailVerified', 'E-Mail bestätigt')}
</>
) : (
<>
<ShieldX className="h-4 w-4 text-rose-500" />
{t('settings:profile.status.emailNotVerified', 'Bestätigung erforderlich')}
</>
)}
</Label>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-3 text-sm text-slate-600">
{profile.email_verified
? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', {
date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '',
})
: t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={savingInfo} className="flex items-center gap-2">
{savingInfo && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.save', 'Speichern')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
if (!profile) {
return;
}
setInfoForm({
name: profile.name ?? '',
email: profile.email ?? '',
preferred_locale: profile.preferred_locale ?? '',
});
setInfoErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Lock className="h-5 w-5 text-indigo-500" />
{t('settings:profile.sections.password.heading', 'Passwort ändern')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="profile-current-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.currentPassword', 'Aktuelles Passwort')}
</Label>
<Input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current_password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))}
/>
{passwordErrors.current_password && <p className="text-sm text-rose-500">{passwordErrors.current_password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-new-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.newPassword', 'Neues Passwort')}
</Label>
<Input
id="profile-new-password"
type="password"
autoComplete="new-password"
value={passwordForm.password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password: event.target.value }))}
/>
{passwordErrors.password && <p className="text-sm text-rose-500">{passwordErrors.password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-password-confirmation" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.passwordConfirmation', 'Passwort bestätigen')}
</Label>
<Input
id="profile-password-confirmation"
type="password"
autoComplete="new-password"
value={passwordForm.password_confirmation}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))}
/>
{passwordErrors.password_confirmation && <p className="text-sm text-rose-500">{passwordErrors.password_confirmation}</p>}
</div>
</div>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-4 text-xs text-slate-600">
{t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')}
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" variant="secondary" disabled={savingPassword} className="flex items-center gap-2">
{savingPassword && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.updatePassword', 'Passwort aktualisieren')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
setPasswordErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { AlertTriangle, LogOut, Palette } from 'lucide-react';
import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
@@ -10,7 +10,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
import {
getNotificationPreferences,
updateNotificationPreferences,
@@ -33,7 +34,10 @@ export default function SettingsPage() {
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
function handleLogout() {
logout({ redirect: ADMIN_LOGIN_PATH });
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
let marketingUrl = buildMarketingLoginUrl(targetPath);
marketingUrl += marketingUrl.includes('?') ? '&reset-auth=1' : '?reset-auth=1';
logout({ redirect: marketingUrl });
}
React.useEffect(() => {
@@ -101,6 +105,9 @@ export default function SettingsPage() {
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
<LogOut className="h-4 w-4" /> Abmelden
</Button>
<Button variant="secondary" onClick={() => navigate(ADMIN_PROFILE_PATH)} className="flex items-center gap-2">
<UserCog className="h-4 w-4" /> {t('settings.profile.actions.openProfile', 'Profil bearbeiten')}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
Abbrechen
</Button>

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ADMIN_HOME_PATH } from '../constants';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget } from '../lib/returnTo';
const highlights = [
{
@@ -27,8 +25,23 @@ const highlights = [
];
export default function WelcomeTeaserPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [isRedirecting, setIsRedirecting] = React.useState(false);
const handleLoginRedirect = React.useCallback(() => {
if (isRedirecting) {
return;
}
setIsRedirecting(true);
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;
}, [isRedirecting]);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
@@ -48,9 +61,10 @@ export default function WelcomeTeaserPage() {
<button
type="button"
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
onClick={() => login(ADMIN_HOME_PATH)}
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
Ich habe bereits Zugang
{isRedirecting ? 'Weiterleitung ...' : 'Ich habe bereits Zugang'}
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
</button>
</div>

View File

@@ -18,14 +18,19 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage';
import EmotionsPage from './pages/EmotionsPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
import LoginStartPage from './pages/LoginStartPage';
import ProfilePage from './pages/ProfilePage';
import LogoutPage from './pages/LogoutPage';
import { useAuth } from './auth/context';
import {
ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH,
ADMIN_PUBLIC_LANDING_PATH,
} from './constants';
import { consumeLastDestination } from './lib/returnTo';
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
import WelcomeEventSetupPage from './onboarding/pages/WelcomeEventSetupPage';
@@ -44,7 +49,7 @@ function RequireAuth() {
}
if (status === 'unauthenticated') {
return <Navigate to={ADMIN_LOGIN_PATH} state={{ from: location }} replace />;
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
}
return <Outlet />;
@@ -52,6 +57,7 @@ function RequireAuth() {
function LandingGate() {
const { status } = useAuth();
const lastDestinationRef = React.useRef<string | null>(null);
if (status === 'loading') {
return (
@@ -62,7 +68,11 @@ function LandingGate() {
}
if (status === 'authenticated') {
return <Navigate to={ADMIN_HOME_PATH} replace />;
if (lastDestinationRef.current === null) {
lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}
return <Navigate to={lastDestinationRef.current ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
}
return <WelcomeTeaserPage />;
@@ -75,6 +85,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'start', element: <LoginStartPage /> },
{ path: 'logout', element: <LogoutPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
{
@@ -96,6 +107,7 @@ export const router = createBrowserRouter([
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'settings/profile', element: <ProfilePage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },