@orieljs/framework 0.1.0-alpha.11 → 0.1.0-alpha.13
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/dist/{chunk-MURTCTRH.js → chunk-6GMXKQ6O.js} +2 -2
- package/dist/{chunk-HTJKT2R2.js → chunk-CKH4KCYF.js} +1 -1
- package/dist/{chunk-HTJKT2R2.js.map → chunk-CKH4KCYF.js.map} +1 -1
- package/dist/{chunk-KDE5BLI3.js → chunk-OTCTQVDQ.js} +7 -4
- package/dist/chunk-OTCTQVDQ.js.map +1 -0
- package/dist/{chunk-24673JQF.js → chunk-ZRTWDJKD.js} +3 -3
- package/dist/contracts.d.ts +7 -0
- package/dist/contracts.js +1 -1
- package/dist/index.d.ts +247 -5
- package/dist/index.js +969 -190
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-CKwfWJGn.d.ts → testing-B8NOiQC9.d.ts} +6 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +3 -3
- package/docs/authentication.md +2 -0
- package/docs/console.md +1 -1
- package/docs/deployment.md +25 -5
- package/docs/live.md +20 -0
- package/docs/navigation.json +4 -0
- package/docs/routing.md +1 -1
- package/docs/scheduling.md +3 -0
- package/docs/storage.md +24 -3
- package/docs/two-factor.md +183 -0
- package/docs/vite.md +35 -7
- package/package.json +35 -33
- package/dist/chunk-KDE5BLI3.js.map +0 -1
- /package/dist/{chunk-MURTCTRH.js.map → chunk-6GMXKQ6O.js.map} +0 -0
- /package/dist/{chunk-24673JQF.js.map → chunk-ZRTWDJKD.js.map} +0 -0
package/dist/jsx-runtime.js
CHANGED
|
@@ -1357,6 +1357,11 @@ declare class LiveComponentManager implements LiveManager {
|
|
|
1357
1357
|
|
|
1358
1358
|
/** The URI the client runtime posts updates to. */
|
|
1359
1359
|
declare const UPDATE_URI = "/oriel/live/update";
|
|
1360
|
+
/**
|
|
1361
|
+
* Where the client gets a session before its first update when the page came without one (a page cached at a CDN):
|
|
1362
|
+
* through the `web` middleware, so the answer sets the session and XSRF-TOKEN cookies (Sanctum's csrf-cookie route).
|
|
1363
|
+
*/
|
|
1364
|
+
declare const SESSION_URI = "/oriel/live/session";
|
|
1360
1365
|
/**
|
|
1361
1366
|
* Binds the LiveManager and the PaginationState that WithPagination components answer, merges the `live` config,
|
|
1362
1367
|
* registers POST /oriel/live/update (named `live.update`, in the `web` group: sessions and CSRF apply), the upload and
|
|
@@ -1583,4 +1588,4 @@ interface PaginationProps {
|
|
|
1583
1588
|
*/
|
|
1584
1589
|
declare function Pagination({ paginator, class: className }: PaginationProps): Htmlable;
|
|
1585
1590
|
|
|
1586
|
-
export {
|
|
1591
|
+
export { DatabaseMigrations as $, UPLOAD_URI as A, BusFake as B, Component as C, UploadValidationError as D, EventFake as E, FileNotPreviewableError as F, WithPagination as G, componentName as H, InvalidLiveRequestError as I, live as J, persistentMiddleware as K, Live as L, MailFake as M, NotificationFake as N, On as O, PERSISTENT_MIDDLEWARE as P, QueueFake as Q, AnonymousNotifiable as R, StorageFake as S, TEMPORARY_UPLOAD_DEFAULTS as T, UPDATE_URI as U, AssertableInertia as V, WithFileUploads as W, AssertableJson as X, type BatchRecord as Y, type BootOptions as Z, type CountOrFilter as _, type ComponentResult as a, type DatabaseTestOptions as a0, DatabaseTransactions as a1, type DispatchedEvent as a2, type ExpectedErrors as a3, type ExpectedListener as a4, FakeDisk as a5, FakeFile as a6, HttpClient as a7, InteractsWithApplication as a8, InteractsWithConsole as a9, cookieHeader as aA, cookiePrefix as aB, decryptCookie as aC, encryptCookie as aD, flatten as aE, freezeSecond as aF, freezeTime as aG, guessMimeType as aH, inertiaPageOf as aI, install as aJ, isExpired as aK, matches as aL, nameOf as aM, recordingExceptionHandler as aN, recordingViewFactory as aO, responseCookies as aP, runCommand as aQ, travel as aR, travelBack as aS, travelTo as aT, usingInMemoryDatabase as aU, InteractsWithDatabase as aa, InteractsWithTime as ab, type JsonStructure as ac, MailRecord as ad, MakesHttpRequests as ae, PendingCommand as af, type PushedJob as ag, RefreshDatabase as ah, RefreshDatabaseState as ai, type RequestData as aj, type RequestOptions as ak, type ResponseContext as al, type ResponseCookie as am, type SentNotification as an, TESTING_ENVIRONMENT as ao, type TableOrModel as ap, type Target as aq, TestCase as ar, type TestHooks as as, TestResponse as at, type TestTrait as au, type TravelDate as av, Wormhole as aw, actingAsAuthFactory as ax, addresses as ay, applyEnvironment as az, Computed as b, CorruptComponentPayloadError as c, ErrorBag as d, InvalidUploadSignatureError as e, LiveComponentManager as f, LiveComponentNotFoundError as g, LiveError as h, type LiveProps as i, LiveServiceProvider as j, Locked as k, MethodNotCallableError as l, PREVIEW_URI as m, type Paginated as n, Pagination as o, type PaginationProps as p, type PaginationSource as q, PendingEvent as r, type PersistentMiddleware as s, PropertyNotUpdatableError as t, SESSION_URI as u, type TemporaryFileData as v, TemporaryFileNotFoundError as w, TemporaryUploadedFile as x, TestRequestScope as y, Testable as z };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { R as AnonymousNotifiable, V as AssertableInertia, X as AssertableJson, Y as BatchRecord, Z as BootOptions, B as BusFake, _ as CountOrFilter, $ as DatabaseMigrations, a0 as DatabaseTestOptions, a1 as DatabaseTransactions, a2 as DispatchedEvent, E as EventFake, a3 as ExpectedErrors, a4 as ExpectedListener, a5 as FakeDisk, a6 as FakeFile, a7 as HttpClient, a8 as InteractsWithApplication, a9 as InteractsWithConsole, aa as InteractsWithDatabase, ab as InteractsWithTime, ac as JsonStructure, L as Live, M as MailFake, ad as MailRecord, ae as MakesHttpRequests, N as NotificationFake, af as PendingCommand, ag as PushedJob, Q as QueueFake, ah as RefreshDatabase, ai as RefreshDatabaseState, aj as RequestData, ak as RequestOptions, al as ResponseContext, am as ResponseCookie, an as SentNotification, S as StorageFake, ao as TESTING_ENVIRONMENT, ap as TableOrModel, aq as Target, ar as TestCase, as as TestHooks, at as TestResponse, au as TestTrait, z as Testable, av as TravelDate, aw as Wormhole, ax as actingAsAuthFactory, ay as addresses, az as applyEnvironment, aA as cookieHeader, aB as cookiePrefix, aC as decryptCookie, aD as encryptCookie, aE as flatten, aF as freezeSecond, aG as freezeTime, aH as guessMimeType, aI as inertiaPageOf, aJ as install, aK as isExpired, aL as matches, aM as nameOf, aN as recordingExceptionHandler, aO as recordingViewFactory, aP as responseCookies, aQ as runCommand, aR as travel, aS as travelBack, aT as travelTo, aU as usingInMemoryDatabase } from './testing-B8NOiQC9.js';
|
|
2
2
|
import './contracts.js';
|
|
3
3
|
import './index.d-WQpfdIbG.js';
|
package/dist/testing.js
CHANGED
|
@@ -52,9 +52,9 @@ import {
|
|
|
52
52
|
travelBack,
|
|
53
53
|
travelTo,
|
|
54
54
|
usingInMemoryDatabase
|
|
55
|
-
} from "./chunk-
|
|
56
|
-
import "./chunk-
|
|
57
|
-
import "./chunk-
|
|
55
|
+
} from "./chunk-OTCTQVDQ.js";
|
|
56
|
+
import "./chunk-6GMXKQ6O.js";
|
|
57
|
+
import "./chunk-CKH4KCYF.js";
|
|
58
58
|
export {
|
|
59
59
|
AnonymousNotifiable,
|
|
60
60
|
AssertableInertia,
|
package/docs/authentication.md
CHANGED
|
@@ -187,6 +187,8 @@ page, or to the path you give.
|
|
|
187
187
|
`attempt()` changes the session's id as it signs the user in, which protects against session fixation; the kits also
|
|
188
188
|
call `regenerate()`, as above.
|
|
189
189
|
|
|
190
|
+
To ask for a code after the password, by email or from an authenticator app, see [Two-factor login](two-factor.md).
|
|
191
|
+
|
|
190
192
|
#### Specifying additional conditions
|
|
191
193
|
|
|
192
194
|
Add any condition to the credentials. A list matches any of its values, and a function receives the query:
|
package/docs/console.md
CHANGED
|
@@ -116,7 +116,7 @@ These are the commands every app has. A new app adds one of its own, `inspire`,
|
|
|
116
116
|
| `schedule:run` | Runs the scheduled tasks that are due |
|
|
117
117
|
| `schedule:work` | Runs the tasks that are due, every minute, until you stop it |
|
|
118
118
|
| `storage:link` | Links `public/storage` to `storage/app/public` |
|
|
119
|
-
| `vercel:build` | Writes your app's build for Vercel into `.vercel/output` ([Deploying to Vercel](deployment.md#deploying-to-vercel)) |
|
|
119
|
+
| `vercel:build` | Writes your app's build for Vercel into `.vercel/output` ([Deploying to Vercel](deployment.md#deploying-to-vercel)); `--cron=` sets when Vercel calls the scheduler ([Scheduled tasks](deployment.md#scheduled-tasks)) |
|
|
120
120
|
|
|
121
121
|
`node oriel help <command>` lists a command's options. A few that are worth knowing up front:
|
|
122
122
|
|
package/docs/deployment.md
CHANGED
|
@@ -308,10 +308,11 @@ and each instance has its own, so:
|
|
|
308
308
|
headers itself, so the function trusts them when `trusted_proxies`, in `config/http.ts`, is empty: your URLs keep
|
|
309
309
|
the browser's `https`, and `request().ip()` is the visitor's (see
|
|
310
310
|
[Configuring trusted proxies](requests.md#configuring-trusted-proxies)).
|
|
311
|
-
- **Files your app stores** go on
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
311
|
+
- **Files your app stores** go on the `blob` disk, a Vercel Blob store, or an `s3` disk (see
|
|
312
|
+
[The Vercel Blob driver](storage.md#the-vercel-blob-driver)): the `local` and `public` disks write into `storage`,
|
|
313
|
+
which the function can't.
|
|
314
|
+
- **No queue worker runs.** With `QUEUE_CONNECTION=sync`, a job runs during the request that dispatches it. Your
|
|
315
|
+
scheduled tasks run through Vercel Cron (see [Scheduled tasks](#scheduled-tasks)).
|
|
315
316
|
- **Inertia pages render in the browser**, as they do when no SSR server answers: the SSR server doesn't run on
|
|
316
317
|
Vercel. `INERTIA_SSR_ENABLED=false` skips asking it.
|
|
317
318
|
|
|
@@ -331,7 +332,26 @@ Route.get('/pricing', () => view('pricing'))
|
|
|
331
332
|
|
|
332
333
|
`cache.headers` leaves a response that sets a cookie alone, so that the CDN never hands one visitor's cookies to the
|
|
333
334
|
next. The `web` group's `StartSession` and `VerifyCsrfToken` set cookies on every page: take them off the pages you
|
|
334
|
-
cache, as above. Those pages have no session, so they can't show who is signed in.
|
|
335
|
+
cache, as above. Those pages have no session, so they can't show who is signed in. Their Live components still work:
|
|
336
|
+
they get a session with their first update (see [Pages cached without a session](live.md#pages-cached-without-a-session)).
|
|
337
|
+
|
|
338
|
+
### Scheduled tasks
|
|
339
|
+
|
|
340
|
+
No `schedule:work` runs on Vercel, so Vercel Cron calls your app's scheduler instead. When your app schedules any task,
|
|
341
|
+
`vercel:build` adds a cron that calls `/_oriel/schedule`, daily at 03:00 UTC. Add a `CRON_SECRET` to the project's
|
|
342
|
+
environment variables (a long random string): Vercel sends it with every call, and without it the route answers 404,
|
|
343
|
+
so nobody else can run your tasks.
|
|
344
|
+
|
|
345
|
+
Each call runs every task that fell due since the previous call, once, however often it fell due in between: a task
|
|
346
|
+
scheduled every five minutes runs once a day, and a task at 04:00 runs at the next morning's call. `vercel:build` names
|
|
347
|
+
the tasks due more often than the cron. Vercel's Hobby plan runs a cron at most once a day, sometime within the hour
|
|
348
|
+
you chose; a paid plan can call more often:
|
|
349
|
+
|
|
350
|
+
```sh
|
|
351
|
+
node oriel vercel:build --cron="*/10 * * * *"
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`--cron=false` writes no cron.
|
|
335
355
|
|
|
336
356
|
## Debug mode
|
|
337
357
|
|
package/docs/live.md
CHANGED
|
@@ -689,6 +689,26 @@ export default class PostCount extends Component {
|
|
|
689
689
|
`lazy={false}` turns `static lazy` off. Without `placeholder()`, the placeholder is the `live.lazy_placeholder` view,
|
|
690
690
|
or an empty `<div>`. Give the placeholder the same root element as `render()`.
|
|
691
691
|
|
|
692
|
+
## Pages cached without a session
|
|
693
|
+
|
|
694
|
+
A page that is the same for every visitor can be cached by a CDN (see
|
|
695
|
+
[Caching pages on the CDN](deployment.md#caching-pages-on-the-cdn)), and its Live components still work. Such a page
|
|
696
|
+
leaves `StartSession` and `VerifyCsrfToken` out, so it comes with no session and no `XSRF-TOKEN` cookie. Before its
|
|
697
|
+
first update, the client asks `/oriel/live/session` for both, once for the whole page, and then sends the update with
|
|
698
|
+
the token:
|
|
699
|
+
|
|
700
|
+
```ts title="routes/web.ts"
|
|
701
|
+
import { Route, StartSession, VerifyCsrfToken, view } from '@orieljs/framework'
|
|
702
|
+
|
|
703
|
+
Route.get('/docs/{page}', (page: string) => view('docs', { page }))
|
|
704
|
+
.middleware('cache.headers:public;s_maxage=86400;stale_while_revalidate=60')
|
|
705
|
+
.withoutMiddleware(StartSession, VerifyCsrfToken)
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
The cached HTML is the same for everyone, so render nothing there that belongs to one visitor: no name, no flashed
|
|
709
|
+
message, no CSRF field. A lazy component suits such a page: its placeholder is cached, and what it shows for this
|
|
710
|
+
visitor comes with its own load. `Live.start({ sessionUri })` moves the session route if you moved the update route.
|
|
711
|
+
|
|
692
712
|
## Pagination
|
|
693
713
|
|
|
694
714
|
`WithPagination` gives a component a page, which `paginate()` uses:
|
package/docs/navigation.json
CHANGED
package/docs/routing.md
CHANGED
|
@@ -431,7 +431,7 @@ yourself. Until the first run, any name is accepted.
|
|
|
431
431
|
Your [Inertia](inertia.md#links-and-the-route-helper) pages build URLs with the same `route()`, from your adapter. It
|
|
432
432
|
takes the same names and parameters, and returns a path. The browser's route table leaves out the routes that
|
|
433
433
|
`types.except_routes` in `config/inertia.ts` names, with `*` as a wildcard. Unless you set it, it leaves out `live.*`,
|
|
434
|
-
Live's own routes.
|
|
434
|
+
Live's own routes, and `vercel.*`, the route Vercel Cron calls.
|
|
435
435
|
|
|
436
436
|
```tsx tab="React" title="resources/js/components/PostLink.tsx"
|
|
437
437
|
import { Link, route } from '@orieljs/inertia-react'
|
package/docs/scheduling.md
CHANGED
|
@@ -219,6 +219,9 @@ Add one cron entry to your server, which runs `schedule:run` every minute:
|
|
|
219
219
|
`schedule:run` runs each due task once, prints its name, and goes on to the next one when a task fails, after
|
|
220
220
|
printing the error. With nothing due, it prints "No scheduled commands are ready to run.".
|
|
221
221
|
|
|
222
|
+
On Vercel, which runs no cron of yours, Vercel Cron calls the scheduler instead (see
|
|
223
|
+
[Scheduled tasks on Vercel](deployment.md#scheduled-tasks)).
|
|
224
|
+
|
|
222
225
|
### Sub-minute tasks
|
|
223
226
|
|
|
224
227
|
A task that runs every few seconds, such as `everyTenSeconds()`, is due every minute. Its `schedule:run` runs it
|
package/docs/storage.md
CHANGED
|
@@ -5,9 +5,9 @@ service), through one API.
|
|
|
5
5
|
|
|
6
6
|
## Configuration
|
|
7
7
|
|
|
8
|
-
Your disks are in `config/filesystems.ts`. Each disk names its driver, `local` or `
|
|
9
|
-
names the disk `Storage` uses when you don't name one (`FILESYSTEM_DISK`, else `local`). A new app
|
|
10
|
-
`local`, `public` and `
|
|
8
|
+
Your disks are in `config/filesystems.ts`. Each disk names its driver, `local`, `s3` or `vercel-blob`, and its
|
|
9
|
+
settings; `default` names the disk `Storage` uses when you don't name one (`FILESYSTEM_DISK`, else `local`). A new app
|
|
10
|
+
has four disks: `local`, `public`, `s3` and `blob`.
|
|
11
11
|
|
|
12
12
|
### The local driver
|
|
13
13
|
|
|
@@ -40,6 +40,27 @@ The `s3` disk reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_R
|
|
|
40
40
|
`AWS_ENDPOINT` and `AWS_USE_PATH_STYLE_ENDPOINT` from your `.env`. For an S3-compatible service, such as MinIO or
|
|
41
41
|
Cloudflare R2, set `endpoint`, and `usePathStyleEndpoint` if it wants the bucket in the path.
|
|
42
42
|
|
|
43
|
+
### The Vercel Blob driver
|
|
44
|
+
|
|
45
|
+
On Vercel, files your app stores need a disk that outlives the function: the `blob` disk keeps them in a
|
|
46
|
+
[Vercel Blob](https://vercel.com/docs/vercel-blob) store. Create a store in your Vercel project's Storage tab and
|
|
47
|
+
connect it to the project: Vercel then sets `BLOB_READ_WRITE_TOKEN`, which the disk reads.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { Storage } from '@orieljs/framework'
|
|
51
|
+
declare const cover: Uint8Array
|
|
52
|
+
// ---cut---
|
|
53
|
+
await Storage.disk('blob').put('covers/launch.png', cover)
|
|
54
|
+
|
|
55
|
+
const address = Storage.disk('blob').url('covers/launch.png')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`url()` gives the file's public address in the store, worked out from the token; set `BLOB_URL` to use another one
|
|
59
|
+
(a custom domain). `root` (`BLOB_ROOT`) keeps every path under a folder, so preview deployments can write under
|
|
60
|
+
`preview` and never touch production's files. Blob stores here are public: a `private` disk or file is refused, and
|
|
61
|
+
`temporaryUrl()` returns the public address. A Vercel function accepts request bodies up to 4.5 MB, which limits what
|
|
62
|
+
a form can upload through it.
|
|
63
|
+
|
|
43
64
|
## Obtaining disk instances
|
|
44
65
|
|
|
45
66
|
`Storage` works on the default disk; `Storage.disk(name)` on another:
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Two-factor login
|
|
2
|
+
|
|
3
|
+
Ask for a second factor after the password: a 6-digit code sent by email, or one from an authenticator app, with
|
|
4
|
+
one-time recovery codes for when the phone or the mailbox is gone.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
With two-factor login on, a correct password no longer signs the user in. It starts a pending login, kept in the
|
|
9
|
+
session, and your app shows a page that asks for the code. Only the right code (or a recovery code) completes the
|
|
10
|
+
login. This is Laravel Fortify's two-factor login, with an emailed code as well.
|
|
11
|
+
|
|
12
|
+
Each user has a method:
|
|
13
|
+
|
|
14
|
+
- **Email**, the default: a code is mailed to the user when their password is accepted. It needs no setup, so every
|
|
15
|
+
user has it from the start.
|
|
16
|
+
- **Authenticator app**: the user scans a QR code with an app such as Google Authenticator or 1Password and confirms
|
|
17
|
+
it with one code; from then on, the app's code is asked for.
|
|
18
|
+
|
|
19
|
+
The starter kits don't have two-factor pages yet: you add the routes and pages your app needs, as below.
|
|
20
|
+
|
|
21
|
+
## Turning it on
|
|
22
|
+
|
|
23
|
+
Add the four columns two-factor login keeps on `users`, then turn it on in `config/auth.ts`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
node oriel two-factor:install
|
|
27
|
+
node oriel migrate
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts title="config/auth.ts"
|
|
31
|
+
import { User } from '../app/Models/User'
|
|
32
|
+
// ---cut---
|
|
33
|
+
export default {
|
|
34
|
+
defaults: { guard: 'web', passwords: 'users' },
|
|
35
|
+
guards: { web: { driver: 'session', provider: 'users' } },
|
|
36
|
+
providers: { users: { driver: 'eloquent', model: User } },
|
|
37
|
+
two_factor: { enabled: true },
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`two_factor` takes these settings, each with its default:
|
|
42
|
+
|
|
43
|
+
| Setting | Default | |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `enabled` | `false` | Ask for a second factor after the password |
|
|
46
|
+
| `methods` | `['email', 'app']` | The methods users may use (see below) |
|
|
47
|
+
| `default` | `'email'` | The method a user has until they set up another |
|
|
48
|
+
| `code_ttl` | `600` | Seconds an emailed code works |
|
|
49
|
+
| `pending_ttl` | `600` | Seconds a pending login waits for its code |
|
|
50
|
+
| `max_attempts` | `5` | Wrong codes a pending login allows; then the user signs in again |
|
|
51
|
+
| `resend_after` | `30` | Seconds before another email code may be sent |
|
|
52
|
+
| `window` | `1` | Authenticator steps (30 seconds each) of clock drift accepted either way |
|
|
53
|
+
| `recovery_codes` | `8` | How many recovery codes a user gets |
|
|
54
|
+
| `issuer` | `app.name` | The name authenticator apps show |
|
|
55
|
+
|
|
56
|
+
A user is asked only by a method open to them: email, or an app they have set up. With `methods: ['app']`, a user who
|
|
57
|
+
hasn't set up an app has no method open, so their password alone signs them in, as in Fortify, where two-factor is each
|
|
58
|
+
user's choice. Keep `'email'` in `methods` to ask every user for a second factor.
|
|
59
|
+
|
|
60
|
+
The secret and the recovery codes are encrypted with your app key; emailed codes are kept hashed, in the cache, so the
|
|
61
|
+
cache must be shared by your servers (`database` or `redis`, as on Vercel).
|
|
62
|
+
|
|
63
|
+
## Signing in
|
|
64
|
+
|
|
65
|
+
Your login controller calls `TwoFactor.attempt()` instead of `Auth.attempt()`. It has three outcomes: `'authenticated'`
|
|
66
|
+
(the user is signed in: two-factor is off, or not asked of this user), `'challenge'` (the password was right and the
|
|
67
|
+
code is asked for), and `false` (wrong credentials):
|
|
68
|
+
|
|
69
|
+
```ts title="app/Http/Controllers/LoginController.ts"
|
|
70
|
+
import { back, redirect, request, TwoFactor } from '@orieljs/framework'
|
|
71
|
+
import { Controller } from './Controller'
|
|
72
|
+
|
|
73
|
+
export class LoginController extends Controller {
|
|
74
|
+
async authenticate() {
|
|
75
|
+
const credentials = await request().validate({ email: 'required|email', password: 'required' })
|
|
76
|
+
|
|
77
|
+
const result = await TwoFactor.attempt(credentials, request().boolean('remember'))
|
|
78
|
+
if (result === 'challenge') return redirect('/two-factor-challenge')
|
|
79
|
+
if (result === 'authenticated') {
|
|
80
|
+
await request().session().regenerate()
|
|
81
|
+
return redirect().intended('dashboard')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return back().withErrors({ email: 'The provided credentials do not match our records.' }).withInput(request().only('email'))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## The challenge page
|
|
90
|
+
|
|
91
|
+
The challenge page's routes take the `two-factor.challenge` middleware: only a login waiting for its code reaches them,
|
|
92
|
+
and anyone else is sent to log in.
|
|
93
|
+
|
|
94
|
+
```ts title="routes/web.ts"
|
|
95
|
+
import { back, redirect, request, Route, TwoFactor, view } from '@orieljs/framework'
|
|
96
|
+
|
|
97
|
+
Route.middleware('two-factor.challenge').group(() => {
|
|
98
|
+
Route.get('/two-factor-challenge', async () => view('auth.two-factor', { challenge: await TwoFactor.challenge() }))
|
|
99
|
+
|
|
100
|
+
Route.post('/two-factor-challenge', async () => {
|
|
101
|
+
if (await TwoFactor.verify(String(request().input('code', '')))) {
|
|
102
|
+
await request().session().regenerate()
|
|
103
|
+
return redirect().intended('dashboard')
|
|
104
|
+
}
|
|
105
|
+
return back().withErrors({ code: 'That code is not right.' })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
Route.post('/two-factor-challenge/email', async () => {
|
|
109
|
+
await TwoFactor.useMethod('email')
|
|
110
|
+
return back()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
Route.post('/two-factor-challenge/resend', async () => {
|
|
114
|
+
return back().with('status', (await TwoFactor.resend()) ? 'We sent you a new code.' : 'Wait a moment before asking again.')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`TwoFactor.challenge()` gives the page what to say: the method asked for, the other methods the user has (`methods`),
|
|
120
|
+
and where an emailed code went, masked (`a***@example.com`). `verify()` takes the 6-digit code for that method, or a
|
|
121
|
+
recovery code, read leniently: spaces, dashes and capitals don't matter. A code works once; five wrong codes end the
|
|
122
|
+
pending login, and the user signs in again.
|
|
123
|
+
|
|
124
|
+
The emailed code is the `TwoFactorCode` notification, sent with your mailer, so on Vercel your app needs a mail service
|
|
125
|
+
over SMTP. `TwoFactorCode.toMailUsing()` lets you write the mail yourself:
|
|
126
|
+
|
|
127
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
128
|
+
import { MailMessage, TwoFactorCode } from '@orieljs/framework'
|
|
129
|
+
// ---cut---
|
|
130
|
+
TwoFactorCode.toMailUsing((_user, code, minutes) =>
|
|
131
|
+
new MailMessage().subject('Your sign-in code').line(`Your code is ${code}.`).line(`It works for ${minutes} minutes.`),
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Setting up an authenticator app
|
|
136
|
+
|
|
137
|
+
On an account page, a signed-in user sets up their app in two steps: show the QR code, then confirm with a code from
|
|
138
|
+
the app. Until it is confirmed, the app doesn't count, and the user is still asked by email:
|
|
139
|
+
|
|
140
|
+
```ts title="routes/web.ts"
|
|
141
|
+
import { back, request, Route, TwoFactor, view } from '@orieljs/framework'
|
|
142
|
+
import type { User } from '../app/Models/User'
|
|
143
|
+
// ---cut---
|
|
144
|
+
Route.middleware('auth').group(() => {
|
|
145
|
+
Route.post('/account/two-factor/app', async () => {
|
|
146
|
+
const { svg, secret } = await TwoFactor.enableApp((await request().user<User>())!)
|
|
147
|
+
return view('account.two-factor-app', { svg, secret })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
Route.post('/account/two-factor/app/confirm', async () => {
|
|
151
|
+
const confirmed = await TwoFactor.confirmApp((await request().user<User>())!, String(request().input('code', '')))
|
|
152
|
+
return confirmed ? back().with('status', 'Your app is set up.') : back().withErrors({ code: 'That code is not right.' })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`enableApp()` returns the secret (for typing in by hand), its `otpauth://` URL and the QR code as an SVG image.
|
|
158
|
+
`confirmApp()` makes the app the user's method and gives them recovery codes. `disableApp()` removes it, and
|
|
159
|
+
`setMethod()` lets a user who has both choose which is asked for.
|
|
160
|
+
|
|
161
|
+
## Recovery codes
|
|
162
|
+
|
|
163
|
+
`TwoFactor.recoveryCodes(user)` returns the user's codes, making them on first ask; show them once and ask the user to
|
|
164
|
+
keep them somewhere safe. Each works once, in place of a code. `regenerateRecoveryCodes(user)` makes new ones, and the
|
|
165
|
+
old ones stop working.
|
|
166
|
+
|
|
167
|
+
## Choosing who is asked
|
|
168
|
+
|
|
169
|
+
By default every user is asked for a second factor once two-factor is on. `requireWhen()` narrows it, for instance to
|
|
170
|
+
admins:
|
|
171
|
+
|
|
172
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
173
|
+
import { TwoFactor } from '@orieljs/framework'
|
|
174
|
+
import type { User } from '../Models/User'
|
|
175
|
+
// ---cut---
|
|
176
|
+
TwoFactor.requireWhen((user) => (user as User).getAttribute('is_admin') === true)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Events
|
|
180
|
+
|
|
181
|
+
`TwoFactorChallenged` (the code was asked for), `TwoFactorFailed` (a wrong code), `TwoFactorAuthenticated` (the user is
|
|
182
|
+
signed in), `RecoveryCodeUsed`, `TwoFactorAppEnabled` and `TwoFactorAppDisabled`, each with the user. The password step
|
|
183
|
+
fires `Attempting` and `Failed`, as `Auth.attempt()` does, and the login fires `Login`.
|
package/docs/vite.md
CHANGED
|
@@ -197,21 +197,48 @@ In your scripts, the names in class positions: `class` and `className` props, pr
|
|
|
197
197
|
`classList`, Angular's `[class.name]`. A string anywhere else keeps its words, so `type: 'hidden'` is still a hidden
|
|
198
198
|
input, and a name your CSS doesn't define keeps its name.
|
|
199
199
|
|
|
200
|
+
The build follows a class value back to its strings when they're in the same file: a variable, a list you join
|
|
201
|
+
yourself, a value standing alone in a template, a function's result, a table of classes, and a memo or a ref:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
declare const wide: boolean, active: boolean, size: 'sm' | 'lg', className: string
|
|
205
|
+
declare function useMemo<T>(make: () => T, deps: unknown[]): T
|
|
206
|
+
// ---cut---
|
|
207
|
+
const sizes = { sm: 'text-sm', lg: 'text-lg' }
|
|
208
|
+
const classes = [wide ? 'window-wide' : 'window', className].filter(Boolean).join(' ')
|
|
209
|
+
const tone = useMemo(() => (active ? 'text-accent' : 'text-body'), [active])
|
|
210
|
+
|
|
211
|
+
const views = [
|
|
212
|
+
<div className={classes} />,
|
|
213
|
+
<p className={`${sizes[size]} ${tone}`} />,
|
|
214
|
+
<NavLink className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')} />,
|
|
215
|
+
]
|
|
216
|
+
// ---cut-after---
|
|
217
|
+
declare function NavLink(props: { className: (state: { isActive: boolean }) => string }): null
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Vue's `computed()` and `ref()`, Svelte's `$derived` and Solid's `createMemo()` count as memos, and a variable named
|
|
221
|
+
like `buttonClass` or `classes` holds classes wherever it's used.
|
|
222
|
+
|
|
200
223
|
Your dependencies keep their class names, and so do the classes their style sheets define, so a library's markup
|
|
201
224
|
always matches its CSS, whether you import it or the library injects it. CSS modules' names are hashed already, and
|
|
202
225
|
stay as Vite wrote them. `dark` is never renamed: the kits' appearance script sets it before the page draws.
|
|
203
226
|
|
|
204
|
-
###
|
|
227
|
+
### Classes the build can't rename
|
|
205
228
|
|
|
206
229
|
A class built from pieces at run time can't be renamed: `` `btn-${variant}` `` asks for `btn-primary`, which the
|
|
207
|
-
built CSS calls something else.
|
|
230
|
+
built CSS calls something else. Nor can a class made by code the build can't follow, such as a function from another
|
|
231
|
+
file given strings. And a string that reads like your class names but sits outside any class value may be one of them,
|
|
232
|
+
used where the build doesn't look. The build lists each one it finds, with its file and line:
|
|
208
233
|
|
|
209
234
|
```text
|
|
210
|
-
|
|
211
|
-
resources/js/components/Button.tsx:12: `btn-${variant}` builds a class name from pieces
|
|
235
|
+
Scrambling can't rename these class names, so they'd lose their styles. Write each whole name where a class goes …
|
|
236
|
+
resources/js/components/Button.tsx:12: `btn-${variant}` builds a class name from pieces
|
|
237
|
+
resources/js/components/Badge.tsx:8: tone("badge", kind) makes classes the build can't follow
|
|
238
|
+
resources/js/styles.ts:3: 'card shadow-sm' reads like class names, outside any class value
|
|
212
239
|
```
|
|
213
240
|
|
|
214
|
-
Write the whole name
|
|
241
|
+
Write the whole name where a class goes instead:
|
|
215
242
|
|
|
216
243
|
```ts
|
|
217
244
|
declare const button: { className: string }
|
|
@@ -233,8 +260,9 @@ import { oriel } from '@orieljs/vite'
|
|
|
233
260
|
oriel({ input: ['resources/css/app.css', 'resources/js/app.ts'], obfuscate: { keep: ['btn-primary', 'btn-danger'] } })
|
|
234
261
|
```
|
|
235
262
|
|
|
236
|
-
`keep` is also for a class something outside the build looks for, such as a script on another site
|
|
237
|
-
|
|
263
|
+
`keep` is also for a class something outside the build looks for, such as a script on another site. Browser tests
|
|
264
|
+
that run against a production build see the short names, so find elements by role, label or text, as Playwright
|
|
265
|
+
recommends, rather than by class.
|
|
238
266
|
|
|
239
267
|
### Turning it off
|
|
240
268
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orieljs/framework",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
4
4
|
"description": "Oriel: a Laravel-style, full-stack TypeScript framework for Node.js and Bun.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"@aws-sdk/client-s3": "3.1139.0",
|
|
48
48
|
"@aws-sdk/s3-request-presigner": "3.1139.0",
|
|
49
49
|
"@node-rs/argon2": "2.2.1",
|
|
50
|
+
"@vercel/blob": "2.8.2",
|
|
50
51
|
"bcryptjs": "3.0.3",
|
|
51
52
|
"better-sqlite3": "13.0.3",
|
|
52
53
|
"cron-parser": "5.10.1",
|
|
@@ -55,40 +56,41 @@
|
|
|
55
56
|
"kysely": "0.29.6",
|
|
56
57
|
"nodemailer": "10.0.10",
|
|
57
58
|
"pino": "10.3.1",
|
|
58
|
-
"pluralize": "8.0.0"
|
|
59
|
+
"pluralize": "8.0.0",
|
|
60
|
+
"uqr": "0.1.3"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
61
|
-
"@orieljs/auth": "0.1.0-alpha.
|
|
62
|
-
"@orieljs/
|
|
63
|
-
"@orieljs/
|
|
64
|
-
"@orieljs/config": "0.1.0-alpha.
|
|
65
|
-
"@orieljs/
|
|
66
|
-
"@orieljs/
|
|
67
|
-
"@orieljs/
|
|
68
|
-
"@orieljs/
|
|
69
|
-
"@orieljs/
|
|
70
|
-
"@orieljs/
|
|
71
|
-
"@orieljs/
|
|
72
|
-
"@orieljs/
|
|
73
|
-
"@orieljs/
|
|
74
|
-
"@orieljs/
|
|
75
|
-
"@orieljs/
|
|
76
|
-
"@orieljs/
|
|
77
|
-
"@orieljs/
|
|
78
|
-
"@orieljs/
|
|
79
|
-
"@orieljs/
|
|
80
|
-
"@orieljs/notifications": "0.1.0-alpha.
|
|
81
|
-
"@orieljs/
|
|
82
|
-
"@orieljs/
|
|
83
|
-
"@orieljs/
|
|
84
|
-
"@orieljs/
|
|
85
|
-
"@orieljs/
|
|
86
|
-
"@orieljs/
|
|
87
|
-
"@orieljs/validation": "0.1.0-alpha.
|
|
88
|
-
"@orieljs/
|
|
89
|
-
"@orieljs/
|
|
90
|
-
"@orieljs/
|
|
91
|
-
"@orieljs/
|
|
63
|
+
"@orieljs/auth": "0.1.0-alpha.13",
|
|
64
|
+
"@orieljs/bus": "0.1.0-alpha.13",
|
|
65
|
+
"@orieljs/cache": "0.1.0-alpha.13",
|
|
66
|
+
"@orieljs/config": "0.1.0-alpha.13",
|
|
67
|
+
"@orieljs/collections": "0.1.0-alpha.13",
|
|
68
|
+
"@orieljs/console": "0.1.0-alpha.13",
|
|
69
|
+
"@orieljs/container": "0.1.0-alpha.13",
|
|
70
|
+
"@orieljs/database": "0.1.0-alpha.13",
|
|
71
|
+
"@orieljs/cookie": "0.1.0-alpha.13",
|
|
72
|
+
"@orieljs/encryption": "0.1.0-alpha.13",
|
|
73
|
+
"@orieljs/events": "0.1.0-alpha.13",
|
|
74
|
+
"@orieljs/filesystem": "0.1.0-alpha.13",
|
|
75
|
+
"@orieljs/hashing": "0.1.0-alpha.13",
|
|
76
|
+
"@orieljs/http": "0.1.0-alpha.13",
|
|
77
|
+
"@orieljs/inertia": "0.1.0-alpha.13",
|
|
78
|
+
"@orieljs/live": "0.1.0-alpha.13",
|
|
79
|
+
"@orieljs/log": "0.1.0-alpha.13",
|
|
80
|
+
"@orieljs/mail": "0.1.0-alpha.13",
|
|
81
|
+
"@orieljs/obfuscate": "0.1.0-alpha.13",
|
|
82
|
+
"@orieljs/notifications": "0.1.0-alpha.13",
|
|
83
|
+
"@orieljs/pipeline": "0.1.0-alpha.13",
|
|
84
|
+
"@orieljs/queue": "0.1.0-alpha.13",
|
|
85
|
+
"@orieljs/routing": "0.1.0-alpha.13",
|
|
86
|
+
"@orieljs/session": "0.1.0-alpha.13",
|
|
87
|
+
"@orieljs/support": "0.1.0-alpha.13",
|
|
88
|
+
"@orieljs/testing": "0.1.0-alpha.13",
|
|
89
|
+
"@orieljs/validation": "0.1.0-alpha.13",
|
|
90
|
+
"@orieljs/vercel": "0.1.0-alpha.13",
|
|
91
|
+
"@orieljs/view": "0.1.0-alpha.13",
|
|
92
|
+
"@orieljs/contracts": "0.1.0-alpha.13",
|
|
93
|
+
"@orieljs/foundation": "0.1.0-alpha.13"
|
|
92
94
|
},
|
|
93
95
|
"peerDependencies": {
|
|
94
96
|
"pg": "^8.23.0",
|