20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 24-public-access-infrastructure | 02 | execute | 1 |
|
false |
|
|
Purpose: Transform GearBox from a login-first tool into a public-first browsing experience (PUBL-01 through PUBL-05). Anonymous visitors see content instantly; write actions prompt sign-in/sign-up instead of hard-redirecting. Output: Reworked root layout, auth prompt modal, public setup hook, guarded write actions.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-public-access-infrastructure/24-CONTEXT.md @.planning/phases/24-public-access-infrastructure/24-RESEARCH.md@src/client/routes/__root.tsx @src/client/stores/uiStore.ts @src/client/hooks/useSetups.ts @src/client/hooks/useAuth.ts @src/client/hooks/useSettings.ts @src/client/routes/global-items/$globalItemId.tsx @src/client/routes/setups/$setupId.tsx @src/client/components/TotalsBar.tsx
From src/client/hooks/useAuth.ts:
interface AuthState {
user: { id: string; email?: string } | null;
authenticated: boolean;
}
export function useAuth(): UseQueryResult<AuthState>;
From src/client/stores/uiStore.ts:
// Existing pattern — all boolean state with open/close actions
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
From src/client/hooks/useSetups.ts:
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
// New hook to add:
export function usePublicSetup(id: number): UseQueryResult<PublicSetupData>;
From src/client/components/TotalsBar.tsx:
// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied)
Add to the `UIState` interface:
```typescript
// Auth prompt modal
showAuthPrompt: boolean;
openAuthPrompt: () => void;
closeAuthPrompt: () => void;
```
Add to the `create` implementation:
```typescript
// Auth prompt modal
showAuthPrompt: false,
openAuthPrompt: () => set({ showAuthPrompt: true }),
closeAuthPrompt: () => set({ showAuthPrompt: false }),
```
**2. Create AuthPromptModal.tsx** per D-06 — inline popup/modal with "sign in or sign up" language. Follow the exact pattern of CandidateDeleteDialog in `__root.tsx` (fixed overlay, centered card, bg-black/30 backdrop):
```typescript
import { Link } from "@tanstack/react-router";
import { useUIStore } from "../stores/uiStore";
export function AuthPromptModal() {
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
if (!showAuthPrompt) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={closeAuthPrompt}
onKeyDown={(e) => {
if (e.key === "Escape") closeAuthPrompt();
}}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Join GearBox
</h3>
<p className="text-sm text-gray-600 mb-6">
To manage your own collection, sign in or sign up.
</p>
<div className="flex flex-col gap-3">
<Link
to="/login"
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Sign in
</Link>
<Link
to="/login"
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
onClick={closeAuthPrompt}
>
Create account
</Link>
</div>
</div>
</div>
);
}
```
Both links go to `/login` because Logto handles both sign-in and sign-up at the same OIDC redirect. The UX distinction is in the button labels per the user's emphasis on welcoming new users (from specifics in CONTEXT.md).
**3. Add usePublicSetup hook** in `src/client/hooks/useSetups.ts`:
Add after the existing `useSetup` function:
```typescript
export function usePublicSetup(setupId: number | null) {
return useQuery({
queryKey: ["setups", setupId, "public"],
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}/public`),
enabled: setupId != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}
```
The public endpoint returns the same shape as the private one (SetupWithItems) but with `isPublic` always `true` and the owner's category names included as read-only context per D-03.
**4. Guard useOnboardingComplete** in `src/client/hooks/useSettings.ts` — Pitfall 2 from research. The `useSetting` hook calls an auth-gated endpoint. For unauthenticated users, it returns an error and `isLoading` may be `true` briefly, blocking render.
Change `useOnboardingComplete` to accept an `enabled` parameter:
```typescript
export function useOnboardingComplete(enabled = true) {
return useQuery({
queryKey: ["settings", "onboardingComplete"],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/onboardingComplete`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
enabled,
});
}
```
This replaces the current delegation to `useSetting("onboardingComplete")` with a direct `useQuery` call that accepts an `enabled` parameter. The query logic is identical to `useSetting` — just inlined so `enabled` can be passed through.
cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint
- uiStore.ts contains `showAuthPrompt: boolean` in the interface
- uiStore.ts contains `openAuthPrompt: () => set({ showAuthPrompt: true })`
- uiStore.ts contains `closeAuthPrompt: () => set({ showAuthPrompt: false })`
- AuthPromptModal.tsx exists and contains `sign in or sign up`
- AuthPromptModal.tsx contains `to="/login"` (both links point to /login)
- AuthPromptModal.tsx contains `Create account` button text
- AuthPromptModal.tsx contains `className="fixed inset-0 z-50`
- useSetups.ts contains `export function usePublicSetup(`
- useSetups.ts contains `/api/setups/${setupId}/public`
- useSettings.ts `useOnboardingComplete` accepts `enabled` parameter
- `bun run lint` exits 0
Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag
Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages
src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx
- src/client/routes/__root.tsx (current auth loading spinner, redirect logic, isPublicRoute)
- src/client/routes/global-items/$globalItemId.tsx (action buttons to guard)
- src/client/routes/setups/$setupId.tsx (full file — need to understand write actions and data flow)
- src/client/stores/uiStore.ts (after Task 1 — confirm showAuthPrompt exists)
- src/client/components/AuthPromptModal.tsx (after Task 1 — confirm component exists)
- src/client/hooks/useSetups.ts (after Task 1 — confirm usePublicSetup exists)
**1. Rework __root.tsx** per D-04 and D-09:
**a. Remove the authLoading spinner gate (lines 121-127).** Delete this entire block:
```typescript
// REMOVE THIS:
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>
);
}
```
**b. Expand isPublicRoute** (replace current line 131-132):
```typescript
const isPublicRoute =
location.pathname === "/" ||
location.pathname.startsWith("/users/") ||
location.pathname.startsWith("/global-items") ||
location.pathname.startsWith("/setups/") ||
location.pathname === "/login";
```
**c. Replace hard redirect** (replace lines 138-145). Remove `window.location.href = "/login"` and replace with soft redirect that only fires after auth resolves:
```typescript
if (!isAuthenticated && !isPublicRoute && !authLoading) {
navigate({ to: "/login" });
return null;
}
```
**d. Remove onboarding loading spinner gate** (lines 147-154). Delete the entire `if (onboardingLoading)` block. The `showWizard` check already guards on `isAuthenticated`, so this gate is unnecessary. Update the `useOnboardingComplete` call to pass `enabled: isAuthenticated`:
```typescript
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete(isAuthenticated);
```
**e. Add AuthPromptModal** to the return JSX. Import at top:
```typescript
import { AuthPromptModal } from "../components/AuthPromptModal";
```
Add inside the root `<div>`, after the `<Toaster>` and before the onboarding wizard:
```tsx
{/* Auth Prompt Modal */}
<AuthPromptModal />
```
**2. Guard write actions in global-items/$globalItemId.tsx** per D-06 and PUBL-05:
Add imports:
```typescript
import { useAuth } from "../../hooks/useAuth";
```
Inside the `GlobalItemDetail` component, add:
```typescript
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
```
Replace the two button onClick handlers. For "Add to Collection":
```typescript
onClick={() => {
if (!isAuthenticated) {
openAuthPrompt();
return;
}
openAddToCollection(item.id, `${item.brand} ${item.model}`);
}}
```
For "Add to Thread":
```typescript
onClick={() => {
if (!isAuthenticated) {
openAuthPrompt();
return;
}
openAddToThread(item.id, `${item.brand} ${item.model}`);
}}
```
**3. Rework setups/$setupId.tsx** for anonymous viewing per PUBL-02:
This is the most complex change. The current page calls `useSetup(id)` which hits the auth-gated `GET /api/setups/:id`. Anonymous visitors get a 401.
Add imports:
```typescript
import { useAuth } from "../../hooks/useAuth";
import { usePublicSetup } from "../../hooks/useSetups";
```
At the top of `SetupDetailPage`, add auth detection:
```typescript
const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user;
```
Change the data fetching to be conditional:
```typescript
const privateSetup = useSetup(isAuthenticated ? numericId : null);
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
const { data: setup, isLoading } = isAuthenticated
? privateSetup
: publicSetup;
```
Wrap all write action UI elements (Delete button, Add Items button, Public toggle, remove item buttons, classification dropdowns) in `isAuthenticated` guards:
```typescript
{isAuthenticated && (
<button onClick={() => setPickerOpen(true)}>Add Items</button>
)}
```
Apply this guard to:
- The "Add Items" button
- The "Delete Setup" button and its confirmation dialog
- The "Public" toggle switch
- The remove button on individual items (the X icon)
- The classification dropdown on individual items
- The `ItemPicker` component render
The read-only display (setup name, items list, weight summary, totals) should render for everyone.
Also: the mutation hooks (`useDeleteSetup`, `useUpdateSetup`, `useRemoveSetupItem`, `useUpdateItemClassification`) can remain — they just won't be invoked since their triggers are hidden. But `useDeleteSetup()` and `useUpdateSetup(numericId)` calls at the top of the component are fine to keep (they return mutation objects, no network call until `.mutate()` is called).
cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint
- __root.tsx does NOT contain `if (authLoading)` followed by a spinner return
- __root.tsx does NOT contain `window.location.href = "/login"`
- __root.tsx contains `pathname === "/" ||` in isPublicRoute
- __root.tsx contains `pathname.startsWith("/global-items")` in isPublicRoute
- __root.tsx contains `pathname.startsWith("/setups/")` in isPublicRoute
- __root.tsx contains `navigate({ to: "/login" })` (soft redirect for private routes)
- __root.tsx contains `!authLoading` in the redirect condition
- __root.tsx contains `
Root layout renders immediately for anonymous visitors. Public routes include /, /global-items/*, /setups/*, /users/*, /login. Write actions on catalog detail show auth prompt. Setup detail page shows read-only view for anonymous visitors using the public API endpoint.
Task 3: Verify public access flows
Complete public access infrastructure: anonymous visitors can browse catalog, view public setups, and view profiles without logging in. Write actions show a friendly sign-in/sign-up prompt instead of redirecting.
1. Start dev server: `bun run dev`
2. Open an incognito/private browser window (no session)
3. Visit `http://localhost:5173/` — should see the app immediately (no spinner, no redirect to /login)
4. Visit `http://localhost:5173/global-items` — catalog page loads with items
5. Click on any catalog item — detail page loads with image, specs, action buttons
6. Click "Add to Collection" — auth prompt modal appears with "sign in or sign up" message, two buttons (Sign in, Create account)
7. Close the modal (click backdrop or press Escape)
8. Click "Add to Thread" — same auth prompt modal appears
9. Visit a public setup URL (e.g., `http://localhost:5173/setups/1` if a public setup exists) — setup renders with items and totals, no write action buttons visible
10. Visit a user profile (e.g., `http://localhost:5173/users/1`) — profile page loads
11. Verify top-right corner shows "Sign in" link (already existing in TotalsBar)
12. Now log in normally — verify all write actions work as before (FAB appears, Add to Collection works, setup edit buttons appear)
Type "approved" or describe issues
- Anonymous visitor can browse catalog without login (PUBL-01)
- Anonymous visitor can view public setups (PUBL-02)
- Anonymous visitor can view user profiles (PUBL-03)
- No auth spinner or redirect on first visit (PUBL-04)
- Write actions prompt sign-in instead of executing (PUBL-05)
- `bun run lint` passes
- `bun test` passes (no regressions)
<success_criteria>
- Root layout renders immediately for anonymous visitors
- isPublicRoute includes /, /global-items/, /setups/, /users/*, /login
- AuthPromptModal shows friendly sign-in/sign-up prompt on write action attempts
- Setup detail page uses public API endpoint for anonymous visitors
- Catalog detail page guards both "Add to Collection" and "Add to Thread" buttons
- No hard redirects (window.location.href) remain in root layout
- Authenticated user experience is completely unchanged </success_criteria>