476 lines
20 KiB
Markdown
476 lines
20 KiB
Markdown
---
|
|
phase: 24-public-access-infrastructure
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/client/routes/__root.tsx
|
|
- src/client/stores/uiStore.ts
|
|
- src/client/components/AuthPromptModal.tsx
|
|
- src/client/hooks/useSetups.ts
|
|
- src/client/hooks/useSettings.ts
|
|
- src/client/routes/global-items/$globalItemId.tsx
|
|
- src/client/routes/setups/$setupId.tsx
|
|
autonomous: false
|
|
requirements: [PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Anonymous visitor sees app content immediately on any public route — no spinner, no redirect"
|
|
- "Anonymous visitor can browse the global item catalog and open catalog detail pages"
|
|
- "Anonymous visitor can view a public setup with its items and totals"
|
|
- "Anonymous visitor can view a user profile page"
|
|
- "Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt instead of the action"
|
|
- "Authenticated user experience is unchanged — all write actions work as before"
|
|
artifacts:
|
|
- path: "src/client/routes/__root.tsx"
|
|
provides: "Render-first root layout with expanded isPublicRoute"
|
|
contains: "pathname.startsWith(\"/global-items\")"
|
|
- path: "src/client/stores/uiStore.ts"
|
|
provides: "showAuthPrompt state for auth modal"
|
|
contains: "showAuthPrompt"
|
|
- path: "src/client/components/AuthPromptModal.tsx"
|
|
provides: "Modal prompting anonymous users to sign in or sign up"
|
|
contains: "sign in or sign up"
|
|
- path: "src/client/hooks/useSetups.ts"
|
|
provides: "usePublicSetup hook for anonymous setup viewing"
|
|
exports: ["usePublicSetup"]
|
|
- path: "src/client/routes/global-items/$globalItemId.tsx"
|
|
provides: "Auth-guarded write action buttons on catalog detail"
|
|
contains: "openAuthPrompt"
|
|
- path: "src/client/routes/setups/$setupId.tsx"
|
|
provides: "Conditional public vs private setup rendering"
|
|
contains: "usePublicSetup"
|
|
key_links:
|
|
- from: "src/client/routes/__root.tsx"
|
|
to: "src/client/components/AuthPromptModal.tsx"
|
|
via: "rendered in root layout"
|
|
pattern: "<AuthPromptModal"
|
|
- from: "src/client/routes/global-items/$globalItemId.tsx"
|
|
to: "src/client/stores/uiStore.ts"
|
|
via: "openAuthPrompt action"
|
|
pattern: "openAuthPrompt"
|
|
- from: "src/client/routes/setups/$setupId.tsx"
|
|
to: "src/client/hooks/useSetups.ts"
|
|
via: "usePublicSetup hook"
|
|
pattern: "usePublicSetup"
|
|
---
|
|
|
|
<objective>
|
|
Make the app render immediately for anonymous visitors, expand public route access to catalog and setups, and intercept write actions with a friendly auth prompt.
|
|
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. -->
|
|
|
|
From src/client/hooks/useAuth.ts:
|
|
```typescript
|
|
interface AuthState {
|
|
user: { id: string; email?: string } | null;
|
|
authenticated: boolean;
|
|
}
|
|
export function useAuth(): UseQueryResult<AuthState>;
|
|
```
|
|
|
|
From src/client/stores/uiStore.ts:
|
|
```typescript
|
|
// Existing pattern — all boolean state with open/close actions
|
|
showAuthPrompt: boolean;
|
|
openAuthPrompt: () => void;
|
|
closeAuthPrompt: () => void;
|
|
```
|
|
|
|
From src/client/hooks/useSetups.ts:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied)
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add auth prompt state to uiStore, create AuthPromptModal, add usePublicSetup hook</name>
|
|
<files>src/client/stores/uiStore.ts, src/client/components/AuthPromptModal.tsx, src/client/hooks/useSetups.ts, src/client/hooks/useSettings.ts</files>
|
|
<read_first>
|
|
- src/client/stores/uiStore.ts (current state shape and patterns)
|
|
- src/client/hooks/useSetups.ts (existing hooks, types)
|
|
- src/client/hooks/useSettings.ts (useOnboardingComplete — needs `enabled` guard)
|
|
- src/client/routes/__root.tsx (CandidateDeleteDialog pattern for modal structure)
|
|
- src/client/components/TotalsBar.tsx (confirm D-10 already handled)
|
|
</read_first>
|
|
<action>
|
|
**1. Extend uiStore.ts** — Add auth prompt state following the existing pattern (e.g., `externalLinkUrl`):
|
|
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages</name>
|
|
<files>src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
**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).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- __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 `<AuthPromptModal` in the JSX
|
|
- __root.tsx contains `useOnboardingComplete(isAuthenticated)`
|
|
- global-items/$globalItemId.tsx contains `openAuthPrompt` import from uiStore
|
|
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToCollection
|
|
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToThread
|
|
- setups/$setupId.tsx contains `usePublicSetup` import
|
|
- setups/$setupId.tsx contains `useAuth` import
|
|
- setups/$setupId.tsx contains conditional `isAuthenticated ? privateSetup : publicSetup` or equivalent
|
|
- setups/$setupId.tsx write action buttons wrapped in `{isAuthenticated &&` guards
|
|
- `bun run lint` exits 0
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<name>Task 3: Verify public access flows</name>
|
|
<what-built>
|
|
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.
|
|
</what-built>
|
|
<how-to-verify>
|
|
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)
|
|
</how-to-verify>
|
|
<resume-signal>Type "approved" or describe issues</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- 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)
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/24-public-access-infrastructure/24-02-SUMMARY.md`
|
|
</output>
|