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

@@ -119,6 +119,30 @@
font-display: swap;
}
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
@keyframes aurora {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.bg-aurora {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
/* Basic typography styling for rendered markdown (prose) without Tailwind plugin */
.prose {
color: rgb(55 65 81);

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

View File

@@ -106,6 +106,13 @@ return [
'layouts_fallback' => 'Layout-Übersicht öffnen',
'token_expiry' => 'Läuft ab am :date',
],
'analytics' => [
'success_total' => 'Erfolgreiche Zugriffe',
'failure_total' => 'Fehlgeschlagene Zugriffe',
'rate_limited_total' => 'Rate-Limit erreicht',
'recent_24h' => 'Aufrufe (24h)',
'last_seen_at' => 'Letzte Aktivität: :date',
],
],
'legal_pages' => [
@@ -329,6 +336,77 @@ return [
],
],
'refresh_tokens' => [
'menu' => 'Refresh Tokens',
'single' => 'Refresh Token',
'fields' => [
'tenant' => 'Mandant',
'client' => 'Client',
'status' => 'Status',
'revoked_reason' => 'Widerrufsgrund',
'created_at' => 'Erstellt',
'last_used_at' => 'Zuletzt verwendet',
'expires_at' => 'Gültig bis',
'ip_address' => 'IP-Adresse',
'user_agent' => 'User Agent',
'note' => 'Notiz',
],
'status' => [
'active' => 'Aktiv',
'revoked' => 'Widerrufen',
'expired' => 'Abgelaufen',
],
'filters' => [
'status' => 'Status',
'tenant' => 'Mandant',
],
'actions' => [
'revoke' => 'Token widerrufen',
],
'reasons' => [
'manual' => 'Manuell',
'operator' => 'Operator-Aktion',
'rotated' => 'Automatisch rotiert',
'ip_mismatch' => 'IP-Abweichung',
'expired' => 'Abgelaufen',
'invalid_secret' => 'Ungültiges Secret',
'tenant_missing' => 'Mandant entfernt',
'max_active_limit' => 'Maximale Anzahl überschritten',
],
'sections' => [
'details' => 'Token-Details',
'security' => 'Sicherheitskontext',
],
'audit' => [
'heading' => 'Audit-Log',
'event' => 'Ereignis',
'events' => [
'issued' => 'Ausgestellt',
'refresh_attempt' => 'Refresh versucht',
'refreshed' => 'Refresh erfolgreich',
'client_mismatch' => 'Client stimmt nicht überein',
'invalid_secret' => 'Ungültiges Secret',
'ip_mismatch' => 'IP-Abweichung',
'expired' => 'Abgelaufen',
'revoked' => 'Widerrufen',
'rotated' => 'Rotiert',
'tenant_missing' => 'Mandant fehlt',
'max_active_limit' => 'Begrenzung erreicht',
],
'performed_by' => 'Ausgeführt von',
'ip_address' => 'IP-Adresse',
'context' => 'Kontext',
'performed_at' => 'Zeitpunkt',
'empty' => [
'heading' => 'Noch keine Einträge',
'description' => 'Sobald das Token verwendet wird, erscheinen hier Einträge.',
],
],
'notifications' => [
'revoked' => 'Refresh Token wurde widerrufen.',
],
],
'shell' => [
'tenant_admin_title' => 'TenantAdmin',
],

View File

@@ -49,4 +49,11 @@ return [
'subject' => 'Neue Kontakt-Anfrage',
'body' => 'Kontakt-Anfrage von :name (:email): :message',
],
];
'contact_confirmation' => [
'subject' => 'Vielen Dank für Ihre Nachricht, :name!',
'greeting' => 'Hallo :name,',
'body' => 'Vielen Dank für Ihre Nachricht an das Fotospiel-Team. Wir melden uns so schnell wie möglich zurück.',
'footer' => 'Viele Grüße<br>Ihr Fotospiel-Team',
],
];

View File

@@ -160,4 +160,7 @@ return [
'currency' => [
'euro' => '€',
],
'contact' => [
'success' => 'Danke! Wir melden uns schnellstmöglich.',
],
];

View File

@@ -105,6 +105,13 @@ return [
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
'open_admin' => 'Open admin app',
],
'analytics' => [
'success_total' => 'Successful checks',
'failure_total' => 'Failures',
'rate_limited_total' => 'Rate limited',
'recent_24h' => 'Requests (24h)',
'last_seen_at' => 'Last activity: :date',
],
],
'legal_pages' => [
@@ -315,6 +322,77 @@ return [
],
],
'refresh_tokens' => [
'menu' => 'Refresh tokens',
'single' => 'Refresh token',
'fields' => [
'tenant' => 'Tenant',
'client' => 'Client',
'status' => 'Status',
'revoked_reason' => 'Revoked reason',
'created_at' => 'Created',
'last_used_at' => 'Last used',
'expires_at' => 'Expires at',
'ip_address' => 'IP address',
'user_agent' => 'User agent',
'note' => 'Operator note',
],
'status' => [
'active' => 'Active',
'revoked' => 'Revoked',
'expired' => 'Expired',
],
'filters' => [
'status' => 'Status',
'tenant' => 'Tenant',
],
'actions' => [
'revoke' => 'Revoke token',
],
'reasons' => [
'manual' => 'Manual',
'operator' => 'Operator action',
'rotated' => 'Rotated (auto)',
'ip_mismatch' => 'IP mismatch',
'expired' => 'Expired',
'invalid_secret' => 'Invalid secret attempt',
'tenant_missing' => 'Tenant removed',
'max_active_limit' => 'Exceeded active token limit',
],
'sections' => [
'details' => 'Token details',
'security' => 'Security context',
],
'audit' => [
'heading' => 'Audit log',
'event' => 'Event',
'events' => [
'issued' => 'Issued',
'refresh_attempt' => 'Refresh attempted',
'refreshed' => 'Refresh succeeded',
'client_mismatch' => 'Client mismatch',
'invalid_secret' => 'Invalid secret',
'ip_mismatch' => 'IP mismatch',
'expired' => 'Expired',
'revoked' => 'Revoked',
'rotated' => 'Rotated',
'tenant_missing' => 'Tenant missing',
'max_active_limit' => 'Pruned (active limit)',
],
'performed_by' => 'Actor',
'ip_address' => 'IP address',
'context' => 'Context',
'performed_at' => 'Timestamp',
'empty' => [
'heading' => 'No audit entries yet',
'description' => 'Token activity will appear here once it is used.',
],
],
'notifications' => [
'revoked' => 'Refresh token revoked.',
],
],
'shell' => [
'tenant_admin_title' => 'Tenant Admin',
],

