stage 1 of oauth removal, switch to sanctum pat tokens

This commit is contained in:
Codex Agent
2025-11-06 20:35:58 +01:00
parent c9783bd57b
commit 776da57ca9
47 changed files with 1571 additions and 2555 deletions

View File

@@ -838,10 +838,17 @@ function eventEndpoint(slug: string): string {
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
}
export async function getEvents(): Promise<TenantEvent[]> {
const response = await authorizedFetch('/api/v1/tenant/events');
const data = await jsonOrThrow<EventListResponse>(response, 'Failed to load events');
return (data.data ?? []).map(normalizeEvent);
export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> {
return cachedFetch(
CacheKeys.events,
async () => {
const response = await authorizedFetch('/api/v1/tenant/events');
const data = await jsonOrThrow<EventListResponse>(response, 'Failed to load events');
return (data.data ?? []).map(normalizeEvent);
},
DEFAULT_CACHE_TTL,
options?.force === true,
);
}
export async function createEvent(payload: EventSavePayload): Promise<{ event: TenantEvent; balance: number }> {
@@ -851,7 +858,9 @@ export async function createEvent(payload: EventSavePayload): Promise<{ event: T
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<CreatedEventResponse>(response, 'Failed to create event');
return { event: normalizeEvent(data.data), balance: data.balance };
const result = { event: normalizeEvent(data.data), balance: data.balance };
invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]);
return result;
}
export async function updateEvent(slug: string, payload: Partial<EventSavePayload>): Promise<TenantEvent> {
@@ -861,7 +870,9 @@ export async function updateEvent(slug: string, payload: Partial<EventSavePayloa
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<EventResponse>(response, 'Failed to update event');
return normalizeEvent(data.data);
const event = normalizeEvent(data.data);
invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]);
return event;
}
export async function getEvent(slug: string): Promise<TenantEvent> {
@@ -1130,38 +1141,52 @@ async function fetchTenantPackagesEndpoint(): Promise<Response> {
return first;
}
export async function getDashboardSummary(): Promise<DashboardSummary | null> {
const response = await authorizedFetch('/api/v1/tenant/dashboard');
if (response.status === 404) {
return null;
}
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load dashboard', response.status, payload);
throw new Error(fallbackMessage);
}
const json = (await response.json()) as JsonValue;
return normalizeDashboard(json);
export async function getDashboardSummary(options?: { force?: boolean }): Promise<DashboardSummary | null> {
return cachedFetch(
CacheKeys.dashboard,
async () => {
const response = await authorizedFetch('/api/v1/tenant/dashboard');
if (response.status === 404) {
return null;
}
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load dashboard', response.status, payload);
throw new Error(fallbackMessage);
}
const json = (await response.json()) as JsonValue;
return normalizeDashboard(json);
},
DEFAULT_CACHE_TTL,
options?.force === true,
);
}
export async function getTenantPackagesOverview(): Promise<{
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
packages: TenantPackageSummary[];
activePackage: TenantPackageSummary | null;
}> {
const response = await fetchTenantPackagesEndpoint();
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load tenant packages', response.status, payload);
throw new Error(fallbackMessage);
}
const data = (await response.json()) as TenantPackagesResponse;
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null;
return { packages, activePackage };
return cachedFetch(
CacheKeys.packages,
async () => {
const response = await fetchTenantPackagesEndpoint();
if (!response.ok) {
const payload = await safeJson(response);
const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
emitApiErrorEvent({ message: fallbackMessage, status: response.status });
console.error('[API] Failed to load tenant packages', response.status, payload);
throw new Error(fallbackMessage);
}
const data = (await response.json()) as TenantPackagesResponse;
const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : [];
const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null;
return { packages, activePackage };
},
DEFAULT_CACHE_TTL * 5,
options?.force === true,
);
}
export type NotificationPreferenceResponse = {
@@ -1651,3 +1676,67 @@ export async function removeEventMember(eventIdentifier: number | string, member
throw new Error('Failed to remove member');
}
}
type CacheEntry<T> = {
value?: T;
expiresAt: number;
promise?: Promise<T>;
};
const tenantApiCache = new Map<string, CacheEntry<unknown>>();
const DEFAULT_CACHE_TTL = 60_000;
const CacheKeys = {
dashboard: 'tenant:dashboard',
events: 'tenant:events',
packages: 'tenant:packages',
} as const;
function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = DEFAULT_CACHE_TTL,
force = false,
): Promise<T> {
if (force) {
tenantApiCache.delete(key);
}
const now = Date.now();
const existing = tenantApiCache.get(key) as CacheEntry<T> | undefined;
if (!force && existing) {
if (existing.promise) {
return existing.promise;
}
if (existing.value !== undefined && existing.expiresAt > now) {
return Promise.resolve(existing.value);
}
}
const promise = fetcher()
.then((value) => {
tenantApiCache.set(key, { value, expiresAt: Date.now() + ttl });
return value;
})
.catch((error) => {
tenantApiCache.delete(key);
throw error;
});
tenantApiCache.set(key, { value: existing?.value, expiresAt: existing?.expiresAt ?? 0, promise });
return promise;
}
export function invalidateTenantApiCache(keys?: string | string[]): void {
if (!keys) {
tenantApiCache.clear();
return;
}
const entries = Array.isArray(keys) ? keys : [keys];
for (const key of entries) {
tenantApiCache.delete(key);
}
}

