@prmichaelsen/acp-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# Auth Session Management Pattern
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: TanStack Start + Cloudflare Workers applications with authentication
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This pattern provides cookie-based session management for TanStack Start applications running on Cloudflare Workers. It uses Firebase Admin SDK for token verification, HTTP-only session cookies for security, and a server function (`getAuthSession`) that can be called from any component, route, or API handler to get the current authenticated user.
|
|
12
|
+
|
|
13
|
+
The pattern enforces server-side-only authentication — all auth checks happen on the server, never on the client. This prevents token exposure and ensures that authentication state is always authoritative.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## When to Use This Pattern
|
|
18
|
+
|
|
19
|
+
✅ **Use this pattern when:**
|
|
20
|
+
- Building authenticated TanStack Start applications
|
|
21
|
+
- Need cookie-based session management
|
|
22
|
+
- Using Firebase Authentication as the identity provider
|
|
23
|
+
- Need a universal `getAuthSession()` function callable from any context
|
|
24
|
+
- Want server-side auth enforcement (never client-side token verification)
|
|
25
|
+
|
|
26
|
+
❌ **Don't use this pattern when:**
|
|
27
|
+
- Building public-only applications with no authentication
|
|
28
|
+
- Using a different auth provider that handles sessions differently (Auth0, Clerk)
|
|
29
|
+
- Building static sites
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Core Principles
|
|
34
|
+
|
|
35
|
+
1. **Server-Side Only**: All authentication verification happens on the server — never verify tokens client-side
|
|
36
|
+
2. **Cookie-Based Sessions**: Use HTTP-only cookies to store session tokens (not localStorage)
|
|
37
|
+
3. **Universal Server Function**: `getAuthSession()` is a `createServerFn` callable from any context
|
|
38
|
+
4. **Graceful Fallback**: Auth failures return `null`, never throw — callers decide how to handle
|
|
39
|
+
5. **Session Cookie Exchange**: Exchange short-lived ID tokens for long-lived session cookies (14 days)
|
|
40
|
+
6. **Request-Based Verification**: `getServerSession(request)` extracts and verifies the cookie from the request
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Implementation
|
|
45
|
+
|
|
46
|
+
### Structure
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/
|
|
50
|
+
├── lib/
|
|
51
|
+
│ └── auth/
|
|
52
|
+
│ ├── session.ts # Server-side session verification
|
|
53
|
+
│ └── server-fn.ts # createServerFn wrapper for components
|
|
54
|
+
├── types/
|
|
55
|
+
│ └── auth.ts # User and ServerSession types
|
|
56
|
+
├── components/
|
|
57
|
+
│ └── auth/
|
|
58
|
+
│ ├── AuthContext.tsx # React context for auth state
|
|
59
|
+
│ └── AuthForm.tsx # Login/register UI
|
|
60
|
+
└── routes/
|
|
61
|
+
├── __root.tsx # Root layout with auth initialization
|
|
62
|
+
├── auth.tsx # Auth page
|
|
63
|
+
└── api/
|
|
64
|
+
└── auth/
|
|
65
|
+
├── session.tsx # POST: Create session cookie
|
|
66
|
+
└── logout.tsx # POST: Destroy session
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Code Example
|
|
70
|
+
|
|
71
|
+
#### Step 1: Define Auth Types
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// src/types/auth.ts
|
|
75
|
+
export interface User {
|
|
76
|
+
uid: string
|
|
77
|
+
email: string | null
|
|
78
|
+
displayName: string | null
|
|
79
|
+
photoURL: string | null
|
|
80
|
+
emailVerified: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ServerSession {
|
|
84
|
+
user: User
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### Step 2: Server-Side Session Verification
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// src/lib/auth/session.ts
|
|
92
|
+
import { verifyIdToken, verifySessionCookie, createSessionCookie as createFirebaseSessionCookie }
|
|
93
|
+
from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
94
|
+
import type { User, ServerSession } from '@/types/auth'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract session cookie from request headers
|
|
98
|
+
*/
|
|
99
|
+
function getSessionCookie(request: Request): string | undefined {
|
|
100
|
+
const cookieHeader = request.headers.get('cookie')
|
|
101
|
+
if (!cookieHeader) return undefined
|
|
102
|
+
|
|
103
|
+
const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
|
|
104
|
+
const [name, value] = cookie.trim().split('=')
|
|
105
|
+
acc[name] = value
|
|
106
|
+
return acc
|
|
107
|
+
}, {} as Record<string, string>)
|
|
108
|
+
|
|
109
|
+
return cookies.session
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get authenticated user from request
|
|
114
|
+
* Returns null if not authenticated — never throws
|
|
115
|
+
*/
|
|
116
|
+
export async function getServerSession(request: Request): Promise<ServerSession | null> {
|
|
117
|
+
try {
|
|
118
|
+
const sessionCookie = getSessionCookie(request)
|
|
119
|
+
if (!sessionCookie) return null
|
|
120
|
+
|
|
121
|
+
// Verify session cookie (try session cookie first, fallback to ID token)
|
|
122
|
+
let decodedToken
|
|
123
|
+
try {
|
|
124
|
+
decodedToken = await verifySessionCookie(sessionCookie)
|
|
125
|
+
} catch {
|
|
126
|
+
decodedToken = await verifyIdToken(sessionCookie)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const user: User = {
|
|
130
|
+
uid: decodedToken.sub,
|
|
131
|
+
email: decodedToken.email || null,
|
|
132
|
+
displayName: decodedToken.name || null,
|
|
133
|
+
photoURL: decodedToken.picture || null,
|
|
134
|
+
emailVerified: decodedToken.email_verified || false,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { user }
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('Failed to get server session', error)
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a long-lived session cookie from a Firebase ID token
|
|
146
|
+
*/
|
|
147
|
+
export async function createSessionCookie(idToken: string): Promise<string> {
|
|
148
|
+
return createFirebaseSessionCookie(idToken, {
|
|
149
|
+
expiresIn: 60 * 60 * 24 * 14 * 1000 // 14 days
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Step 3: Universal Server Function
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// src/lib/auth/server-fn.ts
|
|
158
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
159
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
160
|
+
import { getServerSession } from '@/lib/auth/session'
|
|
161
|
+
import { initFirebaseAdmin } from '@/lib/firebase-admin'
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Server function to get current auth session.
|
|
165
|
+
* Callable from any component, route, or server context.
|
|
166
|
+
*/
|
|
167
|
+
export const getAuthSession = createServerFn({ method: 'GET' }).handler(async () => {
|
|
168
|
+
try {
|
|
169
|
+
initFirebaseAdmin()
|
|
170
|
+
const session = await getServerSession(getRequest())
|
|
171
|
+
return session?.user || null
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('[getAuthSession] Error:', error)
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
#### Step 4: Root Layout with Auth Initialization
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// src/routes/__root.tsx
|
|
183
|
+
import { createRootRouteWithContext } from '@tanstack/react-router'
|
|
184
|
+
import { getAuthSession } from '@/lib/auth/server-fn'
|
|
185
|
+
import { AuthProvider } from '@/components/auth/AuthContext'
|
|
186
|
+
|
|
187
|
+
export const Route = createRootRouteWithContext()({
|
|
188
|
+
beforeLoad: async () => {
|
|
189
|
+
// Fetch auth session server-side (SSR)
|
|
190
|
+
const user = await getAuthSession()
|
|
191
|
+
return { user }
|
|
192
|
+
},
|
|
193
|
+
component: RootLayout,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
function RootLayout() {
|
|
197
|
+
const { user } = Route.useRouteContext()
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<AuthProvider initialUser={user}>
|
|
201
|
+
<Outlet />
|
|
202
|
+
</AuthProvider>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Step 5: Auth Context Provider
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// src/components/auth/AuthContext.tsx
|
|
211
|
+
import { createContext, useContext, useState } from 'react'
|
|
212
|
+
import type { User } from '@/types/auth'
|
|
213
|
+
|
|
214
|
+
interface AuthContextType {
|
|
215
|
+
user: User | null
|
|
216
|
+
setUser: (user: User | null) => void
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const AuthContext = createContext<AuthContextType | null>(null)
|
|
220
|
+
|
|
221
|
+
export function AuthProvider({ initialUser, children }: {
|
|
222
|
+
initialUser: User | null
|
|
223
|
+
children: React.ReactNode
|
|
224
|
+
}) {
|
|
225
|
+
const [user, setUser] = useState<User | null>(initialUser)
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<AuthContext.Provider value={{ user, setUser }}>
|
|
229
|
+
{children}
|
|
230
|
+
</AuthContext.Provider>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function useAuth(): AuthContextType {
|
|
235
|
+
const ctx = useContext(AuthContext)
|
|
236
|
+
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
|
237
|
+
return ctx
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### Step 6: Protected Route with Redirect
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// src/routes/profile.tsx
|
|
245
|
+
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
246
|
+
import { getAuthSession } from '@/lib/auth/server-fn'
|
|
247
|
+
|
|
248
|
+
export const Route = createFileRoute('/profile')({
|
|
249
|
+
beforeLoad: async () => {
|
|
250
|
+
const user = await getAuthSession()
|
|
251
|
+
|
|
252
|
+
if (!user) {
|
|
253
|
+
throw redirect({
|
|
254
|
+
to: '/auth',
|
|
255
|
+
search: { redirect_url: '/profile' },
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { user }
|
|
260
|
+
},
|
|
261
|
+
component: ProfilePage,
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
#### Step 7: Session Creation API Route
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// src/routes/api/auth/session.tsx
|
|
269
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
270
|
+
import { createSessionCookie } from '@/lib/auth/session'
|
|
271
|
+
|
|
272
|
+
export const Route = createFileRoute('/api/auth/session')({
|
|
273
|
+
server: {
|
|
274
|
+
handlers: {
|
|
275
|
+
POST: async ({ request }) => {
|
|
276
|
+
try {
|
|
277
|
+
const { idToken } = await request.json()
|
|
278
|
+
const sessionCookie = await createSessionCookie(idToken)
|
|
279
|
+
|
|
280
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: {
|
|
283
|
+
'Content-Type': 'application/json',
|
|
284
|
+
'Set-Cookie': `session=${sessionCookie}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${60 * 60 * 24 * 14}`,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return new Response(JSON.stringify({ error: 'Failed to create session' }), {
|
|
289
|
+
status: 401,
|
|
290
|
+
headers: { 'Content-Type': 'application/json' },
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Benefits
|
|
302
|
+
|
|
303
|
+
### 1. Universal Auth Check
|
|
304
|
+
`getAuthSession()` works in components, `beforeLoad`, API routes, and server functions — one function everywhere.
|
|
305
|
+
|
|
306
|
+
### 2. Secure by Default
|
|
307
|
+
HTTP-only cookies prevent XSS attacks. Server-side verification prevents token tampering.
|
|
308
|
+
|
|
309
|
+
### 3. SSR Compatible
|
|
310
|
+
Auth state is available during server-side rendering via `beforeLoad`, enabling instant authenticated content.
|
|
311
|
+
|
|
312
|
+
### 4. Graceful Degradation
|
|
313
|
+
Auth failures return `null` instead of throwing, preventing cascading errors.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Trade-offs
|
|
318
|
+
|
|
319
|
+
### 1. Firebase Dependency
|
|
320
|
+
**Downside**: Tightly coupled to Firebase Admin SDK for token verification.
|
|
321
|
+
**Mitigation**: Wrap in an interface if you anticipate switching auth providers.
|
|
322
|
+
|
|
323
|
+
### 2. Cookie Size Limits
|
|
324
|
+
**Downside**: Session cookies have size limits (~4KB).
|
|
325
|
+
**Mitigation**: Store minimal data in the cookie (just the session token), not user profile data.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Anti-Patterns
|
|
330
|
+
|
|
331
|
+
### ❌ Anti-Pattern 1: Client-Side Token Verification
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// ❌ BAD: Verifying tokens on the client
|
|
335
|
+
function MyComponent() {
|
|
336
|
+
const token = localStorage.getItem('token')
|
|
337
|
+
const user = jwt.decode(token) // Client-side decode — not verified!
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ✅ GOOD: Always verify on server
|
|
341
|
+
const user = await getAuthSession()
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### ❌ Anti-Pattern 2: Throwing on Auth Failure
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// ❌ BAD: Throwing prevents page from loading
|
|
348
|
+
export async function getServerSession(request) {
|
|
349
|
+
const cookie = getSessionCookie(request)
|
|
350
|
+
if (!cookie) throw new Error('Not authenticated') // Crashes!
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ✅ GOOD: Return null, let callers decide
|
|
354
|
+
export async function getServerSession(request) {
|
|
355
|
+
const cookie = getSessionCookie(request)
|
|
356
|
+
if (!cookie) return null // Graceful
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Related Patterns
|
|
363
|
+
|
|
364
|
+
- **[API Route Handlers](./tanstack-cloudflare.api-route-handlers.md)**: API routes use `getAuthSession()` for auth
|
|
365
|
+
- **[SSR Preload Pattern](./tanstack-cloudflare.ssr-preload.md)**: `beforeLoad` uses auth for user-specific data
|
|
366
|
+
- **[Rate Limiting](./tanstack-cloudflare.rate-limiting.md)**: Rate limit auth endpoints
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Checklist for Implementation
|
|
371
|
+
|
|
372
|
+
- [ ] `getServerSession(request)` returns `ServerSession | null`
|
|
373
|
+
- [ ] `getAuthSession` is a `createServerFn` wrapper
|
|
374
|
+
- [ ] Session cookie is HTTP-only, Secure, SameSite=Lax
|
|
375
|
+
- [ ] Root layout fetches auth in `beforeLoad`
|
|
376
|
+
- [ ] AuthProvider wraps app with `initialUser` from SSR
|
|
377
|
+
- [ ] Protected routes use `redirect` to `/auth` when not authenticated
|
|
378
|
+
- [ ] API routes check `getAuthSession()` before processing
|
|
379
|
+
- [ ] Auth failures never throw — always return null
|
|
380
|
+
- [ ] Firebase Admin SDK initialized before verification
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
**Status**: Stable - Proven pattern for TanStack Start authentication
|
|
385
|
+
**Recommendation**: Use for all authenticated TanStack Start applications
|
|
386
|
+
**Last Updated**: 2026-02-28
|
|
387
|
+
**Contributors**: Patrick Michaelsen
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Card, NotificationCard & CardList
|
|
2
|
+
|
|
3
|
+
**Category**: Design
|
|
4
|
+
**Applicable To**: All card-based data display, notification items with swipe-to-dismiss, and generic feed/list rendering
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Cards are the primary data display unit across the app. This pattern covers the standard card styling, NotificationCard with swipe-to-dismiss gesture, and CardList (FeedList) — a generic list component that handles loading skeletons, empty states, and error banners for any card type.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Implementation
|
|
16
|
+
|
|
17
|
+
### Card (Standard Styling)
|
|
18
|
+
|
|
19
|
+
All cards in the app follow consistent styling:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// Standard card container
|
|
23
|
+
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-4">
|
|
24
|
+
{/* Card content */}
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
// With hover effect
|
|
28
|
+
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-4
|
|
29
|
+
hover:border-blue-500/50 transition-colors cursor-pointer">
|
|
30
|
+
{/* Clickable card */}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
// Highlighted/active
|
|
34
|
+
<div className="bg-purple-900/20 border border-purple-400/60 rounded-xl p-4 shadow-purple-500/10">
|
|
35
|
+
{/* Active card */}
|
|
36
|
+
</div>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Text Hierarchy**:
|
|
40
|
+
- Title: `text-white font-semibold`
|
|
41
|
+
- Subtitle/secondary: `text-gray-400 text-sm`
|
|
42
|
+
- Meta/timestamp: `text-gray-500 text-xs`
|
|
43
|
+
|
|
44
|
+
**Action Buttons** (icon buttons within cards):
|
|
45
|
+
- Base: `p-2 text-gray-400 hover:text-white hover:bg-gray-700/50 rounded transition-colors`
|
|
46
|
+
- Danger: `text-red-400 hover:text-red-300`
|
|
47
|
+
- Disabled: `opacity-50 cursor-not-allowed`
|
|
48
|
+
|
|
49
|
+
**State Variants**:
|
|
50
|
+
- Deleted/faded: `opacity-20 transition-opacity duration-200`
|
|
51
|
+
- Loading: `animate-pulse bg-gray-800`
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### NotificationCard
|
|
56
|
+
|
|
57
|
+
**File**: `src/components/notifications/NotificationCard.tsx`
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
interface NotificationCardProps {
|
|
61
|
+
notification: Notification
|
|
62
|
+
onMarkAsRead: (id: string) => void
|
|
63
|
+
onOpenFriendRequest?: (notification: Notification) => void
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Features**:
|
|
68
|
+
- Unread indicator: blue dot (w-2 h-2) on left side
|
|
69
|
+
- Avatar circle (w-10 h-10) or type-specific icon with colored background
|
|
70
|
+
- Content: title (truncated), message (line-clamp-2), relative timestamp
|
|
71
|
+
- Message count badge (top-right, blue pill)
|
|
72
|
+
- **Swipe-to-dismiss**: horizontal swipe >80px threshold
|
|
73
|
+
- Reveal background: `bg-blue-600/30`
|
|
74
|
+
- Smooth dismiss animation (300ms translateX)
|
|
75
|
+
- Calls `onMarkAsRead` on dismiss
|
|
76
|
+
- Type-specific icons: `friend_request`, `friend_accepted`, `group_invite`, `new_message`, `system`, `memory_published`, `memory_comment`, `organize_nudge`
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### CardList (Generic Feed Primitive)
|
|
81
|
+
|
|
82
|
+
**File**: `src/components/feed/FeedList.tsx`
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface CardListProps<T> {
|
|
86
|
+
items: T[]
|
|
87
|
+
loading: boolean
|
|
88
|
+
error: string | null
|
|
89
|
+
renderItem: (item: T, index: number) => ReactNode
|
|
90
|
+
emptyIcon: ReactNode
|
|
91
|
+
emptyMessage: string
|
|
92
|
+
skeletonCount?: number // default: 4
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function FeedList<T>({ items, loading, error, renderItem, emptyIcon, emptyMessage, skeletonCount = 4 }: CardListProps<T>) {
|
|
96
|
+
// Error state: red banner
|
|
97
|
+
if (error) return <div className="text-red-400 p-4 ...">{error}</div>
|
|
98
|
+
|
|
99
|
+
// Loading state: skeleton cards
|
|
100
|
+
if (loading) return (
|
|
101
|
+
<div className="space-y-2">
|
|
102
|
+
{Array.from({ length: skeletonCount }).map((_, i) => (
|
|
103
|
+
<div key={i} className="bg-gray-800 rounded-xl h-32 animate-pulse" />
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// Empty state: centered icon + message
|
|
109
|
+
if (items.length === 0) return (
|
|
110
|
+
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
|
111
|
+
{emptyIcon}
|
|
112
|
+
<p className="mt-2">{emptyMessage}</p>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// Items
|
|
117
|
+
return <div className="space-y-2">{items.map(renderItem)}</div>
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Usage**:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
<FeedList
|
|
125
|
+
items={conversations}
|
|
126
|
+
loading={loading}
|
|
127
|
+
error={error}
|
|
128
|
+
renderItem={(conv, i) => <ConversationCard key={conv.id} conversation={conv} />}
|
|
129
|
+
emptyIcon={<MessageSquare className="w-12 h-12 text-gray-600" />}
|
|
130
|
+
emptyMessage="No conversations yet"
|
|
131
|
+
skeletonCount={6}
|
|
132
|
+
/>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
### Virtualized Lists (react-virtuoso)
|
|
138
|
+
|
|
139
|
+
For feeds with many items, use `react-virtuoso` instead of mapping all items to DOM. Two usage patterns exist:
|
|
140
|
+
|
|
141
|
+
#### Pattern A: Window-Scroll Feed (Memories, Spaces, Groups, Profiles)
|
|
142
|
+
|
|
143
|
+
Used for page-level feeds where the entire page scrolls. `useWindowScroll` delegates scrolling to the browser window.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { Virtuoso } from 'react-virtuoso'
|
|
147
|
+
|
|
148
|
+
<Virtuoso
|
|
149
|
+
useWindowScroll
|
|
150
|
+
data={memories}
|
|
151
|
+
endReached={() => {
|
|
152
|
+
if (hasMore && !loading && !loadingMore) {
|
|
153
|
+
loadFeed(false) // Append next page
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
itemContent={(index, memory) => (
|
|
157
|
+
<div className="pb-2">
|
|
158
|
+
<MemoryCard memory={memory} source={source} />
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
components={{
|
|
162
|
+
Footer: () =>
|
|
163
|
+
loadingMore ? (
|
|
164
|
+
<div className="flex justify-center py-4">
|
|
165
|
+
<Loader2 className="w-5 h-5 animate-spin text-gray-500" />
|
|
166
|
+
</div>
|
|
167
|
+
) : null,
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Key props**:
|
|
173
|
+
- `useWindowScroll`: Scroll container is the browser window, not Virtuoso's own div
|
|
174
|
+
- `data`: The array of items to render
|
|
175
|
+
- `endReached`: Callback when user scrolls to the bottom — trigger load-more
|
|
176
|
+
- `itemContent`: Render function per item (wraps card in `pb-2` for gap)
|
|
177
|
+
- `components.Footer`: Loading spinner while fetching next page
|
|
178
|
+
|
|
179
|
+
**Used in**: `/memories`, `SpacesFeed`, `ProfileMemoriesFeed`, `GroupMemories`
|
|
180
|
+
|
|
181
|
+
#### Pattern B: Container-Scroll Chat (MessageList)
|
|
182
|
+
|
|
183
|
+
Used for chat where messages prepend from the top and the container has a fixed height.
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
|
187
|
+
|
|
188
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
|
189
|
+
|
|
190
|
+
<Virtuoso
|
|
191
|
+
ref={virtuosoRef}
|
|
192
|
+
className="flex-grow h-0"
|
|
193
|
+
firstItemIndex={firstItemIndex}
|
|
194
|
+
initialTopMostItemIndex={items.length - 1}
|
|
195
|
+
data={items}
|
|
196
|
+
startReached={() => {
|
|
197
|
+
if (!isLoadingMore && hasMore && onLoadMore) {
|
|
198
|
+
setIsLoadingMore(true)
|
|
199
|
+
onLoadMore()
|
|
200
|
+
}
|
|
201
|
+
}}
|
|
202
|
+
itemContent={(index, item) => <Message ... />}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
// Programmatic scroll to bottom
|
|
206
|
+
virtuosoRef.current?.scrollToIndex({ index: 'LAST', behavior: 'smooth' })
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Key props**:
|
|
210
|
+
- `firstItemIndex`: Enables stable prepend — set to a large number minus item count, decrement as older messages load
|
|
211
|
+
- `initialTopMostItemIndex`: Start at bottom (`items.length - 1`)
|
|
212
|
+
- `startReached`: Callback when user scrolls to the top — load older messages
|
|
213
|
+
- `ref` (`VirtuosoHandle`): Exposes `scrollToIndex` for programmatic scroll (new message arrival, search result navigation)
|
|
214
|
+
|
|
215
|
+
**Used in**: `MessageList`
|
|
216
|
+
|
|
217
|
+
#### When to Use Each
|
|
218
|
+
|
|
219
|
+
| Pattern | When | Scroll Container |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| FeedList (non-virtualized) | < ~50 items, simple lists | Parent div |
|
|
222
|
+
| Virtuoso `useWindowScroll` | Feed pages with infinite scroll | Browser window |
|
|
223
|
+
| Virtuoso container-scroll | Chat with prepend, fixed height | Virtuoso div |
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Anti-Patterns
|
|
228
|
+
|
|
229
|
+
### Inconsistent Card Styling
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// Bad: Custom card styling that doesn't match the system
|
|
233
|
+
<div className="bg-white rounded-md p-2 shadow">{content}</div>
|
|
234
|
+
|
|
235
|
+
// Good: Use standard card classes
|
|
236
|
+
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-4">
|
|
237
|
+
{content}
|
|
238
|
+
</div>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Reimplementing Loading/Empty/Error States
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// Bad: Custom loading/empty per page
|
|
245
|
+
{loading ? <Spinner /> : items.length === 0 ? <p>Empty</p> : items.map(...)}
|
|
246
|
+
|
|
247
|
+
// Good: Use CardList/FeedList
|
|
248
|
+
<FeedList items={items} loading={loading} error={error}
|
|
249
|
+
renderItem={(item) => <MyCard item={item} />}
|
|
250
|
+
emptyIcon={<Icon />} emptyMessage="Nothing here" />
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Checklist
|
|
256
|
+
|
|
257
|
+
- [ ] Cards use `bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-4`
|
|
258
|
+
- [ ] Text hierarchy follows: white (title), gray-400 (subtitle), gray-500 (meta)
|
|
259
|
+
- [ ] Use `FeedList` for small static lists with loading/empty/error states
|
|
260
|
+
- [ ] Use `Virtuoso` with `useWindowScroll` for feed pages with infinite scroll
|
|
261
|
+
- [ ] Use `Virtuoso` container-scroll with `firstItemIndex` for chat-style prepend lists
|
|
262
|
+
- [ ] Wrap each Virtuoso item in `<div className="pb-2">` for consistent card gap
|
|
263
|
+
- [ ] Provide `components.Footer` with loading spinner for load-more feedback
|
|
264
|
+
- [ ] Swipe-to-dismiss uses 80px threshold with reveal background
|
|
265
|
+
- [ ] Deleted/faded items use `opacity-20` transition
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
**Status**: Stable
|
|
270
|
+
**Last Updated**: 2026-03-14
|
|
271
|
+
**Contributors**: Community
|