docs(24): create phase plan
This commit is contained in:
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### 🚧 v2.1 Public Discovery (In Progress)
|
### v2.1 Public Discovery (In Progress)
|
||||||
|
|
||||||
**Milestone Goal:** Transform GearBox from a login-first tool into a public-first discovery platform with always-on catalog search and a browsable feed of community content.
|
**Milestone Goal:** Transform GearBox from a login-first tool into a public-first discovery platform with always-on catalog search and a browsable feed of community content.
|
||||||
|
|
||||||
@@ -84,7 +84,12 @@
|
|||||||
3. An unauthenticated visitor can view a public setup and see its items and totals
|
3. An unauthenticated visitor can view a public setup and see its items and totals
|
||||||
4. An unauthenticated visitor can view a user's public profile page
|
4. An unauthenticated visitor can view a user's public profile page
|
||||||
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
|
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
|
||||||
**Plans**: TBD
|
**Plans**: 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 24-01-PLAN.md — Rate limit factory and tiered public endpoint protection
|
||||||
|
- [ ] 24-02-PLAN.md — Client-side public access (render-first root, auth prompt, setup/catalog guards)
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 25: Catalog Enrichment & Agent Tools
|
### Phase 25: Catalog Enrichment & Agent Tools
|
||||||
@@ -139,7 +144,7 @@
|
|||||||
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
|
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
|
||||||
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
|
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
|
||||||
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
|
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
|
||||||
| 24. Public Access & Infrastructure | v2.1 | 0/TBD | Not started | - |
|
| 24. Public Access & Infrastructure | v2.1 | 0/2 | In progress | - |
|
||||||
| 25. Catalog Enrichment & Agent Tools | v2.1 | 0/TBD | Not started | - |
|
| 25. Catalog Enrichment & Agent Tools | v2.1 | 0/TBD | Not started | - |
|
||||||
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |
|
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |
|
||||||
|
|
||||||
|
|||||||
216
.planning/phases/24-public-access-infrastructure/24-01-PLAN.md
Normal file
216
.planning/phases/24-public-access-infrastructure/24-01-PLAN.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/server/middleware/rateLimit.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- tests/middleware/rateLimit.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [INFR-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Public GET endpoints return 429 after exceeding the configured rate limit"
|
||||||
|
- "Different endpoint tiers have different rate limit thresholds"
|
||||||
|
- "Existing OAuth rate limiting (5 req/15 min) continues to work unchanged"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/middleware/rateLimit.ts"
|
||||||
|
provides: "createRateLimit factory function"
|
||||||
|
exports: ["createRateLimit", "rateLimit", "_resetForTesting"]
|
||||||
|
- path: "src/server/index.ts"
|
||||||
|
provides: "Rate limit middleware applied to public GET endpoints"
|
||||||
|
contains: "createRateLimit"
|
||||||
|
- path: "tests/middleware/rateLimit.test.ts"
|
||||||
|
provides: "Tests for configurable rate limit tiers"
|
||||||
|
contains: "createRateLimit"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/index.ts"
|
||||||
|
to: "src/server/middleware/rateLimit.ts"
|
||||||
|
via: "import createRateLimit"
|
||||||
|
pattern: "createRateLimit\\(\\d+,"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Refactor the rate limiter into a configurable factory and apply tiered rate limits to all public GET API endpoints.
|
||||||
|
|
||||||
|
Purpose: Protect public endpoints from abuse (INFR-01) while allowing normal browsing patterns. The existing single-tier (5 req/15 min) rate limiter is only appropriate for OAuth/auth endpoints.
|
||||||
|
Output: `createRateLimit(max, windowMs)` factory, tiered limits on public GET routes, extended tests.
|
||||||
|
</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/server/middleware/rateLimit.ts
|
||||||
|
@src/server/index.ts
|
||||||
|
@tests/middleware/rateLimit.test.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Refactor rateLimit.ts to factory pattern and extend tests</name>
|
||||||
|
<files>src/server/middleware/rateLimit.ts, tests/middleware/rateLimit.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/middleware/rateLimit.ts (current single-tier implementation)
|
||||||
|
- tests/middleware/rateLimit.test.ts (existing tests to preserve)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: createRateLimit(3, 60000) allows exactly 3 requests then returns 429
|
||||||
|
- Test: createRateLimit(10, 60000) allows exactly 10 requests then returns 429
|
||||||
|
- Test: Two different createRateLimit instances with different limits operate independently (share store but different keys)
|
||||||
|
- Test: Original `rateLimit` export still blocks after 5 requests (backward compat)
|
||||||
|
- Test: 429 response includes Retry-After header
|
||||||
|
- Test: Different IPs tracked independently with createRateLimit
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Refactor `src/server/middleware/rateLimit.ts` per D-07:
|
||||||
|
|
||||||
|
1. Keep the existing module-level `store` Map and `cleanup()`, `getClientIp()` helper functions unchanged.
|
||||||
|
2. Add a new exported factory function:
|
||||||
|
```typescript
|
||||||
|
export function createRateLimit(maxAttempts: number, windowMs: number) {
|
||||||
|
return async function rateLimitMiddleware(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(key);
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (entry.count >= maxAttempts) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many requests. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Rewrite the original `rateLimit` export to delegate to the factory:
|
||||||
|
```typescript
|
||||||
|
export const rateLimit = createRateLimit(5, 15 * 60 * 1000);
|
||||||
|
```
|
||||||
|
Note: Change from `async function` to `const` assignment. The `rateLimit` export must remain a middleware function (not a wrapper that creates one on each call).
|
||||||
|
4. Keep `_resetForTesting()` unchanged — it clears the shared store, which is correct for all tiers.
|
||||||
|
|
||||||
|
In `tests/middleware/rateLimit.test.ts`:
|
||||||
|
5. Add import for `createRateLimit` alongside existing imports.
|
||||||
|
6. Add a new `describe("createRateLimit factory")` block with tests for:
|
||||||
|
- Custom limit (3 req) blocks on 4th request
|
||||||
|
- Custom limit (10 req) allows 10 then blocks
|
||||||
|
- Different IPs tracked independently
|
||||||
|
- Retry-After header present on 429
|
||||||
|
7. Keep all existing tests in the `"rateLimit middleware"` describe block unchanged — they validate backward compatibility.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- rateLimit.ts contains `export function createRateLimit(maxAttempts: number, windowMs: number)`
|
||||||
|
- rateLimit.ts contains `export const rateLimit = createRateLimit(5,` (backward-compatible export)
|
||||||
|
- rateLimit.ts contains `export function _resetForTesting()`
|
||||||
|
- rateLimit.test.ts contains `describe("createRateLimit factory"` with at least 4 test cases
|
||||||
|
- All existing tests in "rateLimit middleware" describe block still pass
|
||||||
|
- `bun test tests/middleware/rateLimit.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>createRateLimit factory exported, backward-compatible rateLimit still works, all tests pass including new factory tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Apply tiered rate limits to public GET endpoints in index.ts</name>
|
||||||
|
<files>src/server/index.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/index.ts (current route registration and auth skip logic, lines 100-167)
|
||||||
|
- src/server/middleware/rateLimit.ts (after Task 1 — confirm createRateLimit export exists)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Apply rate limit tiers to public GET endpoints per D-07 and D-08 (same limits for auth and anon).
|
||||||
|
|
||||||
|
1. Add import at top of `src/server/index.ts`:
|
||||||
|
```typescript
|
||||||
|
import { createRateLimit } from "./middleware/rateLimit";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. After the `app.use("/api/*", async (c, next) => { c.set("db", prodDb); ... })` block (around line 118) and BEFORE the auth middleware block (line 121), add rate limit middleware:
|
||||||
|
```typescript
|
||||||
|
// Rate limiting for public endpoints (per D-07, D-08)
|
||||||
|
const browseTier = createRateLimit(120, 60_000);
|
||||||
|
const detailTier = createRateLimit(60, 60_000);
|
||||||
|
|
||||||
|
// Browse endpoints — higher limit for list/search
|
||||||
|
app.use("/api/global-items", async (c, next) => {
|
||||||
|
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
|
||||||
|
return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/tags", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail endpoints — moderate limit for individual resources
|
||||||
|
app.use("/api/global-items/:id", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/setups/:id/public", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/users/:id/profile", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Do NOT modify the existing auth skip logic (lines 121-140) — it already correctly skips auth for these GET endpoints per D-01.
|
||||||
|
4. Do NOT apply rate limits to `/api/auth/*` or OAuth endpoints — those already have the original `rateLimit` (5/15min) applied where needed.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts && bun run lint</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- index.ts contains `import { createRateLimit } from "./middleware/rateLimit"`
|
||||||
|
- index.ts contains `const browseTier = createRateLimit(120, 60_000)`
|
||||||
|
- index.ts contains `const detailTier = createRateLimit(60, 60_000)`
|
||||||
|
- index.ts contains rate limit middleware for `/api/global-items`, `/api/tags`, `/api/global-items/:id`, `/api/setups/:id/public`, `/api/users/:id/profile`
|
||||||
|
- Rate limit middleware is placed BEFORE the auth middleware block
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All public GET endpoints have tiered rate limits applied. Browse endpoints (global-items list, tags) at 120/min, detail endpoints (global-item detail, public setup, profile) at 60/min.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/middleware/rateLimit.test.ts` — all rate limit tests pass
|
||||||
|
- `bun run lint` — no lint errors
|
||||||
|
- `bun test` — full suite passes (no regressions)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- createRateLimit factory is exported and tested with configurable limits
|
||||||
|
- Original rateLimit export unchanged in behavior (backward compatible)
|
||||||
|
- All 5 public GET endpoint groups have rate limits applied in index.ts
|
||||||
|
- Rate limits are applied before auth middleware
|
||||||
|
- No new dependencies added
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/24-public-access-infrastructure/24-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
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