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:
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
resources/js/admin/pages/LogoutPage.tsx
Normal file
19
resources/js/admin/pages/LogoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user