Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty

states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
    (resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
  - Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
    routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
    resources/js/admin/router.tsx, routes/web.php)
This commit is contained in:
Codex Agent
2025-10-19 23:00:47 +02:00
parent a949c8d3af
commit 6290a3a448
95 changed files with 3708 additions and 394 deletions

View File

@@ -666,7 +666,7 @@ export type Package = {
};
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
const response = await authorizedFetch(`/api/v1/packages?type=${type}`);
const response = await authorizedFetch(`/api/v1/tenant/packages?type=${type}`);
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
return data.data ?? [];
}

View File

@@ -4,11 +4,11 @@ import {
clearOAuthSession,
clearTokens,
completeOAuthCallback,
isAuthError,
loadTokens,
registerAuthFailureHandler,
startOAuthFlow,
} from './tokens';
import { ADMIN_LOGIN_PATH } from '../constants';
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
@@ -58,18 +58,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser(profile);
setStatus('authenticated');
} catch (error) {
if (isAuthError(error)) {
handleAuthFailure();
} else {
console.error('[Auth] Failed to refresh profile', error);
}
console.error('[Auth] Failed to refresh profile', error);
handleAuthFailure();
throw error;
}
}, [handleAuthFailure]);
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');
}
const tokens = loadTokens();
if (!tokens) {
setUser(null);
setStatus('unauthenticated');
return;
}
@@ -77,7 +83,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
refreshProfile().catch(() => {
// refreshProfile already handled failures.
});
}, [refreshProfile]);
}, [handleAuthFailure, refreshProfile]);
const login = React.useCallback((redirectPath?: string) => {
const target = redirectPath ?? window.location.pathname + window.location.search;

View File

@@ -166,8 +166,15 @@ export async function startOAuthFlow(redirectPath?: string): Promise<void> {
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({
@@ -190,16 +197,23 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
const code = params.get('code');
const returnedState = params.get('state');
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
const expectedState = sessionStorage.getItem(STATE_KEY);
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 body = new URLSearchParams({
grant_type: 'authorization_code',
@@ -216,6 +230,7 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
});
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');
@@ -227,6 +242,9 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
if (redirectTarget) {
sessionStorage.removeItem(REDIRECT_KEY);
localStorage.removeItem(REDIRECT_KEY);
} else {
localStorage.removeItem(REDIRECT_KEY);
}
return redirectTarget;
@@ -236,4 +254,7 @@ 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

@@ -35,7 +35,6 @@ import {
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
type StripeCheckoutProps = {
clientSecret: string;
@@ -268,6 +267,10 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const stripePromise = React.useMemo(
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
[stripePublishableKey]
);
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
@@ -335,7 +338,7 @@ export default function WelcomeOrderSummaryPage() {
return () => {
cancelled = true;
};
}, [requiresPayment, packageDetails, t]);
}, [requiresPayment, packageDetails, stripePromise, t]);
const priceText =
progress.selectedPackage?.priceText ??

View File

@@ -8,8 +8,14 @@ export default function AuthCallbackPage() {
const { completeLogin } = useAuth();
const navigate = useNavigate();
const [error, setError] = React.useState<string | null>(null);
const hasHandledRef = React.useRef(false);
React.useEffect(() => {
if (hasHandledRef.current) {
return;
}
hasHandledRef.current = true;
const params = new URLSearchParams(window.location.search);
completeLogin(params)
.then((redirectTo) => {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react';
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -220,22 +220,30 @@ export default function EventFormPage() {
</div>
<div className="space-y-2">
<Label htmlFor="package_id">Package</Label>
<Select value={form.package_id.toString()} onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value) }))}>
<SelectTrigger>
<SelectValue placeholder="Waehlen Sie ein Package" />
</SelectTrigger>
<SelectContent>
{packagesLoading ? (
<SelectItem value="">Laden...</SelectItem>
) : (
packages?.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))
)}
</SelectContent>
</Select>
<Select
value={form.package_id.toString()}
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
disabled={packagesLoading || !packages?.length}
>
<SelectTrigger>
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
</SelectTrigger>
{packages?.length ? (
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
{packagesLoading ? (
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
) : null}
{!packagesLoading && (!packages || packages.length === 0) ? (
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
) : null}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">Package-Details</Button>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useAuth } from '../auth/context';
import { ADMIN_PUBLIC_LANDING_PATH } from '../constants';
export default function LogoutPage() {
const { logout } = useAuth();
React.useEffect(() => {
logout({ redirect: ADMIN_PUBLIC_LANDING_PATH });
}, [logout]);
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600">
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
Abmeldung wird vorbereitet ...
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage';
import EmotionsPage from './pages/EmotionsPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
import LogoutPage from './pages/LogoutPage';
import { useAuth } from './auth/context';
import {
ADMIN_BASE_PATH,
@@ -71,6 +72,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'logout', element: <LogoutPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
{
element: <RequireAuth />,
@@ -92,7 +94,6 @@ export const router = createBrowserRouter([
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
{ path: '', element: <Navigate to="dashboard" replace /> },
],
},
],
@@ -102,4 +103,3 @@ export const router = createBrowserRouter([
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
},
]);

View File

@@ -8,15 +8,10 @@ import AppLayout from './layouts/app/AppLayout';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
import { Toaster } from 'react-hot-toast';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { ConsentProvider } from './contexts/consent';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) => resolvePageComponent(
@@ -42,14 +37,12 @@ createInertiaApp({
}
root.render(
<Elements stripe={stripePromise}>
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<App {...props} />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>
</ConsentProvider>
</Elements>
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<App {...props} />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>
</ConsentProvider>
);
},
progress: {

View File

@@ -21,6 +21,7 @@ interface MatomoTrackerProps {
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
const page = usePage();
const { hasConsent } = useConsent();
const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce;
const analyticsConsent = hasConsent('analytics');
useEffect(() => {
@@ -55,6 +56,19 @@ const MatomoTracker: React.FC<MatomoTrackerProps> = ({ config }) => {
script.async = true;
script.src = `${base}/matomo.js`;
script.dataset.matomo = base;
if (scriptNonce) {
script.setAttribute('nonce', scriptNonce);
} else if (typeof window !== 'undefined' && (window as any).__CSP_NONCE) {
script.setAttribute('nonce', (window as any).__CSP_NONCE);
} else {
const metaNonce = document
.querySelector('meta[name="csp-nonce"]')
?.getAttribute('content');
if (metaNonce) {
script.setAttribute('nonce', metaNonce);
}
}
document.body.appendChild(script);
}

View File

@@ -59,6 +59,16 @@ export const messages: Record<LocaleCode, NestedMessages> = {
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
},
access_rate_limited: {
title: 'Zu viele Aufrufe',
description: 'Es gab sehr viele Aufrufe in kurzer Zeit. Warte kurz und versuche es erneut.',
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
},
gallery_expired: {
title: 'Galerie nicht mehr verfuegbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
ctaLabel: 'Neuen Code anfordern',
},
event_not_public: {
title: 'Event nicht oeffentlich',
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
@@ -404,6 +414,16 @@ export const messages: Record<LocaleCode, NestedMessages> = {
description: 'There were too many attempts in a short time. Wait a bit and try again.',
hint: 'Tip: You can retry in a few minutes.',
},
access_rate_limited: {
title: 'Too many requests',
description: 'There were too many requests in a short time. Please wait a moment and try again.',
hint: 'Tip: You can retry shortly.',
},
gallery_expired: {
title: 'Gallery unavailable',
description: 'The gallery for this event is no longer accessible.',
ctaLabel: 'Request new code',
},
event_not_public: {
title: 'Event not public',
description: 'This event is not publicly accessible right now.',

View File

@@ -197,8 +197,12 @@ function getErrorContent(
return build('token_expired', { ctaHref: '/event' });
case 'token_rate_limited':
return build('token_rate_limited');
case 'access_rate_limited':
return build('access_rate_limited');
case 'event_not_public':
return build('event_not_public');
case 'gallery_expired':
return build('gallery_expired', { ctaHref: '/event' });
case 'network_error':
return build('network_error');
case 'server_error':
@@ -219,4 +223,3 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
</div>
);
}

View File

@@ -67,6 +67,8 @@ const API_ERROR_CODES: FetchEventErrorCode[] = [
'token_expired',
'token_revoked',
'token_rate_limited',
'access_rate_limited',
'gallery_expired',
'event_not_public',
];
@@ -78,9 +80,9 @@ function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode
}
}
if (status === 429) return 'token_rate_limited';
if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited';
if (status === 404) return 'event_not_public';
if (status === 410) return 'token_expired';
if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired';
if (status === 401) return 'invalid_token';
if (status === 403) return 'token_revoked';
if (status >= 500) return 'server_error';
@@ -98,6 +100,10 @@ function defaultMessageForCode(code: FetchEventErrorCode): string {
return 'Dieser Zugriffscode ist abgelaufen.';
case 'token_rate_limited':
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'access_rate_limited':
return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'gallery_expired':
return 'Die Galerie ist nicht mehr verfügbar.';
case 'event_not_public':
return 'Dieses Event ist nicht öffentlich verfügbar.';
case 'network_error':

View File

@@ -243,7 +243,10 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
const stripePromise = useMemo(() => loadStripe(stripePublishableKey), [stripePublishableKey]);
const stripePromise = useMemo(
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
[stripePublishableKey]
);
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
const isReseller = selectedPackage?.type === 'reseller';
@@ -299,6 +302,12 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
return;
}
if (!stripePromise) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.stripe_not_loaded'));
return;
}
if (!authUser) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.auth_required'));
@@ -351,7 +360,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
return () => {
cancelled = true;
};
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, t]);
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]);
const providerLabel = useCallback((provider: Provider) => {
switch (provider) {
@@ -457,7 +466,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
{renderStatusAlert()}
{paymentMethod === 'stripe' && clientSecret && (
{paymentMethod === 'stripe' && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripePaymentForm
selectedPackage={selectedPackage}

View File

@@ -28,6 +28,12 @@ export interface SharedData {
auth: Auth;
sidebarOpen: boolean;
supportedLocales?: string[];
security?: {
csp?: {
scriptNonce?: string;
styleNonce?: string;
};
};
[key: string]: unknown;
}