View File

@@ -1,14 +1,14 @@
import React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
authorizedFetch,
clearOAuthSession,
clearTokens,
completeOAuthCallback,
loadTokens,
isAuthError,
loadToken,
registerAuthFailureHandler,
startOAuthFlow,
storePersonalAccessToken,
} from './tokens';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { invalidateTenantApiCache } from '../api';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
@@ -18,30 +18,74 @@ export interface TenantProfile {
name?: string;
slug?: string;
email?: string | null;
event_credits_balance?: number;
event_credits_balance?: number | null;
[key: string]: unknown;
}
interface AuthContextValue {
status: AuthStatus;
user: TenantProfile | null;
login: (redirectPath?: string) => void;
logout: (options?: { redirect?: string }) => void;
completeLogin: (params: URLSearchParams) => Promise<string | null>;
refreshProfile: () => Promise<void>;
logout: (options?: { redirect?: string }) => Promise<void>;
applyToken: (token: string, abilities: string[]) => Promise<void>;
}
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
function getCsrfToken(): string | undefined {
const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null;
return meta?.content;
}
async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> {
const csrf = getCsrfToken();
try {
const response = await fetch('/api/v1/tenant-auth/exchange', {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
credentials: 'same-origin',
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as { token: string; abilities?: string[] };
if (!data?.token) {
return null;
}
return {
token: data.token,
abilities: Array.isArray(data.abilities) ? data.abilities : [],
};
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Session exchange failed', error);
}
return null;
}
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<TenantProfile | null>(null);
const queryClient = useQueryClient();
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
const handleAuthFailure = React.useCallback(() => {
clearTokens();
invalidateTenantApiCache();
queryClient.removeQueries({ queryKey: profileQueryKey });
setUser(null);
setStatus('unauthenticated');
}, []);
}, [profileQueryKey, queryClient]);
React.useEffect(() => {
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
@@ -49,92 +93,133 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [handleAuthFailure]);
const refreshProfile = React.useCallback(async () => {
setStatus('loading');
try {
const response = await authorizedFetch('/api/v1/tenant/me');
if (!response.ok) {
throw new Error('Failed to load profile');
}
const profile = (await response.json()) as TenantProfile;
setUser(profile);
const data = await queryClient.fetchQuery({
queryKey: profileQueryKey,
queryFn: async () => {
const response = await authorizedFetch('/api/v1/tenant-auth/me', {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch tenant profile');
}
return (await response.json()) as {
user: TenantProfile | null;
tenant?: Record<string, unknown> | null;
abilities: string[];
};
},
staleTime: 1000 * 60 * 5,
cacheTime: 1000 * 60 * 30,
retry: 1,
});
const composed: TenantProfile | null = data.user && data.tenant
? { ...data.user, ...data.tenant }
: data.user;
setUser(composed ?? null);
setStatus('authenticated');
} catch (error) {
console.error('[Auth] Failed to refresh profile', error);
handleAuthFailure();
if (isAuthError(error)) {
handleAuthFailure();
} else {
setStatus('unauthenticated');
}
throw error;
}
}, [handleAuthFailure]);
}, [handleAuthFailure, profileQueryKey, queryClient]);
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
storePersonalAccessToken(token, abilities);
await refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) {
clearTokens();
clearOAuthSession();
setUser(null);
setStatus('unauthenticated');
}
let cancelled = false;
const tokens = loadTokens();
if (!tokens) {
setUser(null);
setStatus('unauthenticated');
return;
}
const bootstrap = async () => {
const stored = loadToken();
if (stored) {
try {
await refreshProfile();
return;
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Stored token bootstrap failed', error);
}
}
}
refreshProfile().catch(() => {
// refreshProfile already handled failures.
const exchanged = await exchangeSessionForToken();
if (cancelled) {
return;
}
if (exchanged) {
await applyToken(exchanged.token, exchanged.abilities);
return;
}
handleAuthFailure();
};
bootstrap().catch((error) => {
if (import.meta.env.DEV) {
console.error('[Auth] Failed to bootstrap authentication', error);
}
handleAuthFailure();
});
}, [handleAuthFailure, refreshProfile]);
const login = React.useCallback((redirectPath?: string) => {
const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`;
startOAuthFlow(target);
}, []);
return () => {
cancelled = true;
};
}, [applyToken, handleAuthFailure, refreshProfile]);
const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => {
try {
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
await fetch('/logout', {
await authorizedFetch('/api/v1/tenant-auth/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);
console.warn('[Auth] API logout failed', error);
}
} finally {
clearTokens();
clearOAuthSession();
setUser(null);
setStatus('unauthenticated');
try {
const csrf = getCsrfToken();
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] Session logout failed', error);
}
}
handleAuthFailure();
if (redirect) {
window.location.href = redirect;
}
}
}, []);
const completeLogin = React.useCallback(
async (params: URLSearchParams) => {
setStatus('loading');
try {
const redirectTarget = await completeOAuthCallback(params);
await refreshProfile();
return redirectTarget;
} catch (error) {
handleAuthFailure();
throw error;
}
},
[handleAuthFailure, refreshProfile]
);
}, [handleAuthFailure]);
const value = React.useMemo<AuthContextValue>(
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
[status, user, login, logout, completeLogin, refreshProfile]
() => ({ status, user, refreshProfile, logout, applyToken }),
[status, user, refreshProfile, logout, applyToken]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -1,42 +1,151 @@
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
import { decodeStoredTokens } from './utils';
import { ADMIN_AUTH_CALLBACK_PATH } from '../constants';
const TOKEN_STORAGE_KEY = 'tenant_admin.token.v1';
const TOKEN_SESSION_KEY = 'tenant_admin.token.session.v1';
const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
const STATE_KEY = 'tenant_oauth_state';
const REDIRECT_KEY = 'tenant_oauth_redirect';
const TOKEN_ENDPOINT = '/api/v1/oauth/token';
const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
function getClientId(): string {
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
if (!clientId) {
throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
}
return clientId;
}
function buildRedirectUri(): string {
return new URL(ADMIN_AUTH_CALLBACK_PATH, window.location.origin).toString();
}
export type StoredToken = {
accessToken: string;
abilities: string[];
issuedAt: number;
};
export class AuthError extends Error {
constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
constructor(public code: 'unauthenticated' | 'unauthorized', message?: string) {
super(message ?? code);
this.name = 'AuthError';
}
}
export function isAuthError(value: unknown): value is AuthError {
return value instanceof AuthError;
let cachedToken: StoredToken | null = null;
function decodeStoredToken(raw: string | null): StoredToken | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as StoredToken;
if (!parsed || typeof parsed.accessToken !== 'string') {
return null;
}
return {
accessToken: parsed.accessToken,
abilities: Array.isArray(parsed.abilities) ? parsed.abilities : [],
issuedAt: typeof parsed.issuedAt === 'number' ? parsed.issuedAt : Date.now(),
} satisfies StoredToken;
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to decode stored token', error);
}
return null;
}
}
function readTokenFromStorage(): StoredToken | null {
if (typeof window === 'undefined') {
return null;
}
try {
const sessionValue = window.sessionStorage.getItem(TOKEN_SESSION_KEY);
const fromSession = decodeStoredToken(sessionValue);
if (fromSession) {
return fromSession;
}
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to read session stored token', error);
}
}
try {
const persistentValue = window.localStorage.getItem(TOKEN_STORAGE_KEY);
return decodeStoredToken(persistentValue);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to read persisted token', error);
}
return null;
}
}
export function loadToken(): StoredToken | null {
if (cachedToken) {
return cachedToken;
}
const stored = readTokenFromStorage();
cachedToken = stored;
return stored;
}
function persistToken(token: StoredToken): void {
if (typeof window === 'undefined') {
cachedToken = token;
return;
}
const serialized = JSON.stringify(token);
try {
window.localStorage.setItem(TOKEN_STORAGE_KEY, serialized);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to persist tenant token to localStorage', error);
}
}
try {
window.sessionStorage.setItem(TOKEN_SESSION_KEY, serialized);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to persist tenant token to sessionStorage', error);
}
}
cachedToken = token;
}
export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken {
const stored: StoredToken = {
accessToken,
abilities,
issuedAt: Date.now(),
};
persistToken(stored);
return stored;
}
export function clearTokens(): void {
cachedToken = null;
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to remove stored tenant token', error);
}
}
try {
window.sessionStorage.removeItem(TOKEN_SESSION_KEY);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to remove session tenant token', error);
}
}
}
type AuthFailureHandler = () => void;
const authFailureHandlers = new Set<AuthFailureHandler>();
function notifyAuthFailure() {
function notifyAuthFailure(): void {
authFailureHandlers.forEach((handler) => {
try {
handler();
@@ -53,214 +162,30 @@ export function registerAuthFailureHandler(handler: AuthFailureHandler): () => v
};
}
export interface StoredTokens {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope?: string;
clientId?: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
scope?: string;
}
export function loadTokens(): StoredTokens | null {
const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
const stored = decodeStoredTokens<StoredTokens>(raw);
if (!stored) {
return null;
}
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
clearTokens();
return null;
}
return stored;
}
export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens {
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
const stored: StoredTokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt,
scope: response.scope,
clientId,
};
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
return stored;
}
export function clearTokens(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY);
}
export async function ensureAccessToken(): Promise<string> {
const tokens = loadTokens();
if (!tokens) {
notifyAuthFailure();
throw new AuthError('unauthenticated', 'No tokens available');
}
if (Date.now() < tokens.expiresAt) {
return tokens.accessToken;
}
return refreshAccessToken(tokens);
}
async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
const clientId = tokens.clientId ?? getClientId();
if (!tokens.refreshToken) {
notifyAuthFailure();
throw new AuthError('unauthenticated', 'Missing refresh token');
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: clientId,
});
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
if (!response.ok) {
console.warn('[Auth] Refresh token request failed', response.status);
notifyAuthFailure();
throw new AuthError('unauthenticated', 'Refresh token invalid');
}
const data = (await response.json()) as TokenResponse;
const stored = saveTokens(data, clientId);
return stored.accessToken;
export function isAuthError(value: unknown): value is AuthError {
return value instanceof AuthError;
}
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const token = await ensureAccessToken();
const stored = loadToken();
if (!stored) {
notifyAuthFailure();
throw new AuthError('unauthenticated', 'No active tenant admin token');
}
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
headers.set('Authorization', `Bearer ${stored.accessToken}`);
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
const response = await fetch(input, { ...init, headers });
if (response.status === 401) {
clearTokens();
notifyAuthFailure();
throw new AuthError('unauthorized', 'Access token rejected');
throw new AuthError('unauthorized', 'Token rejected by API');
}
return response;
}
export async function startOAuthFlow(redirectPath?: string): Promise<void> {
const verifier = generateCodeVerifier();
const state = generateState();
const challenge = await generateCodeChallenge(verifier);
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
sessionStorage.setItem(STATE_KEY, state);
localStorage.setItem(CODE_VERIFIER_KEY, verifier);
localStorage.setItem(STATE_KEY, state);
if (redirectPath) {
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
localStorage.setItem(REDIRECT_KEY, redirectPath);
}
if (import.meta.env.DEV) {
console.debug('[Auth] PKCE store', { state, verifier, redirectPath });
}
const params = new URLSearchParams({
response_type: 'code',
client_id: getClientId(),
redirect_uri: buildRedirectUri(),
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
}
export async function completeOAuthCallback(params: URLSearchParams): Promise<string | null> {
if (params.get('error')) {
throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
}
const code = params.get('code');
const returnedState = params.get('state');
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY);
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
if (import.meta.env.DEV) {
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
}
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
clearOAuthSession();
notifyAuthFailure();
throw new AuthError('invalid_state', 'PKCE state mismatch');
}
sessionStorage.removeItem(CODE_VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
localStorage.removeItem(CODE_VERIFIER_KEY);
localStorage.removeItem(STATE_KEY);
const clientId = getClientId();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: buildRedirectUri(),
code_verifier: verifier,
});
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) {
clearOAuthSession();
console.error('[Auth] Authorization code exchange failed', response.status);
notifyAuthFailure();
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
}
const data = (await response.json()) as TokenResponse;
saveTokens(data, clientId);
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
if (redirectTarget) {
sessionStorage.removeItem(REDIRECT_KEY);
localStorage.removeItem(REDIRECT_KEY);
} else {
localStorage.removeItem(REDIRECT_KEY);
}
return redirectTarget;
}
export function clearOAuthSession(): void {
sessionStorage.removeItem(CODE_VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
sessionStorage.removeItem(REDIRECT_KEY);
localStorage.removeItem(CODE_VERIFIER_KEY);
localStorage.removeItem(STATE_KEY);
localStorage.removeItem(REDIRECT_KEY);
}

View File

@@ -19,6 +19,7 @@ import {
} from 'lucide-react';
import { LanguageSwitcher } from './LanguageSwitcher';
import { registerApiErrorListener } from '../lib/apiError';
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
@@ -37,6 +38,39 @@ interface AdminLayoutProps {
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
const { t } = useTranslation('common');
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () =>
Promise.all([
getDashboardSummary(),
getEvents(),
getTenantPackagesOverview(),
]).then(() => undefined),
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
[ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined),
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
}), []);
const triggerPrefetch = React.useCallback(
(path: string) => {
if (prefetchedPathsRef.current.has(path)) {
return;
}
const runner = prefetchers[path as keyof typeof prefetchers];
if (!runner) {
return;
}
prefetchedPathsRef.current.add(path);
Promise.resolve(runner()).catch(() => {
prefetchedPathsRef.current.delete(path);
});
},
[prefetchers],
);
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme');
@@ -78,18 +112,21 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
{actions}
</div>
</div>
<nav className="hidden items-center gap-2 border-t border-white/20 px-6 py-4 md:flex">
<nav className="hidden items-center gap-2 border-t border-slate-200/70 px-6 py-4 md:flex">
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onPointerEnter={() => triggerPrefetch(to)}
onFocus={() => triggerPrefetch(to)}
onTouchStart={() => triggerPrefetch(to)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950',
isActive
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900'
? 'bg-rose-600 text-white shadow-md shadow-rose-400/30'
: 'border border-slate-200/80 bg-white text-slate-700 hover:bg-rose-50/80 hover:text-rose-700'
)
}
>
@@ -101,7 +138,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
</div>
</header>
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-28 pt-6 sm:px-6">
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+6.5rem)] pt-6 sm:px-6 md:pb-16">
<div className="grid gap-6">{children}</div>
</main>
@@ -115,26 +152,35 @@ function TenantMobileNav({ items }: { items: typeof navItems }) {
const { t } = useTranslation('common');
return (
<nav className="sticky bottom-4 z-40 px-4 pb-2 md:hidden">
<div className="mx-auto flex w-full max-w-md items-center justify-around rounded-full border border-white/20 bg-white/90 p-2 text-slate-600 shadow-2xl shadow-rose-300/20 backdrop-blur-xl">
{items.map(({ to, labelKey, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 rounded-full px-3 py-2 text-xs font-medium transition',
isActive
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
: 'text-slate-600 hover:text-slate-900'
)
}
>
<Icon className="h-5 w-5" />
<span>{t(labelKey)}</span>
</NavLink>
))}
<nav className="md:hidden" aria-label={t('navigation.mobile', { defaultValue: 'Tenant Navigation' })}>
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-16 bg-gradient-to-t from-slate-950/35 via-transparent to-transparent dark:from-black/60"
/>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-xl items-center justify-around gap-1">
{items.map(({ to, labelKey, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onPointerEnter={() => triggerPrefetch(to)}
onFocus={() => triggerPrefetch(to)}
onTouchStart={() => triggerPrefetch(to)}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
isActive
? 'bg-rose-600 text-white shadow-md shadow-rose-400/25'
: 'hover:text-rose-700 dark:hover:text-rose-200'
)
}
>
<Icon className="h-5 w-5" />
<span>{t(labelKey)}</span>
</NavLink>
))}
</div>
</div>
</nav>
);

View File

@@ -29,30 +29,31 @@ export function TenantHeroCard({
return (
<Card
className={cn(
'relative overflow-hidden border border-white/15 bg-white/95 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl',
'dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-100',
'relative overflow-hidden border border-slate-200/80 bg-white text-slate-900 shadow-xl shadow-rose-200/30 backdrop-blur',
'dark:border-white/10 dark:bg-slate-950/90 dark:text-slate-100',
className
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.3),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite]"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.18),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.16),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite] dark:hidden"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay" />
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-rose-50/70 via-white to-sky-50/70 dark:hidden" />
<div aria-hidden className="absolute inset-0 hidden bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay dark:block" />
<CardContent className="relative z-10 flex flex-col gap-8 px-6 py-8 lg:flex-row lg:items-start lg:justify-between lg:px-10 lg:py-12">
<div className="max-w-2xl space-y-6 text-white">
<div className="max-w-2xl space-y-6">
{badge ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/40 bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em]">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50/80 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-600 dark:border-white/30 dark:bg-white/15 dark:text-white">
{badge}
</span>
) : null}
<div className="space-y-3">
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">{title}</h1>
{description ? <p className="text-sm text-white/80 sm:text-base">{description}</p> : null}
<div className="space-y-3 text-slate-700 dark:text-slate-100">
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">{title}</h1>
{description ? <p className="text-sm text-slate-600 dark:text-white/75 sm:text-base">{description}</p> : null}
{supporting?.map((paragraph) => (
<p key={paragraph} className="text-sm text-white/75 sm:text-base">
<p key={paragraph} className="text-sm text-slate-600 dark:text-white/75 sm:text-base">
{paragraph}
</p>
))}
@@ -60,7 +61,7 @@ export function TenantHeroCard({
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-3">
{primaryAction}
{secondaryAction}
</div>
@@ -72,3 +73,15 @@ export function TenantHeroCard({
</Card>
);
}
export const tenantHeroPrimaryButtonClass = cn(
'rounded-full bg-rose-600 px-6 text-sm font-semibold text-white shadow-md shadow-rose-400/30 transition-colors',
'hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
'dark:focus-visible:ring-offset-slate-950'
);
export const tenantHeroSecondaryButtonClass = cn(
'rounded-full border border-slate-200/80 bg-white/95 px-6 text-sm font-semibold text-slate-700 shadow-sm transition-colors',
'hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
'dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:focus-visible:ring-offset-slate-950'
);

View File

@@ -1,6 +1,5 @@
export { TenantHeroCard } from './hero-card';
export { TenantHeroCard, tenantHeroPrimaryButtonClass, tenantHeroSecondaryButtonClass } from './hero-card';
export { FrostedCard, FrostedSurface, frostedCardClass } from './frosted-surface';
export { ChecklistRow } from './checklist-row';
export type { ChecklistStep } from './onboarding-checklist-card';
export { TenantOnboardingChecklistCard } from './onboarding-checklist-card';

View File

@@ -1,189 +1,65 @@
if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
type StoredTokens = {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope?: string;
clientId?: string;
const CREDENTIALS: Record<string, { login: string; password: string }> = {
lumen: { login: 'hello@lumen-moments.demo', password: 'Demo1234!' },
storycraft: { login: 'storycraft-owner@demo.fotospiel', password: 'Demo1234!' },
viewfinder: { login: 'team@viewfinder.demo', password: 'Demo1234!' },
pixel: { login: 'support@pixelco.demo', password: 'Demo1234!' },
};
const CLIENTS: Record<string, string> = {
lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
storycraft: 'demo-tenant-admin-storycraft',
viewfinder: 'demo-tenant-admin-viewfinder',
pixel: 'demo-tenant-admin-pixel',
};
const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
const baseUrl = window.location.origin;
const redirectUri = `${baseUrl}/event-admin/auth/callback`;
async function loginAs(label: string): Promise<void> {
const clientId = CLIENTS[label];
if (!clientId) {
console.warn('[DevAuth] Unknown tenant key', label);
async function loginAs(key: string): Promise<void> {
const credentials = CREDENTIALS[key];
if (!credentials) {
console.warn('[DevAuth] Unknown tenant key', key);
return;
}
try {
const tokens = await fetchTokens(clientId);
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
const response = await fetch('/api/v1/tenant-auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
login: credentials.login,
password: credentials.password,
}),
});
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
}
const payload = (await response.json()) as { token: string; abilities?: string[] };
const stored = {
accessToken: payload.token,
abilities: Array.isArray(payload.abilities) ? payload.abilities : [],
issuedAt: Date.now(),
} satisfies { accessToken: string; abilities: string[]; issuedAt: number };
try {
window.localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
} catch (error) {
console.warn('[DevAuth] Failed to persist PAT to localStorage', error);
}
try {
window.sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
} catch (error) {
console.warn('[DevAuth] Failed to persist PAT to sessionStorage', error);
}
window.location.assign('/event-admin/dashboard');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[DevAuth] Failed to login', message);
console.error('[DevAuth] Demo login failed', message);
throw error instanceof Error ? error : new Error(message);
}
}
async function fetchTokens(clientId: string): Promise<StoredTokens> {
const verifier = randomString(32);
const challenge = await sha256(verifier);
const state = randomString(12);
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
verifyState(callbackUrl.searchParams.get('state'), state);
const code = callbackUrl.searchParams.get('code');
if (!code) {
throw new Error('Authorize response missing code');
}
const tokenResponse = await fetch('/api/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed with ${tokenResponse.status}`);
}
const body = await tokenResponse.json();
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
scope: body.scope,
clientId,
};
}
function randomString(bytes: number): string {
const buffer = new Uint8Array(bytes);
crypto.getRandomValues(buffer);
return base64Url(buffer);
}
async function sha256(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64Url(new Uint8Array(digest));
}
function base64Url(data: Uint8Array): string {
const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
const api = { loginAs, clients: CLIENTS };
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
const api = { loginAs, clients: Object.keys(CREDENTIALS) };
console.info('[DevAuth] Demo tenant helpers ready', api.clients);
(window as typeof window & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
(globalThis as typeof globalThis & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
}
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
const requestUrl = new URL(url, window.location.origin);
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
const isSuccess = (xhr.status >= 200 && xhr.status < 400) || xhr.status === 0;
if (!isSuccess) {
reject(new Error(`Authorize failed with ${xhr.status}`));
return;
}
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
if (contentType.includes('application/json')) {
try {
const payload = JSON.parse(xhr.responseText ?? '{}') as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
}
const finalUrl = new URL(target, window.location.origin);
if (payload.code && !finalUrl.searchParams.has('code')) {
finalUrl.searchParams.set('code', payload.code);
}
if (payload.state && !finalUrl.searchParams.has('state')) {
finalUrl.searchParams.set('state', payload.state);
}
resolve(finalUrl);
return;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
}
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if (responseUrl) {
const finalUrl = new URL(responseUrl, window.location.origin);
if (finalUrl.searchParams.has('code') || finalUrl.toString() !== requestUrl.toString()) {
resolve(finalUrl);
return;
}
}
if (fallbackRedirect) {
resolve(new URL(fallbackRedirect, window.location.origin));
return;
}
reject(new Error('Authorize response missing redirect target'));
};
xhr.onerror = () => reject(new Error('Authorize request failed'));
xhr.send();
});
}
function verifyState(returnedState: string | null, expectedState: string): void {
if (returnedState && returnedState !== expectedState) {
throw new Error('Authorize state mismatch');
}
}

