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 />,
},
]);