feat(24-02): render-first root layout, guarded write actions, public setup viewing

- Remove authLoading spinner gate — app renders immediately for all visitors
- Expand isPublicRoute to include /, /global-items/*, /setups/*, /users/, /login
- Replace hard window.location.href redirect with soft navigate() after auth resolves
- Remove onboarding loading spinner — pass isAuthenticated as enabled to guard query
- Add AuthPromptModal to root JSX for global availability
- Guard Add to Collection and Add to Thread buttons with isAuthenticated check
- Rework setup detail page to use usePublicSetup for anonymous visitors
- Wrap all write action UI (Add Items, Delete, Public toggle, remove/classify) in isAuthenticated guards
This commit is contained in:
2026-04-10 10:09:41 +02:00
parent 50f9629707
commit 7b0efae0c4
3 changed files with 132 additions and 111 deletions

View File

@@ -12,6 +12,7 @@ import { Toaster } from "sonner";
import "../app.css"; import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal"; import { AddToCollectionModal } from "../components/AddToCollectionModal";
import { AddToThreadModal } from "../components/AddToThreadModal"; import { AddToThreadModal } from "../components/AddToThreadModal";
import { AuthPromptModal } from "../components/AuthPromptModal";
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay"; import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog"; import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
@@ -94,7 +95,7 @@ function RootLayout() {
// Onboarding — only check when authenticated (endpoint requires auth) // Onboarding — only check when authenticated (endpoint requires auth)
const { data: onboardingComplete, isLoading: onboardingLoading } = const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete(); useOnboardingComplete(isAuthenticated);
const [wizardDismissed, setWizardDismissed] = useState(false); const [wizardDismissed, setWizardDismissed] = useState(false);
// Don't show onboarding wizard until user has created an account // Don't show onboarding wizard until user has created an account
@@ -117,40 +118,21 @@ function RootLayout() {
const totalsBarProps = isDashboard ? {} : { linkTo: "/" }; const totalsBarProps = isDashboard ? {} : { linkTo: "/" };
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
// Redirect unauthenticated users to login (server-side OIDC route)
// Allow public routes through without auth // Allow public routes through without auth
const isPublicRoute = const isPublicRoute =
location.pathname.startsWith("/users/") || location.pathname === "/login"; location.pathname === "/" ||
location.pathname.startsWith("/users/") ||
location.pathname.startsWith("/global-items") ||
location.pathname.startsWith("/setups/") ||
location.pathname === "/login";
// FAB visibility: show on all authenticated, non-public routes // FAB visibility: show on all authenticated, non-public routes
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true }); const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
const showFab = isAuthenticated && !isPublicRoute; const showFab = isAuthenticated && !isPublicRoute;
if (!isAuthenticated && !isPublicRoute) { if (!isAuthenticated && !isPublicRoute && !authLoading) {
window.location.href = "/login"; navigate({ to: "/login" });
return ( return null;
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-sm text-gray-500">Redirecting to login...</p>
</div>
);
}
// Show loading while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
</div>
);
} }
return ( return (
@@ -198,6 +180,9 @@ function RootLayout() {
<AddToThreadModal /> <AddToThreadModal />
<Toaster position="bottom-right" richColors /> <Toaster position="bottom-right" richColors />
{/* Auth Prompt Modal */}
<AuthPromptModal />
{/* Onboarding Wizard */} {/* Onboarding Wizard */}
{showWizard && ( {showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} /> <OnboardingWizard onComplete={() => setWizardDismissed(true)} />

View File

@@ -1,4 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useAuth } from "../../hooks/useAuth";
import { useFormatters } from "../../hooks/useFormatters"; import { useFormatters } from "../../hooks/useFormatters";
import { useGlobalItem } from "../../hooks/useGlobalItems"; import { useGlobalItem } from "../../hooks/useGlobalItems";
import { useUIStore } from "../../stores/uiStore"; import { useUIStore } from "../../stores/uiStore";
@@ -11,8 +12,11 @@ function GlobalItemDetail() {
const { globalItemId } = Route.useParams(); const { globalItemId } = Route.useParams();
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId)); const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAddToCollection = useUIStore((s) => s.openAddToCollection); const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread); const openAddToThread = useUIStore((s) => s.openAddToThread);
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
if (isLoading) { if (isLoading) {
return ( return (
@@ -133,18 +137,26 @@ function GlobalItemDetail() {
<div className="flex gap-3 mb-6"> <div className="flex gap-3 mb-6">
<button <button
type="button" type="button"
onClick={() => onClick={() => {
openAddToCollection(item.id, `${item.brand} ${item.model}`) if (!isAuthenticated) {
} openAuthPrompt();
return;
}
openAddToCollection(item.id, `${item.brand} ${item.model}`);
}}
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors" className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
> >
Add to Collection Add to Collection
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => {
openAddToThread(item.id, `${item.brand} ${item.model}`) if (!isAuthenticated) {
} openAuthPrompt();
return;
}
openAddToThread(item.id, `${item.brand} ${item.model}`);
}}
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors" className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
> >
Add to Thread Add to Thread

View File

@@ -4,9 +4,11 @@ import { CategoryHeader } from "../../components/CategoryHeader";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ItemPicker } from "../../components/ItemPicker"; import { ItemPicker } from "../../components/ItemPicker";
import { WeightSummaryCard } from "../../components/WeightSummaryCard"; import { WeightSummaryCard } from "../../components/WeightSummaryCard";
import { useAuth } from "../../hooks/useAuth";
import { useFormatters } from "../../hooks/useFormatters"; import { useFormatters } from "../../hooks/useFormatters";
import { import {
useDeleteSetup, useDeleteSetup,
usePublicSetup,
useRemoveSetupItem, useRemoveSetupItem,
useSetup, useSetup,
useUpdateItemClassification, useUpdateItemClassification,
@@ -23,7 +25,16 @@ function SetupDetailPage() {
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
const numericId = Number(setupId); const numericId = Number(setupId);
const { data: setup, isLoading } = useSetup(numericId);
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const privateSetup = useSetup(isAuthenticated ? numericId : null);
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
const { data: setup, isLoading } = isAuthenticated
? privateSetup
: publicSetup;
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId); const updateSetup = useUpdateSetup(numericId);
const removeItem = useRemoveSetupItem(numericId); const removeItem = useRemoveSetupItem(numericId);
@@ -140,62 +151,64 @@ function SetupDetailPage() {
</div> </div>
</div> </div>
{/* Actions */} {/* Actions — only visible to authenticated users */}
<div className="flex items-center gap-3 py-4"> {isAuthenticated && (
<button <div className="flex items-center gap-3 py-4">
type="button" <button
onClick={() => setPickerOpen(true)} type="button"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors" onClick={() => setPickerOpen(true)}
> className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
strokeLinecap="round" className="w-4 h-4"
strokeLinejoin="round" fill="none"
strokeWidth={2} stroke="currentColor"
d="M12 4v16m8-8H4" viewBox="0 0 24 24"
/> >
</svg> <path
Add Items strokeLinecap="round"
</button> strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Items
</button>
{/* Public toggle */} {/* Public toggle */}
<button <button
type="button" type="button"
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })} onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ className={`inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
setup.isPublic setup.isPublic
? "text-green-700 bg-green-50 hover:bg-green-100" ? "text-green-700 bg-green-50 hover:bg-green-100"
: "text-gray-500 bg-gray-50 hover:bg-gray-100" : "text-gray-500 bg-gray-50 hover:bg-gray-100"
}`} }`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
> >
<circle cx="12" cy="12" r="10" /> <svg
<path d="M2 12h20" /> className="w-4 h-4"
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> fill="none"
</svg> stroke="currentColor"
{setup.isPublic ? "Public" : "Private"} viewBox="0 0 24 24"
</button> strokeWidth={1.5}
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{setup.isPublic ? "Public" : "Private"}
</button>
<div className="flex-1" /> <div className="flex-1" />
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors" className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
> >
Delete Setup Delete Setup
</button> </button>
</div> </div>
)}
{/* Empty state */} {/* Empty state */}
{itemCount === 0 && ( {itemCount === 0 && (
@@ -214,13 +227,15 @@ function SetupDetailPage() {
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Add items from your collection to build this loadout. Add items from your collection to build this loadout.
</p> </p>
<button {isAuthenticated && (
type="button" <button
onClick={() => setPickerOpen(true)} type="button"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors" onClick={() => setPickerOpen(true)}
> className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
Add Items >
</button> Add Items
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -268,15 +283,22 @@ function SetupDetailPage() {
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
imageUrl={item.imageUrl} imageUrl={item.imageUrl}
productUrl={item.productUrl} productUrl={item.productUrl}
onRemove={() => removeItem.mutate(item.id)} onRemove={
isAuthenticated
? () => removeItem.mutate(item.id)
: undefined
}
classification={item.classification} classification={item.classification}
onClassificationCycle={() => onClassificationCycle={
updateClassification.mutate({ isAuthenticated
itemId: item.id, ? () =>
classification: nextClassification( updateClassification.mutate({
item.classification, itemId: item.id,
), classification: nextClassification(
}) item.classification,
),
})
: undefined
} }
/> />
))} ))}
@@ -288,16 +310,18 @@ function SetupDetailPage() {
</div> </div>
)} )}
{/* Item Picker */} {/* Item Picker — only for authenticated users */}
<ItemPicker {isAuthenticated && (
setupId={numericId} <ItemPicker
currentItemIds={currentItemIds} setupId={numericId}
isOpen={pickerOpen} currentItemIds={currentItemIds}
onClose={() => setPickerOpen(false)} isOpen={pickerOpen}
/> onClose={() => setPickerOpen(false)}
/>
)}
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog — only for authenticated users */}
{confirmDelete && ( {isAuthenticated && confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/30" className="absolute inset-0 bg-black/30"