@remix-run/cli 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +77 -3
- package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
- package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
- package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
- package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
- package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
- package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
- package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
- package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
- package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
- package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
- package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
- package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
- package/bootstrap/AGENTS.md +39 -0
- package/bootstrap/README.md +27 -0
- package/bootstrap/app/assets/entry.ts +19 -0
- package/bootstrap/app/assets.ts +18 -0
- package/bootstrap/app/controllers/auth.tsx +21 -0
- package/bootstrap/app/controllers/home.tsx +11 -0
- package/bootstrap/app/router.ts +16 -0
- package/bootstrap/app/routes.ts +7 -0
- package/bootstrap/app/ui/document.tsx +26 -0
- package/bootstrap/app/ui/layout.tsx +22 -0
- package/bootstrap/app/ui/prompt-button.tsx +162 -0
- package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
- package/bootstrap/app/utils/render.tsx +26 -0
- package/bootstrap/package.json +22 -0
- package/bootstrap/server.ts +37 -0
- package/bootstrap/tsconfig.json +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/bootstrap-project.d.ts +17 -0
- package/dist/lib/bootstrap-project.d.ts.map +1 -0
- package/dist/lib/bootstrap-project.js +137 -0
- package/dist/lib/cli-context.d.ts +11 -0
- package/dist/lib/cli-context.d.ts.map +1 -0
- package/dist/lib/cli-context.js +19 -0
- package/dist/lib/cli.d.ts +6 -0
- package/dist/lib/cli.d.ts.map +1 -0
- package/dist/lib/cli.js +84 -0
- package/dist/lib/commands/completion.d.ts +3 -0
- package/dist/lib/commands/completion.d.ts.map +1 -0
- package/dist/lib/commands/completion.js +51 -0
- package/dist/lib/commands/doctor.d.ts +4 -0
- package/dist/lib/commands/doctor.d.ts.map +1 -0
- package/dist/lib/commands/doctor.js +293 -0
- package/dist/lib/commands/help.d.ts +4 -0
- package/dist/lib/commands/help.d.ts.map +1 -0
- package/dist/lib/commands/help.js +100 -0
- package/dist/lib/commands/new.d.ts +4 -0
- package/dist/lib/commands/new.d.ts.map +1 -0
- package/dist/lib/commands/new.js +70 -0
- package/dist/lib/commands/routes.d.ts +4 -0
- package/dist/lib/commands/routes.d.ts.map +1 -0
- package/dist/lib/commands/routes.js +161 -0
- package/dist/lib/commands/test.d.ts +4 -0
- package/dist/lib/commands/test.d.ts.map +1 -0
- package/dist/lib/commands/test.js +23 -0
- package/dist/lib/commands/version.d.ts +4 -0
- package/dist/lib/commands/version.d.ts.map +1 -0
- package/dist/lib/commands/version.js +29 -0
- package/dist/lib/completion.d.ts +12 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +310 -0
- package/dist/lib/contained-path.d.ts +2 -0
- package/dist/lib/contained-path.d.ts.map +1 -0
- package/dist/lib/contained-path.js +10 -0
- package/dist/lib/controller-files.d.ts +14 -0
- package/dist/lib/controller-files.d.ts.map +1 -0
- package/dist/lib/controller-files.js +53 -0
- package/dist/lib/controller-ownership.d.ts +41 -0
- package/dist/lib/controller-ownership.d.ts.map +1 -0
- package/dist/lib/controller-ownership.js +224 -0
- package/dist/lib/display-path.d.ts +2 -0
- package/dist/lib/display-path.d.ts.map +1 -0
- package/dist/lib/display-path.js +12 -0
- package/dist/lib/doctor/controller-findings.d.ts +4 -0
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -0
- package/dist/lib/doctor/controller-findings.js +132 -0
- package/dist/lib/doctor/controller-fix-plans.d.ts +4 -0
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -0
- package/dist/lib/doctor/controller-fix-plans.js +130 -0
- package/dist/lib/doctor/controller-placeholders.d.ts +4 -0
- package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -0
- package/dist/lib/doctor/controller-placeholders.js +183 -0
- package/dist/lib/doctor/controllers.d.ts +8 -0
- package/dist/lib/doctor/controllers.d.ts.map +1 -0
- package/dist/lib/doctor/controllers.js +13 -0
- package/dist/lib/doctor/environment.d.ts +17 -0
- package/dist/lib/doctor/environment.d.ts.map +1 -0
- package/dist/lib/doctor/environment.js +226 -0
- package/dist/lib/doctor/fixes.d.ts +3 -0
- package/dist/lib/doctor/fixes.d.ts.map +1 -0
- package/dist/lib/doctor/fixes.js +65 -0
- package/dist/lib/doctor/project.d.ts +10 -0
- package/dist/lib/doctor/project.d.ts.map +1 -0
- package/dist/lib/doctor/project.js +371 -0
- package/dist/lib/doctor/types.d.ts +48 -0
- package/dist/lib/doctor/types.d.ts.map +1 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/errors.d.ts +167 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +307 -0
- package/dist/lib/help-text.d.ts +13 -0
- package/dist/lib/help-text.d.ts.map +1 -0
- package/dist/lib/help-text.js +43 -0
- package/dist/lib/load-route-map-worker.d.ts +2 -0
- package/dist/lib/load-route-map-worker.d.ts.map +1 -0
- package/dist/lib/load-route-map-worker.js +92 -0
- package/dist/lib/parse-args.d.ts +22 -0
- package/dist/lib/parse-args.d.ts.map +1 -0
- package/dist/lib/parse-args.js +35 -0
- package/dist/lib/remix-version.d.ts +2 -0
- package/dist/lib/remix-version.d.ts.map +1 -0
- package/dist/lib/remix-version.js +58 -0
- package/dist/lib/reporter.d.ts +56 -0
- package/dist/lib/reporter.d.ts.map +1 -0
- package/dist/lib/reporter.js +357 -0
- package/dist/lib/route-map.d.ts +38 -0
- package/dist/lib/route-map.d.ts.map +1 -0
- package/dist/lib/route-map.js +195 -0
- package/dist/lib/terminal.d.ts +17 -0
- package/dist/lib/terminal.d.ts.map +1 -0
- package/dist/lib/terminal.js +66 -0
- package/package.json +47 -5
- package/src/index.ts +1 -0
- package/src/lib/bootstrap-project.ts +222 -0
- package/src/lib/cli-context.ts +38 -0
- package/src/lib/cli.ts +116 -0
- package/src/lib/commands/completion.ts +74 -0
- package/src/lib/commands/doctor.ts +393 -0
- package/src/lib/commands/help.ts +123 -0
- package/src/lib/commands/new.ts +104 -0
- package/src/lib/commands/routes.ts +251 -0
- package/src/lib/commands/test.ts +31 -0
- package/src/lib/commands/version.ts +39 -0
- package/src/lib/completion.ts +418 -0
- package/src/lib/contained-path.ts +13 -0
- package/src/lib/controller-files.ts +79 -0
- package/src/lib/controller-ownership.ts +397 -0
- package/src/lib/display-path.ts +16 -0
- package/src/lib/doctor/controller-findings.ts +157 -0
- package/src/lib/doctor/controller-fix-plans.ts +184 -0
- package/src/lib/doctor/controller-placeholders.ts +242 -0
- package/src/lib/doctor/controllers.ts +23 -0
- package/src/lib/doctor/environment.ts +289 -0
- package/src/lib/doctor/fixes.ts +84 -0
- package/src/lib/doctor/project.ts +435 -0
- package/src/lib/doctor/types.ts +95 -0
- package/src/lib/errors.ts +367 -0
- package/src/lib/help-text.ts +88 -0
- package/src/lib/load-route-map-worker.ts +110 -0
- package/src/lib/parse-args.ts +82 -0
- package/src/lib/remix-version.ts +80 -0
- package/src/lib/reporter.ts +535 -0
- package/src/lib/route-map.ts +303 -0
- package/src/lib/terminal.ts +86 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Authentication and Sessions
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How to remember things about a browser between requests and how to identify a user. Read this when
|
|
6
|
+
the task involves:
|
|
7
|
+
|
|
8
|
+
- Storing per-browser state across requests (login, cart, "I have submitted this form")
|
|
9
|
+
- Adding a credentials login flow or an OAuth provider
|
|
10
|
+
- Protecting routes with `requireAuth()` or stacking authorization checks
|
|
11
|
+
- Reading or writing `Session`, `Auth`, or other identity-related context values
|
|
12
|
+
- Logging in, logging out, or rotating session IDs
|
|
13
|
+
|
|
14
|
+
For raw cookies that are not session-backed (theme, locale, dismissed-banner), see
|
|
15
|
+
`createCookie` in this file plus the broader `Package Map` in `SKILL.md`.
|
|
16
|
+
|
|
17
|
+
## Sessions vs Plain Cookies
|
|
18
|
+
|
|
19
|
+
Reach for `remix/session` when state is sensitive, must be tamper-resistant, or represents the
|
|
20
|
+
identity of a request: who is logged in, which form a browser already submitted, what items are in
|
|
21
|
+
a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a
|
|
22
|
+
typed `Session` object you can `get`, `set`, `flash`, `unset`, and `regenerateId`.
|
|
23
|
+
|
|
24
|
+
Reach for `remix/cookie` directly when the browser is allowed to carry the value and the server
|
|
25
|
+
does not need session semantics. This often means preferences (theme, locale, dismissed banner),
|
|
26
|
+
but a signed cookie can also be fine for small low-risk values where you truly only need one
|
|
27
|
+
cookie-shaped fact and do not need `Session` helpers.
|
|
28
|
+
|
|
29
|
+
If a malicious user editing the value would be a bug, or if the value needs server-managed
|
|
30
|
+
lifecycle, reach for a session.
|
|
31
|
+
|
|
32
|
+
### Quick chooser
|
|
33
|
+
|
|
34
|
+
| Need | Best fit | Why |
|
|
35
|
+
| ------------------------------------------------------------------- | --------------- | -------------------------------------------------- |
|
|
36
|
+
| Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference |
|
|
37
|
+
| Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed |
|
|
38
|
+
| "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state |
|
|
39
|
+
| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
|
|
40
|
+
|
|
41
|
+
## Session Setup
|
|
42
|
+
|
|
43
|
+
### Create a session cookie
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { createCookie } from 'remix/cookie'
|
|
47
|
+
|
|
48
|
+
let sessionSecret = process.env.SESSION_SECRET
|
|
49
|
+
if (!sessionSecret && process.env.NODE_ENV !== 'test') {
|
|
50
|
+
throw new Error('SESSION_SECRET is required')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export let sessionCookie = createCookie('session', {
|
|
54
|
+
secrets: [sessionSecret ?? 'test-only-secret'],
|
|
55
|
+
httpOnly: true,
|
|
56
|
+
sameSite: 'Lax',
|
|
57
|
+
secure: process.env.NODE_ENV === 'production',
|
|
58
|
+
maxAge: 2592000, // 30 days
|
|
59
|
+
path: '/',
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be `secure` in
|
|
64
|
+
production. Demo defaults like `'s3cr3t'` are fine in tests but should never reach production —
|
|
65
|
+
fail fast when the secret is missing.
|
|
66
|
+
|
|
67
|
+
### Create session storage
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// Filesystem storage
|
|
71
|
+
import { createFsSessionStorage } from 'remix/session/fs-storage'
|
|
72
|
+
export let sessionStorage = createFsSessionStorage('./tmp/sessions')
|
|
73
|
+
|
|
74
|
+
// Memory storage (for tests)
|
|
75
|
+
import { createMemorySessionStorage } from 'remix/session/memory-storage'
|
|
76
|
+
export let sessionStorage = createMemorySessionStorage()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Add session middleware
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { session } from 'remix/session-middleware'
|
|
83
|
+
|
|
84
|
+
let router = createRouter({
|
|
85
|
+
middleware: [
|
|
86
|
+
session(sessionCookie, sessionStorage),
|
|
87
|
+
// ... other middleware
|
|
88
|
+
],
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Using sessions in handlers
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { Session } from 'remix/session'
|
|
96
|
+
|
|
97
|
+
async function handler({ get }) {
|
|
98
|
+
let session = get(Session)
|
|
99
|
+
|
|
100
|
+
// Read
|
|
101
|
+
let userId = session.get('userId')
|
|
102
|
+
|
|
103
|
+
// Write
|
|
104
|
+
session.set('userId', 42)
|
|
105
|
+
|
|
106
|
+
// Flash (read once, then cleared)
|
|
107
|
+
session.flash('message', 'Settings saved!')
|
|
108
|
+
let message = session.get('message') // returns and clears
|
|
109
|
+
|
|
110
|
+
// Remove a key
|
|
111
|
+
session.unset('userId')
|
|
112
|
+
|
|
113
|
+
// Regenerate session ID (after login/logout)
|
|
114
|
+
session.regenerateId(true)
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Sessions for non-auth state
|
|
119
|
+
|
|
120
|
+
Sessions are not just for login. They are the right place to store any tamper-sensitive
|
|
121
|
+
per-browser fact: which form a browser already submitted, how many free actions are left in a
|
|
122
|
+
trial, which feature flags a tester opted into, what items are in a cart.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
async function submit({ get }) {
|
|
126
|
+
let session = get(Session)
|
|
127
|
+
if (session.get('hasSubmitted')) {
|
|
128
|
+
return render(<AlreadySubmittedPage />, { status: 409 })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let parsed = s.parseSafe(submitSchema, get(FormData))
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await saveSubmission(parsed.value)
|
|
137
|
+
session.set('hasSubmitted', true)
|
|
138
|
+
session.flash('message', 'Thanks for submitting!')
|
|
139
|
+
|
|
140
|
+
return redirect(routes.thanks.href())
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Notice that there is no manual `Set-Cookie` plumbing in the action — the session middleware handles
|
|
145
|
+
that, and the handler returns an ordinary `Response`. Per-browser state enforced this way is still
|
|
146
|
+
bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account
|
|
147
|
+
(see auth providers below).
|
|
148
|
+
|
|
149
|
+
## Auth Middleware
|
|
150
|
+
|
|
151
|
+
### Basic setup
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { auth, createSessionAuthScheme } from 'remix/auth-middleware'
|
|
155
|
+
import { Session } from 'remix/session'
|
|
156
|
+
import { Database } from 'remix/data-table'
|
|
157
|
+
|
|
158
|
+
export function loadAuth() {
|
|
159
|
+
return auth({
|
|
160
|
+
schemes: [
|
|
161
|
+
createSessionAuthScheme({
|
|
162
|
+
read(session) {
|
|
163
|
+
let data = session.get('auth')
|
|
164
|
+
return data ?? null
|
|
165
|
+
},
|
|
166
|
+
async verify(value, context) {
|
|
167
|
+
let db = context.get(Database)
|
|
168
|
+
return (await db.find(users, value.userId)) ?? null
|
|
169
|
+
},
|
|
170
|
+
invalidate(session) {
|
|
171
|
+
session.unset('auth')
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
],
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Reading auth state
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { Auth } from 'remix/auth-middleware'
|
|
183
|
+
|
|
184
|
+
function handler({ get }) {
|
|
185
|
+
let auth = get(Auth)
|
|
186
|
+
|
|
187
|
+
if (auth.ok) {
|
|
188
|
+
// User is authenticated
|
|
189
|
+
let user = auth.identity
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Credentials Auth
|
|
195
|
+
|
|
196
|
+
### Define a credentials provider
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { createCredentialsAuthProvider, verifyCredentials, completeAuth } from 'remix/auth'
|
|
200
|
+
import * as s from 'remix/data-schema'
|
|
201
|
+
import * as f from 'remix/data-schema/form-data'
|
|
202
|
+
|
|
203
|
+
let loginSchema = f.object({
|
|
204
|
+
email: f.field(s.defaulted(s.string(), '')),
|
|
205
|
+
password: f.field(s.defaulted(s.string(), '')),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
export let passwordProvider = createCredentialsAuthProvider({
|
|
209
|
+
parse(context) {
|
|
210
|
+
let formData = context.get(FormData)
|
|
211
|
+
return s.parse(loginSchema, formData)
|
|
212
|
+
},
|
|
213
|
+
async verify({ email, password }, context) {
|
|
214
|
+
let db = context.get(Database)
|
|
215
|
+
let user = await db.findOne(users, { where: { email } })
|
|
216
|
+
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
return user
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Login action
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { verifyCredentials, completeAuth } from 'remix/auth'
|
|
228
|
+
import { redirect } from 'remix/response/redirect'
|
|
229
|
+
|
|
230
|
+
async action(context) {
|
|
231
|
+
let user = await verifyCredentials(passwordProvider, context)
|
|
232
|
+
|
|
233
|
+
if (user == null) {
|
|
234
|
+
let session = context.get(Session)
|
|
235
|
+
session.flash('error', 'Invalid email or password.')
|
|
236
|
+
return redirect(routes.auth.login.href())
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let session = completeAuth(context)
|
|
240
|
+
session.set('auth', { userId: user.id })
|
|
241
|
+
|
|
242
|
+
return redirect(routes.home.href())
|
|
243
|
+
},
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Logout action
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
import { Session } from 'remix/session'
|
|
250
|
+
import { redirect } from 'remix/response/redirect'
|
|
251
|
+
|
|
252
|
+
function logout(context) {
|
|
253
|
+
let session = context.get(Session)
|
|
254
|
+
session.unset('auth')
|
|
255
|
+
session.regenerateId(true)
|
|
256
|
+
return redirect(routes.home.href())
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## OAuth / External Auth
|
|
261
|
+
|
|
262
|
+
### Create providers
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import {
|
|
266
|
+
createAtmosphereAuthProvider,
|
|
267
|
+
createGoogleAuthProvider,
|
|
268
|
+
createGitHubAuthProvider,
|
|
269
|
+
startExternalAuth,
|
|
270
|
+
finishExternalAuth,
|
|
271
|
+
completeAuth,
|
|
272
|
+
refreshExternalAuth,
|
|
273
|
+
} from 'remix/auth'
|
|
274
|
+
|
|
275
|
+
let googleProvider = createGoogleAuthProvider({
|
|
276
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
277
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
278
|
+
redirectUri: new URL(routes.auth.google.callback.href(), origin),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
let githubProvider = createGitHubAuthProvider({
|
|
282
|
+
clientId: process.env.GITHUB_CLIENT_ID,
|
|
283
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
284
|
+
redirectUri: new URL(routes.auth.github.callback.href(), origin),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
|
|
288
|
+
if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
|
|
289
|
+
throw new Error('ATMOSPHERE_SESSION_SECRET is required')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let atmosphereProvider = createAtmosphereAuthProvider({
|
|
293
|
+
clientId: 'https://app.example.com/oauth/client-metadata.json',
|
|
294
|
+
redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
|
|
295
|
+
sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
|
|
296
|
+
})
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
For Atmosphere-compatible atproto OAuth, create the provider once, call
|
|
300
|
+
`atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then pass the same
|
|
301
|
+
module-scope provider to `finishExternalAuth(...)` and `refreshExternalAuth(...)`.
|
|
302
|
+
|
|
303
|
+
### OAuth controller
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
export default {
|
|
307
|
+
actions: {
|
|
308
|
+
// GET /auth/google — redirect to Google
|
|
309
|
+
async index(context) {
|
|
310
|
+
return await startExternalAuth(googleProvider, context, {
|
|
311
|
+
returnTo: context.url.searchParams.get('returnTo'),
|
|
312
|
+
})
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
// GET /auth/google/callback — handle redirect back
|
|
316
|
+
async callback(context) {
|
|
317
|
+
let { result, returnTo } = await finishExternalAuth(googleProvider, context)
|
|
318
|
+
|
|
319
|
+
let db = context.get(Database)
|
|
320
|
+
let { user, authAccount } = await resolveExternalAuth(db, result)
|
|
321
|
+
|
|
322
|
+
let session = completeAuth(context)
|
|
323
|
+
session.set('auth', {
|
|
324
|
+
userId: user.id,
|
|
325
|
+
loginMethod: result.provider,
|
|
326
|
+
authAccountId: authAccount.id,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return redirect(returnTo ?? routes.account.href())
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
} satisfies Controller<typeof routes.auth.google>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Refresh stored provider tokens
|
|
336
|
+
|
|
337
|
+
Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a
|
|
338
|
+
fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support
|
|
339
|
+
refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle
|
|
340
|
+
preserves the current one.
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
async function refreshGoogleTokens({ get }) {
|
|
344
|
+
let db = get(Database)
|
|
345
|
+
let account = await db.findOne(authAccounts, { where: { provider: 'google' } })
|
|
346
|
+
if (!account) return null
|
|
347
|
+
|
|
348
|
+
let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
|
|
349
|
+
await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
|
|
350
|
+
|
|
351
|
+
return refreshed.tokens
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Protecting Routes
|
|
356
|
+
|
|
357
|
+
### Controller-level protection
|
|
358
|
+
|
|
359
|
+
Apply `requireAuth()` to an entire controller subtree:
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { requireAuth } from 'remix/auth-middleware'
|
|
363
|
+
|
|
364
|
+
export default {
|
|
365
|
+
middleware: [requireAuth()],
|
|
366
|
+
actions: {
|
|
367
|
+
index() {
|
|
368
|
+
/* guaranteed authenticated */
|
|
369
|
+
},
|
|
370
|
+
settings: settingsController,
|
|
371
|
+
},
|
|
372
|
+
} satisfies Controller<typeof routes.account>
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Stacking middleware
|
|
376
|
+
|
|
377
|
+
Combine auth checks with role checks:
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
export default {
|
|
381
|
+
middleware: [requireAuth(), requireAdmin()],
|
|
382
|
+
actions: {
|
|
383
|
+
index() {
|
|
384
|
+
/* requires auth + admin */
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
} satisfies Controller<typeof routes.admin>
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Action-level protection
|
|
391
|
+
|
|
392
|
+
Apply middleware to a single route:
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
import { Auth, requireAuth } from 'remix/auth-middleware'
|
|
396
|
+
|
|
397
|
+
router.get(routes.account, {
|
|
398
|
+
middleware: [requireAuth()],
|
|
399
|
+
handler(context) {
|
|
400
|
+
let auth = context.get(Auth)
|
|
401
|
+
return render(<AccountPage identity={auth.identity} />)
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Redirect on auth failure
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
import { requireAuth } from 'remix/auth-middleware'
|
|
410
|
+
import { redirect } from 'remix/response/redirect'
|
|
411
|
+
|
|
412
|
+
export function requireAuthRedirect() {
|
|
413
|
+
return requireAuth({
|
|
414
|
+
onFailure(context) {
|
|
415
|
+
let returnTo = encodeURIComponent(context.url.pathname)
|
|
416
|
+
return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
```
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# Component Model
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when
|
|
6
|
+
the task involves:
|
|
7
|
+
|
|
8
|
+
- Writing a component (`handle` plus render function)
|
|
9
|
+
- Managing component-local state, derived values, or post-render DOM work
|
|
10
|
+
- Using `handle.props`, `handle.update()`, `handle.queueTask()`, `handle.signal`, `handle.id`, or
|
|
11
|
+
`handle.context`
|
|
12
|
+
- Listening to global events with cleanup tied to the component lifecycle
|
|
13
|
+
|
|
14
|
+
For host-element behavior (event handlers, styles, refs, animations), see
|
|
15
|
+
`mixins-styling-events.md`. For browser hydration, frames, and navigation, see
|
|
16
|
+
`hydration-frames-navigation.md`.
|
|
17
|
+
|
|
18
|
+
## Phases
|
|
19
|
+
|
|
20
|
+
A component has two phases:
|
|
21
|
+
|
|
22
|
+
1. **Setup phase** — runs once when the component is created
|
|
23
|
+
2. **Render phase** — returned function runs on initial render and every update
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { on, type Handle } from 'remix/ui'
|
|
27
|
+
|
|
28
|
+
function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
|
|
29
|
+
let count = handle.props.initialCount ?? 0
|
|
30
|
+
|
|
31
|
+
return () => (
|
|
32
|
+
<button
|
|
33
|
+
mix={on('click', () => {
|
|
34
|
+
count++
|
|
35
|
+
handle.update()
|
|
36
|
+
})}
|
|
37
|
+
>
|
|
38
|
+
{handle.props.label}: {count}
|
|
39
|
+
</button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Props
|
|
45
|
+
|
|
46
|
+
Components receive all JSX props through `handle.props`. The object identity is stable for the
|
|
47
|
+
component lifetime, and its values are updated before each render. Put initialization inputs on
|
|
48
|
+
normal JSX props and read them from `handle.props`:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
|
|
52
|
+
let seconds = handle.props.initialSeconds
|
|
53
|
+
|
|
54
|
+
return () => <div>Time remaining: {seconds}s</div>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Usage: <Timer initialSeconds={60} paused={false} />
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Because `handle.props` is stable, destructuring `let { props } = handle` is safe when helpers need
|
|
61
|
+
to read current values later. Destructuring individual prop values is only a snapshot; prefer
|
|
62
|
+
`handle.props.name` inside callbacks and render output when values can change.
|
|
63
|
+
|
|
64
|
+
## State Rules
|
|
65
|
+
|
|
66
|
+
- Keep state in setup scope as plain JavaScript variables.
|
|
67
|
+
- Store only what affects rendering. Derive computed values in render.
|
|
68
|
+
- Do not mirror input state unless you truly need controlled behavior.
|
|
69
|
+
- Do work in event handlers, not in render. Use the handler scope for transient state.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// Derive computed values in render
|
|
73
|
+
function TodoList(handle: Handle) {
|
|
74
|
+
let todos: Array<{ text: string; completed: boolean }> = []
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
let completedCount = todos.filter((t) => t.completed).length
|
|
78
|
+
return <div>Completed: {completedCount}</div>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Handle API
|
|
84
|
+
|
|
85
|
+
### `handle.update()`
|
|
86
|
+
|
|
87
|
+
Schedules a rerender. Returns a promise that resolves with an `AbortSignal` after the update
|
|
88
|
+
completes. Await it when you need the updated DOM before follow-up work:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
on('click', async () => {
|
|
92
|
+
isPlaying = true
|
|
93
|
+
let signal = await handle.update()
|
|
94
|
+
// DOM is now updated, safe to focus or measure
|
|
95
|
+
stopButton.focus()
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `handle.queueTask(task)`
|
|
100
|
+
|
|
101
|
+
Schedules a task to run after the next update. The task receives an `AbortSignal` that aborts when
|
|
102
|
+
the component re-renders or is removed. Use for post-render DOM work, reactive data loading, or
|
|
103
|
+
hydration-sensitive setup:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
let data = null
|
|
107
|
+
let requestedUrl: string | null = null
|
|
108
|
+
|
|
109
|
+
// Post-render DOM work in an event handler
|
|
110
|
+
on('click', () => {
|
|
111
|
+
showDetails = true
|
|
112
|
+
handle.update()
|
|
113
|
+
handle.queueTask(() => {
|
|
114
|
+
detailsSection.scrollIntoView({ behavior: 'smooth' })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Reactive data loading keyed by props.url
|
|
119
|
+
return () => {
|
|
120
|
+
if (requestedUrl !== handle.props.url) {
|
|
121
|
+
let nextUrl = handle.props.url
|
|
122
|
+
requestedUrl = nextUrl
|
|
123
|
+
data = null
|
|
124
|
+
|
|
125
|
+
handle.queueTask(async (signal) => {
|
|
126
|
+
let response = await fetch(nextUrl, { signal })
|
|
127
|
+
let json = await response.json()
|
|
128
|
+
if (signal.aborted || requestedUrl !== nextUrl) return
|
|
129
|
+
data = json
|
|
130
|
+
handle.update()
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return <div>{data ?? 'Loading...'}</div>
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Avoid creating intermediate state just to trigger `queueTask`. Do the work directly in the handler
|
|
139
|
+
or the queued task.
|
|
140
|
+
|
|
141
|
+
### `handle.signal`
|
|
142
|
+
|
|
143
|
+
An `AbortSignal` aborted when the component disconnects. Use for cleanup:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
function Clock(handle: Handle) {
|
|
147
|
+
let interval = setInterval(handle.update, 1000)
|
|
148
|
+
handle.signal.addEventListener('abort', () => clearInterval(interval))
|
|
149
|
+
|
|
150
|
+
return () => <span>{new Date().toString()}</span>
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `handle.id`
|
|
155
|
+
|
|
156
|
+
Stable identifier per component instance. Useful for `htmlFor`, `aria-owns`, etc.:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
function LabeledInput(handle: Handle) {
|
|
160
|
+
return () => (
|
|
161
|
+
<div>
|
|
162
|
+
<label htmlFor={handle.id}>Name</label>
|
|
163
|
+
<input id={handle.id} type="text" />
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `handle.frame` and `handle.frames`
|
|
170
|
+
|
|
171
|
+
Frame-aware behavior for client entries rendered inside frames:
|
|
172
|
+
|
|
173
|
+
- `handle.frame.reload()` — reload the containing frame
|
|
174
|
+
- `handle.frame.src` — the URL of the containing frame
|
|
175
|
+
- `handle.frames.top` — the root frame (the whole page)
|
|
176
|
+
- `handle.frames.top.reload()` — reload the entire page/frame tree
|
|
177
|
+
- `handle.frames.get(name)` — look up a named frame; returns `FrameHandle | undefined`
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
function RefreshButton(handle: Handle) {
|
|
181
|
+
return () => <button mix={on('click', () => handle.frame.reload())}>Refresh</button>
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### `handle.context`
|
|
186
|
+
|
|
187
|
+
Context for ancestor/descendant communication. See the context section below.
|
|
188
|
+
|
|
189
|
+
## Context
|
|
190
|
+
|
|
191
|
+
Use `handle.context.set()` to provide values and `handle.context.get(Provider)` to consume them.
|
|
192
|
+
`set()` does **not** trigger updates — call `handle.update()` if the tree needs to rerender.
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
function ThemeProvider(handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>) {
|
|
196
|
+
let theme: 'light' | 'dark' = 'light'
|
|
197
|
+
handle.context.set({ theme })
|
|
198
|
+
|
|
199
|
+
return () => (
|
|
200
|
+
<div>
|
|
201
|
+
<button
|
|
202
|
+
mix={on('click', () => {
|
|
203
|
+
theme = theme === 'light' ? 'dark' : 'light'
|
|
204
|
+
handle.context.set({ theme })
|
|
205
|
+
handle.update()
|
|
206
|
+
})}
|
|
207
|
+
>
|
|
208
|
+
Toggle
|
|
209
|
+
</button>
|
|
210
|
+
{handle.props.children}
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ThemedContent(handle: Handle) {
|
|
216
|
+
let { theme } = handle.context.get(ThemeProvider)
|
|
217
|
+
return () => <div>Current theme: {theme}</div>
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
For granular updates without re-rendering the full subtree, use `TypedEventTarget`:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { TypedEventTarget, addEventListeners } from 'remix/ui'
|
|
225
|
+
|
|
226
|
+
class Theme extends TypedEventTarget<{ change: Event }> {
|
|
227
|
+
#value: 'light' | 'dark' = 'light'
|
|
228
|
+
get value() {
|
|
229
|
+
return this.#value
|
|
230
|
+
}
|
|
231
|
+
setValue(value: 'light' | 'dark') {
|
|
232
|
+
this.#value = value
|
|
233
|
+
this.dispatchEvent(new Event('change'))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
|
|
238
|
+
let theme = new Theme()
|
|
239
|
+
handle.context.set(theme)
|
|
240
|
+
|
|
241
|
+
return () => (
|
|
242
|
+
<div>
|
|
243
|
+
<button mix={on('click', () => theme.setValue(theme.value === 'light' ? 'dark' : 'light'))}>
|
|
244
|
+
Toggle
|
|
245
|
+
</button>
|
|
246
|
+
{handle.props.children}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function ThemedContent(handle: Handle) {
|
|
252
|
+
let theme = handle.context.get(ThemeProvider)
|
|
253
|
+
addEventListeners(theme, handle.signal, {
|
|
254
|
+
change() {
|
|
255
|
+
handle.update()
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
return () => <div>Theme: {theme.value}</div>
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Global Events
|
|
263
|
+
|
|
264
|
+
Use `addEventListeners(target, handle.signal, listeners)` to listen to global targets with
|
|
265
|
+
automatic cleanup when the component disconnects:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
import { addEventListeners, type Handle } from 'remix/ui'
|
|
269
|
+
|
|
270
|
+
function ResizeTracker(handle: Handle) {
|
|
271
|
+
let width = window.innerWidth
|
|
272
|
+
|
|
273
|
+
addEventListeners(window, handle.signal, {
|
|
274
|
+
resize() {
|
|
275
|
+
width = window.innerWidth
|
|
276
|
+
handle.update()
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
return () => <div>{width}</div>
|
|
281
|
+
}
|
|
282
|
+
```
|