docs(24): create phase plan
This commit is contained in:
475
.planning/phases/24-public-access-infrastructure/24-02-PLAN.md
Normal file
475
.planning/phases/24-public-access-infrastructure/24-02-PLAN.md
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user