odoro 1.0.9 → 2.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 +94 -0
- package/client.d.ts +90 -16
- package/dist/build-XJIP4GNT.js +5 -0
- package/dist/{chunk-34RGOZFA.js → chunk-4D56Z7G3.js} +49 -50
- package/dist/{chunk-JMEHF3KN.js → chunk-7ZB5MAM6.js} +4 -4
- package/dist/chunk-DF67LADH.js +735 -0
- package/dist/{chunk-LHZGX5ML.js → chunk-HS7NGZLM.js} +307 -196
- package/dist/chunk-LIKVHWB4.js +231 -0
- package/dist/chunk-QOVBLN7A.js +241 -0
- package/dist/chunk-RTWJ5GFB.js +729 -0
- package/dist/chunk-TONYIKCN.js +57 -0
- package/dist/cli.d.ts +12 -13
- package/dist/cli.js +86 -63
- package/dist/{commands-AHMXBWQQ.js → commands-GYA7NTCX.js} +124 -124
- package/dist/commands-UOWN3IK4.js +181 -0
- package/dist/{create-CYVSVDAD.js → create-B2TLV6XI.js} +198 -160
- package/dist/index.d.ts +603 -151
- package/dist/index.js +6 -6
- package/dist/{package-EIDLKAA6.js → package-3ZJOEXM4.js} +6 -5
- package/dist/preview-3IRG7MHV.js +4 -0
- package/dist/registry/index.d.ts +75 -76
- package/dist/registry/index.js +1 -1
- package/dist/{server-4UGN3SFS.js → server-ZB3P3EGS.js} +3 -3
- package/package.json +6 -5
- package/templates/react-ts/README.md +71 -35
- package/templates/react-ts/_env.example +9 -0
- package/templates/react-ts/_variants/with-engine/src/background.tsx +118 -0
- package/templates/react-ts/_variants/without-libs/src/App.tsx +380 -0
- package/templates/react-ts/_variants/without-libs/src/background.tsx +24 -0
- package/templates/react-ts/_variants/without-libs/src/entry-server.tsx +39 -0
- package/templates/react-ts/_variants/without-libs/src/main.tsx +26 -0
- package/templates/{react-ts-server/_variantes/sans-libs/client → react-ts/_variants/without-libs}/src/styles.css +137 -137
- package/templates/react-ts/{_variantes/sans-routeur → _variants/without-router}/src/App.tsx +141 -141
- package/templates/react-ts/_variants/without-router/src/entry-server.tsx +39 -0
- package/templates/react-ts/index.html +4 -4
- package/templates/react-ts/odoro.config.ts +5 -0
- package/templates/react-ts/src/App.tsx +219 -209
- package/templates/react-ts/src/background.tsx +66 -0
- package/templates/react-ts/src/entry-server.tsx +85 -0
- package/templates/react-ts/src/main.tsx +13 -4
- package/templates/react-ts/src/router.tsx +65 -45
- package/templates/react-ts/src/styles.css +5 -5
- package/templates/react-ts-server/Dockerfile +9 -9
- package/templates/react-ts-server/README.md +97 -45
- package/templates/react-ts-server/_env.example +31 -33
- package/templates/react-ts-server/_variants/with-engine/client/src/background.tsx +118 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/App.tsx +380 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/background.tsx +24 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/main.tsx +26 -0
- package/templates/{react-ts/_variantes/sans-libs → react-ts-server/_variants/without-libs/client}/src/styles.css +137 -137
- package/templates/react-ts-server/{_variantes/sans-routeur → _variants/without-router}/client/src/App.tsx +141 -141
- package/templates/react-ts-server/_variants/without-router/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/client/index.html +4 -4
- package/templates/react-ts-server/client/src/App.tsx +228 -210
- package/templates/react-ts-server/client/src/account.tsx +267 -0
- package/templates/react-ts-server/client/src/auth.tsx +139 -0
- package/templates/react-ts-server/client/src/background.tsx +66 -0
- package/templates/react-ts-server/client/src/entry-server.tsx +92 -0
- package/templates/react-ts-server/client/src/main.tsx +17 -5
- package/templates/react-ts-server/client/src/router.tsx +77 -45
- package/templates/react-ts-server/client/src/styles.css +5 -5
- package/templates/react-ts-server/odoro.config.ts +7 -4
- package/templates/react-ts-server/package.json +2 -0
- package/templates/react-ts-server/scripts/dev.mjs +14 -14
- package/templates/react-ts-server/server/src/main.ts +119 -45
- package/templates/react-ts-server/server/src/modules/auth/index.ts +238 -0
- package/templates/react-ts-server/server/src/modules/auth/password.ts +122 -0
- package/templates/react-ts-server/server/src/modules/auth/store.ts +193 -0
- package/templates/react-ts-server/server/src/modules/health/index.ts +45 -46
- package/dist/build-JFQHODAT.js +0 -5
- package/dist/chunk-22KJTV2R.js +0 -380
- package/dist/chunk-3SZIN6VG.js +0 -64
- package/dist/chunk-DL3NPC4H.js +0 -113
- package/dist/chunk-TQUJ3MFS.js +0 -268
- package/dist/chunk-ZVL7EXJO.js +0 -66
- package/dist/commands-4JRBD55Z.js +0 -245
- package/dist/preview-7DKEAQOQ.js +0 -4
- package/templates/react-ts/_variantes/avec-moteur/src/fond.tsx +0 -118
- package/templates/react-ts/_variantes/sans-libs/src/App.tsx +0 -382
- package/templates/react-ts/_variantes/sans-libs/src/fond.tsx +0 -23
- package/templates/react-ts/_variantes/sans-libs/src/main.tsx +0 -17
- package/templates/react-ts/src/fond.tsx +0 -66
- package/templates/react-ts-server/_variantes/avec-moteur/client/src/fond.tsx +0 -118
- package/templates/react-ts-server/_variantes/sans-libs/client/src/App.tsx +0 -382
- package/templates/react-ts-server/_variantes/sans-libs/client/src/fond.tsx +0 -23
- package/templates/react-ts-server/_variantes/sans-libs/client/src/main.tsx +0 -17
- package/templates/react-ts-server/client/src/fond.tsx +0 -66
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three account pages: sign in, register, profile.
|
|
3
|
+
*
|
|
4
|
+
* ## Why they live apart from `App.tsx`
|
|
5
|
+
*
|
|
6
|
+
* `App.tsx` holds the page you see before signing in, and it is meant to be
|
|
7
|
+
* read in one go. These three depend on the back end: they are the part to
|
|
8
|
+
* open when wiring the API, and the part to delete when the project does not
|
|
9
|
+
* need accounts.
|
|
10
|
+
*
|
|
11
|
+
* ## What the form does about errors
|
|
12
|
+
*
|
|
13
|
+
* It shows what the server said, and nothing else. The foundation answers in
|
|
14
|
+
* `problem+json`, where `detail` is a sentence written for a human — "This
|
|
15
|
+
* address already has an account." A status code in its place would be a
|
|
16
|
+
* number in front of someone who mistyped their email.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { buttonClasses } from '@odoro-cli/libs/ui'
|
|
22
|
+
import { useState, type FormEvent, type ReactElement, type ReactNode } from 'react'
|
|
23
|
+
|
|
24
|
+
import { useSession } from '@/auth'
|
|
25
|
+
import { Link } from '@/router'
|
|
26
|
+
|
|
27
|
+
/** The frame the three pages share. */
|
|
28
|
+
function Panel({
|
|
29
|
+
title,
|
|
30
|
+
lead,
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
readonly title: string
|
|
34
|
+
readonly lead: string
|
|
35
|
+
readonly children: ReactNode
|
|
36
|
+
}): ReactElement {
|
|
37
|
+
return (
|
|
38
|
+
<section className="o-px-6 o-py-24">
|
|
39
|
+
<div className="o-mx-auto o-w-full o-max-w-md">
|
|
40
|
+
<h1 className="o-text-3xl o-font-bold o-tracking-tight">{title}</h1>
|
|
41
|
+
<p className="o-mt-3 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">{lead}</p>
|
|
42
|
+
<div className="o-mt-8">{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A labelled field. */
|
|
49
|
+
function Field({
|
|
50
|
+
label,
|
|
51
|
+
type,
|
|
52
|
+
value,
|
|
53
|
+
onChange,
|
|
54
|
+
autoComplete,
|
|
55
|
+
hint,
|
|
56
|
+
}: {
|
|
57
|
+
readonly label: string
|
|
58
|
+
readonly type: string
|
|
59
|
+
readonly value: string
|
|
60
|
+
readonly onChange: (value: string) => void
|
|
61
|
+
readonly autoComplete: string
|
|
62
|
+
readonly hint?: string
|
|
63
|
+
}): ReactElement {
|
|
64
|
+
return (
|
|
65
|
+
<label className="o-block o-mb-4">
|
|
66
|
+
<span className="o-block o-mb-1 o-text-sm o-font-medium">{label}</span>
|
|
67
|
+
<input
|
|
68
|
+
type={type}
|
|
69
|
+
value={value}
|
|
70
|
+
required
|
|
71
|
+
autoComplete={autoComplete}
|
|
72
|
+
onChange={(event) => onChange(event.target.value)}
|
|
73
|
+
className="o-w-full o-rounded-lg o-border o-border-zinc-200 dark:o-border-zinc-800 o-bg-transparent o-px-3 o-py-2 o-text-sm"
|
|
74
|
+
/>
|
|
75
|
+
{hint === undefined ? null : (
|
|
76
|
+
<span className="o-mt-1 o-block o-text-xs o-text-zinc-500 dark:o-text-zinc-400">
|
|
77
|
+
{hint}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</label>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The error of a submission, when there is one. */
|
|
85
|
+
function Problem({ message }: { readonly message: string | null }): ReactElement | null {
|
|
86
|
+
if (message === null) return null
|
|
87
|
+
return (
|
|
88
|
+
<p
|
|
89
|
+
// `alert` so a screen reader announces it: a message that only appears
|
|
90
|
+
// visually leaves whoever does not see it waiting on a form that did
|
|
91
|
+
// nothing.
|
|
92
|
+
role="alert"
|
|
93
|
+
className="o-mb-4 o-rounded-lg o-border o-border-red-300 dark:o-border-red-900 o-px-3 o-py-2 o-text-sm o-text-red-700 dark:o-text-red-400"
|
|
94
|
+
>
|
|
95
|
+
{message}
|
|
96
|
+
</p>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Runs a submission, holding the pending state and the error. */
|
|
101
|
+
function useSubmission(): {
|
|
102
|
+
busy: boolean
|
|
103
|
+
error: string | null
|
|
104
|
+
run: (action: () => Promise<void>) => (event: FormEvent) => void
|
|
105
|
+
} {
|
|
106
|
+
const [busy, setBusy] = useState(false)
|
|
107
|
+
const [error, setError] = useState<string | null>(null)
|
|
108
|
+
|
|
109
|
+
const run = (action: () => Promise<void>) => (event: FormEvent) => {
|
|
110
|
+
event.preventDefault()
|
|
111
|
+
setBusy(true)
|
|
112
|
+
setError(null)
|
|
113
|
+
action()
|
|
114
|
+
.catch((cause: unknown) => {
|
|
115
|
+
setError(cause instanceof Error ? cause.message : 'The request failed.')
|
|
116
|
+
})
|
|
117
|
+
.finally(() => setBusy(false))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { busy, error, run }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Signing in. */
|
|
124
|
+
export function SignIn(): ReactElement {
|
|
125
|
+
const { profile, signIn } = useSession()
|
|
126
|
+
const [email, setEmail] = useState('')
|
|
127
|
+
const [password, setPassword] = useState('')
|
|
128
|
+
const { busy, error, run } = useSubmission()
|
|
129
|
+
|
|
130
|
+
if (profile != null) return <AlreadySignedIn name={profile.name} />
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Panel title="Sign in" lead="The session is a cookie: nothing to keep in the page.">
|
|
134
|
+
<form onSubmit={run(() => signIn(email, password))}>
|
|
135
|
+
<Problem message={error} />
|
|
136
|
+
<Field
|
|
137
|
+
label="Email"
|
|
138
|
+
type="email"
|
|
139
|
+
value={email}
|
|
140
|
+
onChange={setEmail}
|
|
141
|
+
autoComplete="email"
|
|
142
|
+
/>
|
|
143
|
+
<Field
|
|
144
|
+
label="Password"
|
|
145
|
+
type="password"
|
|
146
|
+
value={password}
|
|
147
|
+
onChange={setPassword}
|
|
148
|
+
autoComplete="current-password"
|
|
149
|
+
/>
|
|
150
|
+
<button type="submit" disabled={busy} className={buttonClasses({ tone: 'primary' })}>
|
|
151
|
+
{busy ? 'Signing in…' : 'Sign in'}
|
|
152
|
+
</button>
|
|
153
|
+
</form>
|
|
154
|
+
|
|
155
|
+
<p className="o-mt-6 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
|
|
156
|
+
No account yet? <Link to="/register">Create one</Link>.
|
|
157
|
+
</p>
|
|
158
|
+
</Panel>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Registering. */
|
|
163
|
+
export function Register(): ReactElement {
|
|
164
|
+
const { profile, register } = useSession()
|
|
165
|
+
const [email, setEmail] = useState('')
|
|
166
|
+
const [name, setName] = useState('')
|
|
167
|
+
const [password, setPassword] = useState('')
|
|
168
|
+
const { busy, error, run } = useSubmission()
|
|
169
|
+
|
|
170
|
+
if (profile != null) return <AlreadySignedIn name={profile.name} />
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Panel
|
|
174
|
+
title="Create an account"
|
|
175
|
+
lead="The password is hashed with scrypt, and never leaves the server in clear."
|
|
176
|
+
>
|
|
177
|
+
<form onSubmit={run(() => register(email, name, password))}>
|
|
178
|
+
<Problem message={error} />
|
|
179
|
+
<Field label="Name" type="text" value={name} onChange={setName} autoComplete="name" />
|
|
180
|
+
<Field
|
|
181
|
+
label="Email"
|
|
182
|
+
type="email"
|
|
183
|
+
value={email}
|
|
184
|
+
onChange={setEmail}
|
|
185
|
+
autoComplete="email"
|
|
186
|
+
/>
|
|
187
|
+
<Field
|
|
188
|
+
label="Password"
|
|
189
|
+
type="password"
|
|
190
|
+
value={password}
|
|
191
|
+
onChange={setPassword}
|
|
192
|
+
autoComplete="new-password"
|
|
193
|
+
hint="Twelve characters at least. Length is what holds; a mix of symbols is not."
|
|
194
|
+
/>
|
|
195
|
+
<button type="submit" disabled={busy} className={buttonClasses({ tone: 'primary' })}>
|
|
196
|
+
{busy ? 'Creating…' : 'Create the account'}
|
|
197
|
+
</button>
|
|
198
|
+
</form>
|
|
199
|
+
|
|
200
|
+
<p className="o-mt-6 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
|
|
201
|
+
Already have one? <Link to="/sign-in">Sign in</Link>.
|
|
202
|
+
</p>
|
|
203
|
+
</Panel>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The profile of the signed-in account. */
|
|
208
|
+
export function ProfilePage(): ReactElement {
|
|
209
|
+
const { profile, signOut } = useSession()
|
|
210
|
+
|
|
211
|
+
// Not asked yet: showing the signed-out page here would flash it at every
|
|
212
|
+
// reload, including to someone who is signed in.
|
|
213
|
+
if (profile === undefined) {
|
|
214
|
+
return <Panel title="Profile" lead="Reading the session…" children={null} />
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (profile === null) {
|
|
218
|
+
return (
|
|
219
|
+
<Panel title="Profile" lead="This page needs an account.">
|
|
220
|
+
<Link to="/sign-in" className={`o-no-underline ${buttonClasses({ tone: 'primary' })}`}>
|
|
221
|
+
Sign in
|
|
222
|
+
</Link>
|
|
223
|
+
</Panel>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Panel title={profile.name} lead="What the server knows about this account.">
|
|
229
|
+
<dl className="o-border-t o-border-zinc-200 dark:o-border-zinc-800">
|
|
230
|
+
{[
|
|
231
|
+
['Email', profile.email],
|
|
232
|
+
['Identifier', profile.id],
|
|
233
|
+
['Created', new Date(profile.createdAt).toLocaleString()],
|
|
234
|
+
].map(([label, value]) => (
|
|
235
|
+
<div
|
|
236
|
+
key={label}
|
|
237
|
+
className="o-flex o-gap-6 o-border-b o-border-zinc-200 dark:o-border-zinc-800 o-py-3"
|
|
238
|
+
>
|
|
239
|
+
<dt className="o-w-28 o-shrink-0 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
|
|
240
|
+
{label}
|
|
241
|
+
</dt>
|
|
242
|
+
<dd className="o-break-all o-font-mono o-text-sm">{value}</dd>
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</dl>
|
|
246
|
+
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => void signOut()}
|
|
250
|
+
className={`o-mt-8 ${buttonClasses({ tone: 'secondary' })}`}
|
|
251
|
+
>
|
|
252
|
+
Sign out
|
|
253
|
+
</button>
|
|
254
|
+
</Panel>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Shown on the two forms when the visitor already has a session. */
|
|
259
|
+
function AlreadySignedIn({ name }: { readonly name: string }): ReactElement {
|
|
260
|
+
return (
|
|
261
|
+
<Panel title={`Signed in as ${name}`} lead="Nothing to do here.">
|
|
262
|
+
<Link to="/profile" className={`o-no-underline ${buttonClasses({ tone: 'primary' })}`}>
|
|
263
|
+
See the profile
|
|
264
|
+
</Link>
|
|
265
|
+
</Panel>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session, client side.
|
|
3
|
+
*
|
|
4
|
+
* ## Why there is no token here
|
|
5
|
+
*
|
|
6
|
+
* The session lives in an `httpOnly` cookie that the browser sends on its own.
|
|
7
|
+
* This file never sees it, and that is the point: a value a script can read is
|
|
8
|
+
* a value the first injected script can steal.
|
|
9
|
+
*
|
|
10
|
+
* So the only question the interface can ask is "who am I", and the only way
|
|
11
|
+
* to ask it is `GET /api/auth/me`. That is one request at startup, and the
|
|
12
|
+
* answer is kept for the life of the page.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the state has three values and not two
|
|
15
|
+
*
|
|
16
|
+
* `undefined` is "not asked yet", `null` is "asked, nobody". Collapsing them
|
|
17
|
+
* would flash the signed-out interface at every reload, before the answer
|
|
18
|
+
* arrives — including to someone who is signed in.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
createContext,
|
|
25
|
+
useCallback,
|
|
26
|
+
useContext,
|
|
27
|
+
useEffect,
|
|
28
|
+
useMemo,
|
|
29
|
+
useState,
|
|
30
|
+
type ReactElement,
|
|
31
|
+
type ReactNode,
|
|
32
|
+
} from 'react'
|
|
33
|
+
|
|
34
|
+
/** A profile, as the API returns it. */
|
|
35
|
+
export interface Profile {
|
|
36
|
+
readonly id: string
|
|
37
|
+
readonly email: string
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly createdAt: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the interface can do with the session. */
|
|
43
|
+
export interface Session {
|
|
44
|
+
/** `undefined` while unknown, `null` when signed out. */
|
|
45
|
+
readonly profile: Profile | null | undefined
|
|
46
|
+
register(email: string, name: string, password: string): Promise<void>
|
|
47
|
+
signIn(email: string, password: string): Promise<void>
|
|
48
|
+
signOut(): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SessionContext = createContext<Session | undefined>(undefined)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A failed call, with the message the server gave.
|
|
55
|
+
*
|
|
56
|
+
* The foundation answers in `application/problem+json`: `title` says what
|
|
57
|
+
* happened, `detail` says why. Showing the HTTP status instead would put "409"
|
|
58
|
+
* in front of someone who typed an address that already has an account.
|
|
59
|
+
*/
|
|
60
|
+
async function call<T>(path: string, body?: unknown): Promise<T> {
|
|
61
|
+
const response = await fetch(path, {
|
|
62
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
63
|
+
// The cookie is only sent if it is asked for. Without this, every call is
|
|
64
|
+
// anonymous and the session looks broken for no visible reason.
|
|
65
|
+
credentials: 'same-origin',
|
|
66
|
+
...(body === undefined
|
|
67
|
+
? {}
|
|
68
|
+
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (response.status === 204) return undefined as T
|
|
72
|
+
|
|
73
|
+
const payload: unknown = await response.json().catch(() => undefined)
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const problem = payload as { detail?: string; title?: string } | undefined
|
|
77
|
+
throw new Error(problem?.detail ?? problem?.title ?? 'The request failed.')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return payload as T
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Provides the session to the whole tree. */
|
|
84
|
+
export function SessionProvider({ children }: { children: ReactNode }): ReactElement {
|
|
85
|
+
const [profile, setProfile] = useState<Profile | null | undefined>(undefined)
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let alive = true
|
|
89
|
+
call<Profile>('/api/auth/me')
|
|
90
|
+
.then((me) => {
|
|
91
|
+
if (alive) setProfile(me)
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {
|
|
94
|
+
// Not signed in, or the server has nothing to answer with. Either way
|
|
95
|
+
// the interface shows the signed-out state; it is not an incident.
|
|
96
|
+
if (alive) setProfile(null)
|
|
97
|
+
})
|
|
98
|
+
return () => {
|
|
99
|
+
alive = false
|
|
100
|
+
}
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const register = useCallback(async (email: string, name: string, password: string) => {
|
|
104
|
+
setProfile(await call<Profile>('/api/auth/register', { email, name, password }))
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
const signIn = useCallback(async (email: string, password: string) => {
|
|
108
|
+
setProfile(await call<Profile>('/api/auth/login', { email, password }))
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const signOut = useCallback(async () => {
|
|
112
|
+
await call<void>('/api/auth/logout', {})
|
|
113
|
+
setProfile(null)
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const value = useMemo<Session>(
|
|
117
|
+
() => ({ profile, register, signIn, signOut }),
|
|
118
|
+
[profile, register, signIn, signOut],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reads the session.
|
|
126
|
+
*
|
|
127
|
+
* @throws {Error} Outside `SessionProvider` — which is a wiring mistake, and
|
|
128
|
+
* is better said than silently returning "signed out".
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const { profile, signOut } = useSession()
|
|
132
|
+
*/
|
|
133
|
+
export function useSession(): Session {
|
|
134
|
+
const session = useContext(SessionContext)
|
|
135
|
+
if (session === undefined) {
|
|
136
|
+
throw new Error('useSession used outside SessionProvider.')
|
|
137
|
+
}
|
|
138
|
+
return session
|
|
139
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decorative background of the page.
|
|
3
|
+
*
|
|
4
|
+
* ## Why it is not in `App.tsx`
|
|
5
|
+
*
|
|
6
|
+
* It is the only piece of the page that depends on what was ticked at
|
|
7
|
+
* creation: with the engine, this file is replaced by a version that opens an
|
|
8
|
+
* animated WebGL surface; without it, it is the gradient below.
|
|
9
|
+
*
|
|
10
|
+
* Keeping it apart avoids having two `App.tsx` files to maintain — one per
|
|
11
|
+
* case — which would diverge at the first change of wording. `App.tsx` places
|
|
12
|
+
* `<Background />` and asks for nothing more.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the sizes are inline styles
|
|
15
|
+
*
|
|
16
|
+
* The style system **emits no arbitrary-value class**: `o-h-[42rem]` produces
|
|
17
|
+
* no rule at all, and a missing class paints nothing. The container ended up
|
|
18
|
+
* zero pixels tall, so did both glows, and the background was nowhere to be
|
|
19
|
+
* seen — with nothing to report it.
|
|
20
|
+
*
|
|
21
|
+
* The utilities cover the values on the scale; anything off the scale is
|
|
22
|
+
* written as a style, where it is safe.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ReactElement } from 'react'
|
|
28
|
+
|
|
29
|
+
/** Two brand glows, heavily diluted, behind the top of the page. */
|
|
30
|
+
export function Background(): ReactElement {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className="o-pointer-events-none o-absolute o-inset-x-0 o-top-0 o-overflow-hidden"
|
|
35
|
+
style={{ height: '42rem' }}
|
|
36
|
+
>
|
|
37
|
+
<div
|
|
38
|
+
className="o-absolute o-rounded-full"
|
|
39
|
+
style={{
|
|
40
|
+
left: '50%',
|
|
41
|
+
top: '-18rem',
|
|
42
|
+
width: '46rem',
|
|
43
|
+
height: '46rem',
|
|
44
|
+
transform: 'translateX(-50%)',
|
|
45
|
+
filter: 'blur(64px)',
|
|
46
|
+
opacity: 0.3,
|
|
47
|
+
background:
|
|
48
|
+
'radial-gradient(closest-side, var(--o-palette-brand-500), transparent)',
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
<div
|
|
52
|
+
className="o-absolute o-rounded-full"
|
|
53
|
+
style={{
|
|
54
|
+
right: '-10rem',
|
|
55
|
+
top: '6rem',
|
|
56
|
+
width: '30rem',
|
|
57
|
+
height: '30rem',
|
|
58
|
+
filter: 'blur(64px)',
|
|
59
|
+
opacity: 0.18,
|
|
60
|
+
background:
|
|
61
|
+
'radial-gradient(closest-side, var(--o-palette-brand-400), transparent)',
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prerendering entry point.
|
|
3
|
+
*
|
|
4
|
+
* ## What it does, and why it exists
|
|
5
|
+
*
|
|
6
|
+
* `odoro build` renders each of the routes below to HTML, at build time, and
|
|
7
|
+
* writes one complete document per route. The site stays a set of static files
|
|
8
|
+
* — nothing runs in production — but a search engine, a link preview or a
|
|
9
|
+
* screen reader find text in the very first response, instead of an empty
|
|
10
|
+
* container.
|
|
11
|
+
*
|
|
12
|
+
* `src/main.tsx` then takes over: seeing a container already filled, it
|
|
13
|
+
* hydrates instead of rebuilding.
|
|
14
|
+
*
|
|
15
|
+
* ## Adding a route
|
|
16
|
+
*
|
|
17
|
+
* A single line in `routes`. The path becomes a directory in `dist/`:
|
|
18
|
+
* `/pricing` gives `dist/pricing/index.html`, which any host serves with no
|
|
19
|
+
* configuration.
|
|
20
|
+
*
|
|
21
|
+
* ## Doing without
|
|
22
|
+
*
|
|
23
|
+
* Remove `prerender` from `odoro.config.ts`. This file can then go: nothing
|
|
24
|
+
* else imports it.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { StrictMode } from 'react'
|
|
30
|
+
import { renderToString } from 'react-dom/server'
|
|
31
|
+
|
|
32
|
+
import { App } from '@/App'
|
|
33
|
+
import { SessionProvider } from '@/auth'
|
|
34
|
+
|
|
35
|
+
/** The routes rendered at build time. */
|
|
36
|
+
export const routes = ['/', '/about']
|
|
37
|
+
|
|
38
|
+
/** What a page brings to the head of the document. */
|
|
39
|
+
const TITLES: Readonly<Record<string, { title: string; description: string }>> = {
|
|
40
|
+
'/': {
|
|
41
|
+
title: 'Odoro',
|
|
42
|
+
description: 'An Odoro project: in-house engine, generated styles, built-in router.',
|
|
43
|
+
},
|
|
44
|
+
'/about': {
|
|
45
|
+
title: 'About — Odoro',
|
|
46
|
+
description: 'What this project ships, and what each package does in it.',
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Escapes what goes into an HTML attribute. */
|
|
51
|
+
function escape(text: string): string {
|
|
52
|
+
return text
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Renders a route.
|
|
61
|
+
*
|
|
62
|
+
* @param url The requested path.
|
|
63
|
+
* @returns The HTML of the container, and the tags to add to the head.
|
|
64
|
+
*/
|
|
65
|
+
export function render(url: string): { html: string; head: string } {
|
|
66
|
+
const meta = TITLES[url] ?? TITLES['/']
|
|
67
|
+
|
|
68
|
+
const head = [
|
|
69
|
+
`<title>${escape(meta?.title ?? 'Odoro')}</title>`,
|
|
70
|
+
`<meta name="description" content="${escape(meta?.description ?? '')}">`,
|
|
71
|
+
`<meta property="og:title" content="${escape(meta?.title ?? 'Odoro')}">`,
|
|
72
|
+
`<meta property="og:description" content="${escape(meta?.description ?? '')}">`,
|
|
73
|
+
].join('\n ')
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
// `StrictMode` is present on both sides: a tree rendered without it here
|
|
77
|
+
// and with it in the browser does not always produce the same markup, and
|
|
78
|
+
// hydration then reports a difference that is not one.
|
|
79
|
+
html: renderToString(
|
|
80
|
+
<StrictMode>
|
|
81
|
+
{/* The same tree as in the browser. The provider asks the server
|
|
82
|
+
nothing here — its request lives in an effect, and effects do not
|
|
83
|
+
run while prerendering — but a page that reads the session must
|
|
84
|
+
find it, or the whole prerender fails on that one page. */}
|
|
85
|
+
<SessionProvider>
|
|
86
|
+
<App url={url} />
|
|
87
|
+
</SessionProvider>
|
|
88
|
+
</StrictMode>,
|
|
89
|
+
),
|
|
90
|
+
head,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { StrictMode } from 'react'
|
|
2
|
-
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
3
3
|
|
|
4
4
|
import { App } from '@/App'
|
|
5
|
+
import { SessionProvider } from '@/auth'
|
|
5
6
|
|
|
6
7
|
import '@odoro-cli/libs/styles.css'
|
|
7
8
|
import '@/styles.css'
|
|
8
9
|
|
|
9
10
|
const container = document.getElementById('root')
|
|
10
11
|
if (container === null) {
|
|
11
|
-
throw new Error('
|
|
12
|
+
throw new Error('Root element "#root" not found in index.html.')
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
const tree = (
|
|
15
16
|
<StrictMode>
|
|
16
|
-
<
|
|
17
|
-
|
|
17
|
+
<SessionProvider>
|
|
18
|
+
<App />
|
|
19
|
+
</SessionProvider>
|
|
20
|
+
</StrictMode>
|
|
18
21
|
)
|
|
22
|
+
|
|
23
|
+
// A container that is already filled comes from prerendering: it has to be
|
|
24
|
+
// **hydrated**, that is, taken over as it stands with the events attached.
|
|
25
|
+
// Rebuilding it would throw away the page the visitor already sees only to draw
|
|
26
|
+
// it again identically — a flicker, and the whole benefit of prerendering lost.
|
|
27
|
+
//
|
|
28
|
+
// Empty, it is an ordinary render.
|
|
29
|
+
if (container.firstElementChild === null) createRoot(container).render(tree)
|
|
30
|
+
else hydrateRoot(container, tree)
|