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