View File

@@ -49,4 +49,11 @@ return [
'subject' => 'New Contact Request',
'body' => 'Contact request from :name (:email): :message',
],
];
'contact_confirmation' => [
'subject' => 'Thank you for reaching out, :name!',
'greeting' => 'Hi :name,',
'body' => 'Thank you for your message to the Fotospiel team. We will get back to you as soon as possible.',
'footer' => 'Best regards,<br>The Fotospiel Team',
],
];

View File

@@ -160,4 +160,7 @@ return [
'currency' => [
'euro' => '€',
],
'contact' => [
'success' => 'Thanks! We will get back to you soon.',
],
];

View File

@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@viteReactRefresh
@vite('resources/js/admin/main.tsx')
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
</head>
<body>
<div id="root"></div>

View File

@@ -1,14 +1,22 @@
@php
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@if($scriptNonce)
<meta name="csp-nonce" content="{{ $scriptNonce }}">
@endif
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
window.__CSP_NONCE = '{{ $scriptNonce }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -20,17 +28,6 @@
})();
</script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
@@ -41,7 +38,7 @@
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@inertiaHead
</head>
<body class="font-sans antialiased">

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $package->name]) }}</title>
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $packageName]) }}</title>
<style>
.cta-button {
background-color: #007bff;
@@ -26,7 +26,7 @@
<body>
<h1>{{ __('emails.abandoned_checkout.greeting', ['name' => $user->fullName]) }}</h1>
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $package->name]) }}</p>
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $packageName]) }}</p>
<a href="{{ $resumeUrl }}" class="cta-button">
{{ __('emails.abandoned_checkout.cta_button') }}
@@ -44,4 +44,4 @@
<p>{!! __('emails.abandoned_checkout.footer') !!}</p>
</body>
</html>
</html>

View File

@@ -1,10 +1,7 @@
@component('mail::message')
# Hallo {{ $name }},
# {{ __('emails.contact_confirmation.greeting', ['name' => $name]) }}
vielen Dank fuer Ihre Nachricht an das Fotospiel Team. Wir melden uns so schnell wie moeglich bei Ihnen.
{{ __('emails.contact_confirmation.body') }}
Falls Sie weitere Informationen hinzufuegen moechten, antworten Sie einfach auf diese E-Mail.
Viele Gruesse
Ihr Fotospiel Team
{{ __('emails.contact_confirmation.footer') }}
@endcomponent

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ __('emails.purchase.subject', ['package' => $package->name]) }}</title>
<title>{{ __('emails.purchase.subject', ['package' => $packageName]) }}</title>
</head>
<body>
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
<p>{{ __('emails.purchase.package', ['package' => $package->name]) }}</p>
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p>
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
<p>{{ __('emails.purchase.activation') }}</p>
<p>{!! __('emails.purchase.footer') !!}</p>
</body>
</html>
</html>

View File

@@ -68,6 +68,44 @@
</div>
</div>
@php
$analytics = $token['analytics'] ?? [];
@endphp
@if (!empty($analytics))
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.success_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['success_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.failure_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['failure_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.rate_limited_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['rate_limited_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.recent_24h') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['recent_24h'] ?? 0) }}
</div>
@if (!empty($analytics['last_seen_at']))
<div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
</div>
@endif
</div>
</div>
@endif
@if (!empty($token['layouts']))
<div class="mt-4 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">

View File

@@ -1,3 +1,7 @@
@php
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
@endphp
<!doctype html>
<html lang="de">
<head>
@@ -7,7 +11,13 @@
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
<meta name="csrf-token" content="{{ csrf_token() }}">
@if($scriptNonce)
<meta name="csp-nonce" content="{{ $scriptNonce }}">
@endif
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
window.__CSP_NONCE = '{{ $scriptNonce }}';
</script>
@php
$currentLocale = app()->getLocale();
@@ -20,17 +30,6 @@
<link rel="alternate" hreflang="{{ $locale }}" href="{{ url("/$locale$path") }}">
@endforeach
<link rel="alternate" hreflang="x-default" href="{{ url('/de' . $path) }}">
<style>
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.bg-aurora {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">

View File

@@ -186,7 +186,7 @@
@endsection
@push('scripts')
<script>
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
document.addEventListener('DOMContentLoaded', function() {
const tabLinks = document.querySelectorAll('.tab-link');
tabLinks.forEach(link => {
@@ -201,4 +201,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
@endpush
@endpush

View File

@@ -6,7 +6,7 @@
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
@auth
@if(auth()->user()->email_verified_at)
<script>
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
window.location.href = '/event-admin';
</script>
<div class="text-center">