View File

@@ -1,8 +1,5 @@
import { ADMIN_BASE_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
const DASHBOARD_PREFIX = '/dashboard';
function ensureLeadingSlash(target: string): string {
if (!target) {
return '/';
@@ -15,14 +12,6 @@ function ensureLeadingSlash(target: string): string {
return target.startsWith('/') ? target : `/${target}`;
}
function matchesDashboardScope(path: string): boolean {
return (
path === DASHBOARD_PREFIX ||
path.startsWith(`${DASHBOARD_PREFIX}?`) ||
path.startsWith(`${DASHBOARD_PREFIX}/`)
);
}
export function isPermittedReturnTarget(target: string): boolean {
if (!target) {
return false;
@@ -30,11 +19,20 @@ export function isPermittedReturnTarget(target: string): boolean {
const sanitized = ensureLeadingSlash(target);
if (sanitized.startsWith(ADMIN_BASE_PATH)) {
return true;
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(sanitized)) {
try {
const url = new URL(sanitized);
return url.pathname.startsWith(ADMIN_BASE_PATH);
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[Auth] Failed to parse return target URL', error);
}
return false;
}
}
return matchesDashboardScope(sanitized);
return sanitized.startsWith(ADMIN_BASE_PATH);
}
function base64UrlEncode(value: string): string {
@@ -135,69 +133,3 @@ export function resolveReturnTarget(raw: string | null, fallback: string): Retur
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

@@ -16,7 +16,20 @@ const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TEN
initializeTheme();
const rootEl = document.getElementById('root')!;
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
cacheTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 1,
},
mutations: {
retry: 1,
},
},
});
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {

View File

@@ -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>
);
}

View File

@@ -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')}

View File

@@ -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}

View File

@@ -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')}

View File

@@ -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" />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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(() => {

View File

@@ -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 (

View File

@@ -9,7 +9,6 @@ import {
ADMIN_LOGIN_START_PATH,
ADMIN_PUBLIC_LANDING_PATH,
} from './constants';
import { consumeLastDestination } from './lib/returnTo';
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
const EventsPage = React.lazy(() => import('./pages/EventsPage'));
@@ -57,7 +56,6 @@ function RequireAuth() {
function LandingGate() {
const { status } = useAuth();
const lastDestinationRef = React.useRef<string | null>(null);
if (status === 'loading') {
return (
@@ -68,11 +66,7 @@ function LandingGate() {
}
if (status === 'authenticated') {
if (lastDestinationRef.current === null) {
lastDestinationRef.current = consumeLastDestination() ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}
return <Navigate to={lastDestinationRef.current ?? ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
}
return <WelcomeTeaserPage />;