@orieljs/framework 0.1.0-alpha.6 → 0.1.0-alpha.8
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/NOTICE +4 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/{chunk-ZOEPKBVF.js → chunk-SPQUX3C7.js} +1347 -428
- package/dist/chunk-SPQUX3C7.js.map +1 -0
- package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +515 -17
- package/dist/contracts.js +1 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +964 -152
- package/dist/index.js +4279 -866
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
- package/dist/jsx-runtime.d.ts +2 -2
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +651 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +34 -32
- package/dist/chunk-I63ZFMX6.js.map +0 -1
- package/dist/chunk-MFTZQMZM.js.map +0 -1
- package/dist/chunk-POEP4B2M.js.map +0 -1
- package/dist/chunk-ZOEPKBVF.js.map +0 -1
- package/dist/index.d-BJyJx6U1.d.ts +0 -319
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# Starter kits
|
|
2
|
+
|
|
3
|
+
Every new Oriel app starts from a starter kit: sign-in pages, settings and a dashboard in React, Vue, Svelte, Solid,
|
|
4
|
+
Angular or Live, or a JSON API with tokens.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
A starter kit gives your new app the routes, controllers, pages and tests it needs to register and sign in its
|
|
9
|
+
users. The code is copied into your app when you create it, so it is yours: change any of it, or delete what you don't
|
|
10
|
+
need. There is nothing to keep in sync with the kit afterwards.
|
|
11
|
+
|
|
12
|
+
The kits sign users in with Oriel's own authentication, without a separate package such as Laravel's Fortify: the
|
|
13
|
+
controllers, or the Live components, are in your app.
|
|
14
|
+
|
|
15
|
+
## Creating an application using a starter kit
|
|
16
|
+
|
|
17
|
+
The installer asks which kit you want, or takes it as an option (see
|
|
18
|
+
[Installation](installation.md#choosing-a-starter-kit)):
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm create @orieljs@latest blog -- --inertia=react
|
|
22
|
+
npm create @orieljs@latest blog -- --live
|
|
23
|
+
npm create @orieljs@latest blog -- --api
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It installs the app, sets its key, migrates the database and builds the assets, so the app runs straight away. While
|
|
27
|
+
you work on it, run Vite and the server in two terminals:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm run dev
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
node oriel serve --watch
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then open [http://localhost:8000](http://localhost:8000). `serve --watch` restarts the server when your server code
|
|
38
|
+
changes, and Vite updates your pages' scripts and styles in the browser. After a restart, reload the page once more:
|
|
39
|
+
Vite doesn't reload it once the new server is up (see
|
|
40
|
+
[Running your application](installation.md#running-your-application)).
|
|
41
|
+
|
|
42
|
+
A new app sends no real mail: `MAIL_MAILER=log` in `.env` writes each mail to `storage/logs/oriel.log`, so the email
|
|
43
|
+
verification link and the password reset link are in there. Set the `MAIL_*` variables to send real mail.
|
|
44
|
+
|
|
45
|
+
## Available starter kits
|
|
46
|
+
|
|
47
|
+
### React
|
|
48
|
+
|
|
49
|
+
The React kit's pages are React 19 components, served through Inertia by `@orieljs/inertia-react` and rendered on the
|
|
50
|
+
server. They are written in TypeScript and styled with Tailwind CSS 4 and the Oriel theme.
|
|
51
|
+
|
|
52
|
+
### Vue
|
|
53
|
+
|
|
54
|
+
The Vue kit's pages are Vue 3 single-file components with `<script setup lang="ts">`, served through Inertia by
|
|
55
|
+
`@orieljs/inertia-vue` and rendered on the server.
|
|
56
|
+
|
|
57
|
+
### Svelte
|
|
58
|
+
|
|
59
|
+
The Svelte kit's pages are Svelte 5 components, written with runes, served through Inertia by
|
|
60
|
+
`@orieljs/inertia-svelte` and rendered on the server.
|
|
61
|
+
|
|
62
|
+
### Solid
|
|
63
|
+
|
|
64
|
+
The Solid kit's pages are Solid components, served through Inertia by `@orieljs/inertia-solid` and rendered on the
|
|
65
|
+
server.
|
|
66
|
+
|
|
67
|
+
### Angular
|
|
68
|
+
|
|
69
|
+
The Angular kit's pages are standalone Angular 21 components, served through Inertia by `@orieljs/inertia-angular`. A
|
|
70
|
+
page receives its props as signal inputs. The Angular kit renders its pages in the browser only: it has no server-side
|
|
71
|
+
rendering.
|
|
72
|
+
|
|
73
|
+
### Live
|
|
74
|
+
|
|
75
|
+
The Live kit's pages are Live components: TSX classes rendered on the server, whose public fields are their state and
|
|
76
|
+
whose public methods are the actions the browser can call. `@orieljs/live-client` keeps them interactive in the
|
|
77
|
+
browser. Live is Oriel's Livewire.
|
|
78
|
+
|
|
79
|
+
### API
|
|
80
|
+
|
|
81
|
+
The API kit has no pages: JSON in and out, personal access tokens, and no sessions, cookies or views. Its endpoints are
|
|
82
|
+
listed in [The API kit](#the-api-kit).
|
|
83
|
+
|
|
84
|
+
## Starter kit customization
|
|
85
|
+
|
|
86
|
+
### The Inertia kits
|
|
87
|
+
|
|
88
|
+
The five Inertia kits share their server code: the same controllers, routes and form requests. Only `resources/js`
|
|
89
|
+
differs:
|
|
90
|
+
|
|
91
|
+
| Folder | What is in it |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `resources/js/pages` | The pages: `Auth/Login`, `Auth/Register`, `Auth/ForgotPassword`, `Auth/ResetPassword`, `Auth/VerifyEmail`, `Dashboard`, `Settings/Profile`, `Settings/Password`, `Settings/Appearance` |
|
|
94
|
+
| `resources/js/layouts` | `AppLayout` (the sidebar and the top bar), `AuthLayout` (the guest pages) and `SettingsLayout` (the settings tabs) |
|
|
95
|
+
| `resources/js/components` | `NavLink`, `SidebarNav`, `UserMenu`, `AppearanceSwitch`, `Icon`, `Brand`, `CopyButton`, `InputError` and `DeleteUser`, with a few more per framework, such as React's `Field` and `Status` |
|
|
96
|
+
| `resources/js/app.*`, `resources/js/ssr.*` | The browser entry, and the server entry of the kits with server-side rendering |
|
|
97
|
+
| `resources/js/appearance.ts`, `checklist.ts`, `menus.ts`, `names.ts` | The browser code every kit shares |
|
|
98
|
+
|
|
99
|
+
Each framework keeps the Light / Dark / System setting in its own idiom: React's `hooks/useAppearance.ts`, Vue's
|
|
100
|
+
`composables/useAppearance.ts`, Svelte's `lib/appearance.svelte.ts`, Solid's `lib/appearance.ts` and Angular's
|
|
101
|
+
`services/appearance.service.ts`. The Angular kit also has `PageTitleService`, which sets the document's title and the
|
|
102
|
+
breadcrumb.
|
|
103
|
+
|
|
104
|
+
`resources/views/app.tsx` is the root view every page is rendered into. `HandleInertiaRequests`, in
|
|
105
|
+
`app/Http/Middleware`, shares the validation errors and three props with every page: `name`, the app's name;
|
|
106
|
+
`auth.user`, the signed-in user's `id`, `name`, `email` and `email_verified_at`, or `null`; and `appearance`.
|
|
107
|
+
|
|
108
|
+
A page's props are typed. `node oriel inertia:types` writes `resources/js/oriel/pages.d.ts` from your controllers,
|
|
109
|
+
and `node oriel route:types` writes the route table behind `route('dashboard')` in your pages. The kits' Vite plugin
|
|
110
|
+
runs both when Vite starts, and again when your server code changes while `npm run dev` runs, and `npm run typecheck`
|
|
111
|
+
runs them before TypeScript. [Inertia](inertia.md#pages) explains them.
|
|
112
|
+
|
|
113
|
+
### The Live kit
|
|
114
|
+
|
|
115
|
+
The Live kit's pages are components in `app/Live`, and its other markup is in `resources/views`:
|
|
116
|
+
|
|
117
|
+
| Path | What is in it |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `app/Live/Auth` | The guest pages: `Login`, `Register`, `ForgotPassword` and `ResetPassword`, and `VerifyEmail` |
|
|
120
|
+
| `app/Live/Settings` | `Profile`, with `DeleteUserForm` inside it, and `Password` |
|
|
121
|
+
| `app/Live/Actions/Logout.tsx` | The user menu's Log out button, and the sign-out the other components share |
|
|
122
|
+
| `resources/views/dashboard.tsx`, `resources/views/settings/appearance.tsx` | The dashboard and Settings → Appearance, as plain views |
|
|
123
|
+
| `resources/views/components/layouts` | `app` (the sidebar and the top bar) and `auth` (the guest pages), the documents the pages render into |
|
|
124
|
+
| `resources/views/components` | `auth-page`, `settings/layout`, `field`, `status`, `nav-link`, `text-link`, `sidebar-nav`, `user-menu`, `appearance-switch`, `icon`, `brand` and `copy-button` |
|
|
125
|
+
| `resources/js/app.ts` | Starts Live in the browser, with the Light / Dark / System switch, the menus, and the dashboard's Copy buttons and tick |
|
|
126
|
+
|
|
127
|
+
A Live page's route names its component: `Route.get('login', Login)`. Every update the browser sends runs the page
|
|
128
|
+
route's `auth`, `verified`, `guest` and `can` middleware again, so a signed-out tab can't run an action. TypeScript's
|
|
129
|
+
`private` doesn't hide a method from the browser: name it with `#` or a leading `_`, or mark it `@Locked`.
|
|
130
|
+
[Live](live.md) explains components.
|
|
131
|
+
|
|
132
|
+
### The dashboard
|
|
133
|
+
|
|
134
|
+
The dashboard greets the user by first name. In the `local` environment, it also shows a Getting started checklist
|
|
135
|
+
and the app's details; elsewhere it shows neither. The checklist ticks its steps from your app's real state:
|
|
136
|
+
|
|
137
|
+
| Step | Done when |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| Create your account | Always: you are signed in |
|
|
140
|
+
| Verify your email | Your email address is verified |
|
|
141
|
+
| Add your first page | Your app has a named route that isn't one of the kit's or the framework's |
|
|
142
|
+
| Store some data | `app/Models` has a model besides `User` |
|
|
143
|
+
| Run the tests | You tick it: the browser remembers it in `localStorage` |
|
|
144
|
+
|
|
145
|
+
The "Your app" card shows the environment, the Oriel version, the runtime, the database, the queue connection, the
|
|
146
|
+
mailer, and whether the health route's checks pass. `app/Support/GettingStarted.ts` builds both; change it, or remove
|
|
147
|
+
the cards from the dashboard page, as you like.
|
|
148
|
+
|
|
149
|
+
Two steps have a command with a Copy button. A click copies the command and the button says "Copied" for two
|
|
150
|
+
seconds. A polite live region after the button says it too, so a screen reader announces it. Where the browser offers
|
|
151
|
+
no clipboard, as on plain `http` away from `localhost`, the button stays on "Copy".
|
|
152
|
+
|
|
153
|
+
### Appearance
|
|
154
|
+
|
|
155
|
+
Settings → Appearance offers Light, Dark and System, and new users start on System. The choice is the `appearance`
|
|
156
|
+
cookie, kept for a year and mirrored to `localStorage`. The browser writes it, so `bootstrap/app.ts` leaves it out of
|
|
157
|
+
cookie encryption: `EncryptCookies.except('appearance')`.
|
|
158
|
+
|
|
159
|
+
The `HandleAppearance` middleware reads the cookie for each request, so the server renders the page in the right
|
|
160
|
+
theme: the root view puts the `dark` class on `<html>` for Dark. For System, the head carries a short inline script,
|
|
161
|
+
before the stylesheet, that adds `dark` before the first paint when the system is dark, and the browser code follows
|
|
162
|
+
the system setting as it changes. The script carries the request's CSP nonce when your app sets one with
|
|
163
|
+
`vite().useCspNonce()`, as the tags `vite()` writes do, so a strict Content Security Policy doesn't block it.
|
|
164
|
+
|
|
165
|
+
The welcome page, at `/`, follows the same cookie. Its styles are inline, with the nonce, and use plain custom
|
|
166
|
+
properties rather than CSS's `light-dark()`, so browsers without `light-dark()`, such as Safari before 17.5 and Chrome
|
|
167
|
+
before 123, get both themes too.
|
|
168
|
+
|
|
169
|
+
### The stylesheet
|
|
170
|
+
|
|
171
|
+
The kits with pages share `resources/css/app.css`: Tailwind CSS 4 with the Oriel theme, one violet accent and system
|
|
172
|
+
fonts, in light and dark. Tailwind's `dark:` variant follows the `dark` class on `<html>`. The theme adds:
|
|
173
|
+
|
|
174
|
+
- **Colours** that follow the theme: `text-ink`, `text-body`, `text-muted`, `text-faint`, `border-line`,
|
|
175
|
+
`text-accent`, `text-success` and `text-danger` (each with every utility Tailwind makes from a colour), and Oriel's
|
|
176
|
+
fixed colours, such as `bg-midnight-canvas` and `text-glow-violet`.
|
|
177
|
+
- **A type scale** in place of Tailwind's: `text-small`, `text-sm`, `text-body`, `text-body-lg`, `text-intro`,
|
|
178
|
+
`text-subheading`, `text-heading-sm`, `text-heading`, `text-heading-lg` and `text-display`.
|
|
179
|
+
- **Radii** in place of Tailwind's: `rounded-control`, `rounded-list`, `rounded-glass` and `rounded-pill`. Tailwind's
|
|
180
|
+
shadow scales are removed: the component classes carry the depth.
|
|
181
|
+
|
|
182
|
+
And the component classes the pages are built from:
|
|
183
|
+
|
|
184
|
+
| Group | Classes |
|
|
185
|
+
| --- | --- |
|
|
186
|
+
| Buttons | `btn` (the primary), `btn-secondary`, `btn-quiet`, `btn-danger`, `btn-ghost`, `btn-sm`, `btn-icon` |
|
|
187
|
+
| Surfaces | `card`, `card-danger`, `glass` |
|
|
188
|
+
| Text | `title`, `section-title`, `description`, `link`, `code` |
|
|
189
|
+
| Forms | `form`, `field`, `label`, `input`, `checkbox`, `error`, `status` |
|
|
190
|
+
| Navigation | `nav-link`, `seg`, `seg-icons`, `crumbs`, `menu`, `menu-button`, `menu-panel`, `menu-panel-start`, `menu-who`, `menu-item` |
|
|
191
|
+
| Layout | `shell`, `sidebar`, `side-nav`, `side-nav-end`, `shell-main`, `topbar`, `page`, `section`, `sky`, `sky-header`, `brand`, `avatar` |
|
|
192
|
+
| Dashboard | `progress`, `step`, `step-title`, `step-text`, `check`, `copy`, `copy-label`, `facts`, `healthy` |
|
|
193
|
+
| Appearance | `previews`, `preview`, `preview-swatch`, `preview-light`, `preview-dark`, `preview-system` |
|
|
194
|
+
|
|
195
|
+
Utilities still override them: `<button class="btn btn-sm w-full">`.
|
|
196
|
+
|
|
197
|
+
### Accessibility
|
|
198
|
+
|
|
199
|
+
The kits are built to be read by everyone:
|
|
200
|
+
|
|
201
|
+
- Every text colour, `--faint` included (placeholders, the breadcrumb's separator, finished checklist steps), keeps a
|
|
202
|
+
contrast of 4.5:1 (WCAG AA) on the page, cards, the top bar, the sidebar and inputs, in both themes. So do the
|
|
203
|
+
welcome page's footer and step numbers.
|
|
204
|
+
- `.btn-danger`, the Delete account button, is dark enough for its white label to keep 4.5:1, hovered or not.
|
|
205
|
+
- `.status` stays in the page even when it is empty, so a screen reader announces the status text that appears in it.
|
|
206
|
+
- The Copy buttons announce "Copied" through a polite live region.
|
|
207
|
+
- The menus are native `<details>` elements: Escape closes one and returns the focus to its button, and a click
|
|
208
|
+
elsewhere, a choice inside it or the focus moving out of it closes it too.
|
|
209
|
+
- `:focus-visible` draws a violet outline around the focused control.
|
|
210
|
+
|
|
211
|
+
## Authentication
|
|
212
|
+
|
|
213
|
+
The kits with pages register their routes in `routes/auth.ts` and `routes/settings.ts`, and the dashboard and the
|
|
214
|
+
welcome page in `routes/web.ts`:
|
|
215
|
+
|
|
216
|
+
| URI | Method | Route name | What it does |
|
|
217
|
+
| --- | --- | --- | --- |
|
|
218
|
+
| `/` | `GET` | `home` | The welcome page |
|
|
219
|
+
| `/dashboard` | `GET` | `dashboard` | The dashboard, for verified users |
|
|
220
|
+
| `/register` | `GET`, `POST` | `register` | The registration page, and registering |
|
|
221
|
+
| `/login` | `GET`, `POST` | `login` | The login page, and signing in |
|
|
222
|
+
| `/logout` | `POST` | `logout` | Signing out |
|
|
223
|
+
| `/forgot-password` | `GET`, `POST` | `password.request`, `password.email` | The reset link page, and sending the link |
|
|
224
|
+
| `/reset-password/{token}` | `GET` | `password.reset` | The new password page |
|
|
225
|
+
| `/reset-password` | `POST` | `password.store` | Setting the new password |
|
|
226
|
+
| `/verify-email` | `GET` | `verification.notice` | The page that asks the user to verify their email |
|
|
227
|
+
| `/verify-email/{id}/{hash}` | `GET` | `verification.verify` | The signed link in the verification mail |
|
|
228
|
+
| `/email/verification-notification` | `POST` | `verification.send` | Sending the verification mail again |
|
|
229
|
+
| `/settings` | `GET` | | A redirect to `/settings/profile` |
|
|
230
|
+
| `/settings/profile` | `GET`, `PATCH`, `DELETE` | `profile.edit`, `profile.update`, `profile.destroy` | The profile page, updating the name and email, and deleting the account |
|
|
231
|
+
| `/settings/password` | `GET`, `PUT` | `password.edit`, `password.update` | The password page, and changing the password |
|
|
232
|
+
| `/settings/appearance` | `GET` | `appearance.edit` | The appearance page |
|
|
233
|
+
|
|
234
|
+
Where a URI has two methods and one name, the name is the `GET` route's. The guest pages are behind the `guest`
|
|
235
|
+
middleware, and the rest behind `auth`. In the Live kit, a page's route is a `GET` route to its component, and the
|
|
236
|
+
forms are the component's actions: the `POST`, `PATCH`, `PUT` and `DELETE` routes, and `logout` and
|
|
237
|
+
`verification.send`, aren't there. `node oriel route:list` shows them all.
|
|
238
|
+
|
|
239
|
+
> [!NOTE]
|
|
240
|
+
> Laravel's starter kits also have two-factor authentication and a password confirmation page. Oriel's kits have
|
|
241
|
+
> neither.
|
|
242
|
+
|
|
243
|
+
### Email verification
|
|
244
|
+
|
|
245
|
+
Users verify their email address before they reach the dashboard: its route has the `verified` middleware. A new
|
|
246
|
+
user gets the verification mail when they register, and an unverified user is sent to `/verify-email`, which can
|
|
247
|
+
send it again. A user who changes their email address on the profile page has to verify the new one.
|
|
248
|
+
|
|
249
|
+
To let users in without verifying, take `verified` off the dashboard's route:
|
|
250
|
+
|
|
251
|
+
```ts title="routes/web.ts"
|
|
252
|
+
// @filename: app/Http/Controllers/DashboardController.ts
|
|
253
|
+
export declare class DashboardController {
|
|
254
|
+
__invoke(): Promise<unknown>
|
|
255
|
+
}
|
|
256
|
+
// ---cut---
|
|
257
|
+
import { Route, view } from '@orieljs/framework'
|
|
258
|
+
import { DashboardController } from '../app/Http/Controllers/DashboardController'
|
|
259
|
+
|
|
260
|
+
Route.get('/', () => view('welcome')).name('home')
|
|
261
|
+
|
|
262
|
+
Route.middleware(['auth']).group(() => {
|
|
263
|
+
Route.get('dashboard', DashboardController).name('dashboard')
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
In the Live kit, take it off the `Route.view('dashboard', 'dashboard')` route the same way. The verification mail is
|
|
268
|
+
still sent when a user registers.
|
|
269
|
+
|
|
270
|
+
> [!NOTE]
|
|
271
|
+
> Laravel sends the verification mail only when the `User` model implements `MustVerifyEmail`, which a new Laravel
|
|
272
|
+
> app's doesn't. In Oriel, every user model built on `AuthenticatableModel` can verify its email, and the kits with
|
|
273
|
+
> pages require it.
|
|
274
|
+
|
|
275
|
+
### Rate limiting
|
|
276
|
+
|
|
277
|
+
The kits limit the routes an attacker would try many times:
|
|
278
|
+
|
|
279
|
+
| What | Limit |
|
|
280
|
+
| --- | --- |
|
|
281
|
+
| Signing in | 5 failed attempts per email address and IP address, then locked for a minute |
|
|
282
|
+
| The verification link, and sending it again | 6 requests a minute (`throttle:6,1`) |
|
|
283
|
+
| Changing the password | 6 requests a minute (`throttle:6,1`) |
|
|
284
|
+
| Sending a reset link | One link per email address a minute (`passwords.users.throttle` in `config/auth.ts`) |
|
|
285
|
+
|
|
286
|
+
The login limit is in `LoginRequest`, in `app/Http/Requests/Auth` (in the Live kit, in the `Login` component): change
|
|
287
|
+
the `5` in `tooManyAttempts()` to allow more. In the Live kit, sending the verification mail again and changing the
|
|
288
|
+
password are component actions, which have no limit of their own.
|
|
289
|
+
|
|
290
|
+
### Customizing user creation
|
|
291
|
+
|
|
292
|
+
Registration is `RegisteredUserController`'s `store()` (in the Live kit, the `Register` component's `register()`).
|
|
293
|
+
It validates the form, creates the user, fires the `Registered` event, which sends the verification mail, and signs
|
|
294
|
+
the user in. To ask for more, add the field's rule and save it, after a migration that adds its column:
|
|
295
|
+
|
|
296
|
+
```ts title="app/Http/Controllers/Auth/RegisteredUserController.ts"
|
|
297
|
+
import { Auth, event, Hash, inertia, redirect, Registered, request, Rule } from '@orieljs/framework'
|
|
298
|
+
import { User } from '../../../Models/User'
|
|
299
|
+
import { Controller } from '../Controller'
|
|
300
|
+
|
|
301
|
+
export class RegisteredUserController extends Controller {
|
|
302
|
+
/** Show the registration page. */
|
|
303
|
+
create() {
|
|
304
|
+
return inertia('Auth/Register')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Handle an incoming registration request. */
|
|
308
|
+
async store() {
|
|
309
|
+
const data = await request().validate({
|
|
310
|
+
name: 'required|string|max:255',
|
|
311
|
+
email: 'required|string|lowercase|email|max:255|unique:users',
|
|
312
|
+
phone: 'required|string|max:20',
|
|
313
|
+
password: ['required', 'confirmed', Rule.password()],
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const user = await User.create({
|
|
317
|
+
name: data.name,
|
|
318
|
+
email: data.email,
|
|
319
|
+
phone: data.phone,
|
|
320
|
+
password: await Hash.make(data.password),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await event(new Registered(user))
|
|
324
|
+
|
|
325
|
+
await Auth.login(user)
|
|
326
|
+
|
|
327
|
+
return redirect().route('dashboard')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Add `phone` to the model's `fillable` too, and the field to the registration page. `Rule.password()` asks for at
|
|
333
|
+
least 8 characters; [Validating passwords](validation.md#validating-passwords) shows how to ask for more.
|
|
334
|
+
|
|
335
|
+
The password reset is `NewPasswordController` (the `ResetPassword` component in Live), and the profile is
|
|
336
|
+
`ProfileController` and `ProfileUpdateRequest` (the `Profile` component). Deleting an account asks for the current
|
|
337
|
+
password, then signs the user out and deletes them.
|
|
338
|
+
|
|
339
|
+
## The API kit
|
|
340
|
+
|
|
341
|
+
The API kit's routes are in `routes/api.ts`, so their URIs start with `/api`:
|
|
342
|
+
|
|
343
|
+
| URI | Method | Route name | Token | Answer |
|
|
344
|
+
| --- | --- | --- | --- | --- |
|
|
345
|
+
| `/api/register` | `POST` | `register` | | 201 with `{ user, token }` |
|
|
346
|
+
| `/api/login` | `POST` | `login` | | `{ token }`; a 422 on bad credentials, locked for a minute after five failures |
|
|
347
|
+
| `/api/user` | `GET` | `user` | Yes | The user |
|
|
348
|
+
| `/api/logout` | `POST` | `logout` | Yes | 204; the token is revoked |
|
|
349
|
+
| `/api/forgot-password` | `POST` | `password.email` | | `{ status }`, the same whether or not the account exists |
|
|
350
|
+
| `/api/reset-password` | `POST` | `password.store` | | `{ status }`; every token of the user is revoked |
|
|
351
|
+
|
|
352
|
+
Send the token in the `Authorization` header: `Authorization: Bearer <token>`. The routes with a token are behind
|
|
353
|
+
`auth:token`, and the four without one are limited to 60 requests a minute per client (`throttle:60,1`). Errors are
|
|
354
|
+
JSON under `/api`, whatever the request's `Accept` header: `bootstrap/app.ts` says so with `shouldRenderJsonWhen()`.
|
|
355
|
+
|
|
356
|
+
The reset mail links to `/reset-password/{token}?email=…` on the host the request came to, which is your API's own
|
|
357
|
+
address. That page belongs to your client app, which posts the token, the email and the new password to
|
|
358
|
+
`/api/reset-password`, so point the link at it with `ResetPassword.createUrlUsing()` (see [Reset link
|
|
359
|
+
customization](passwords.md#reset-link-customization)).
|
|
360
|
+
|
|
361
|
+
The API kit has no email verification. To add it, add the verification routes and fire the `Registered` event in
|
|
362
|
+
`RegisteredUserController`, which then sends the mail.
|
|
363
|
+
|
|
364
|
+
## Inertia SSR
|
|
365
|
+
|
|
366
|
+
The React, Vue, Svelte and Solid kits render their pages on the server, so the first visit gets HTML. The Angular kit
|
|
367
|
+
renders its pages in the browser only.
|
|
368
|
+
|
|
369
|
+
`npm run build` builds both bundles: the browser's into `public/build`, and the server's, from `resources/js/ssr.*`,
|
|
370
|
+
into `bootstrap/ssr`. While `npm run dev` runs, Vite renders the pages on the server instead; right after it starts,
|
|
371
|
+
the first pages can come back rendered in the browser only while it warms up, and Vite prints "SSR skipped".
|
|
372
|
+
|
|
373
|
+
In production, run the SSR server next to your app:
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
node oriel inertia:start-ssr
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
On Bun, run `bun oriel inertia:start-ssr --runtime=bun`. The server listens on the port of `INERTIA_SSR_URL`,
|
|
380
|
+
`http://127.0.0.1:13714` unless you set it, and `INERTIA_SSR_ENABLED=false` turns server-side rendering off. When no
|
|
381
|
+
SSR server answers, the pages are rendered in the browser, as in the Angular kit. `inertia:check-ssr` tells you
|
|
382
|
+
whether it is running, and `inertia:stop-ssr` stops it. [Deployment](deployment.md#the-ssr-server) shows how to keep it
|
|
383
|
+
running.
|
|
384
|
+
|
|
385
|
+
## Frequently asked questions
|
|
386
|
+
|
|
387
|
+
#### How do I upgrade?
|
|
388
|
+
|
|
389
|
+
The kit's code is your app's from the start, so there is no kit to upgrade. The framework itself is a dependency:
|
|
390
|
+
update `@orieljs/framework` and the other `@orieljs/*` packages with your package manager.
|
|
391
|
+
|
|
392
|
+
#### Where is the mail a new user receives?
|
|
393
|
+
|
|
394
|
+
With the `log` mailer, a new app's default, in `storage/logs/oriel.log`, or `storage/logs/mail.log` with
|
|
395
|
+
`MAIL_LOG_CHANNEL=mail` in `.env`. The verification mail and the reset mail are Oriel's own notifications; the
|
|
396
|
+
[Notifications](notifications.md) page shows how to change them.
|
package/docs/storage.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# File storage
|
|
2
|
+
|
|
3
|
+
Oriel's `Storage` reads and writes files on disks: folders on your server, or an Amazon S3 bucket (or any S3-compatible
|
|
4
|
+
service), through one API.
|
|
5
|
+
|
|
6
|
+
## Configuration
|
|
7
|
+
|
|
8
|
+
Your disks are in `config/filesystems.ts`. Each disk names its driver, `local` or `s3`, and its settings; `default`
|
|
9
|
+
names the disk `Storage` uses when you don't name one (`FILESYSTEM_DISK`, else `local`). A new app has three disks:
|
|
10
|
+
`local`, `public` and `s3`.
|
|
11
|
+
|
|
12
|
+
### The local driver
|
|
13
|
+
|
|
14
|
+
A `local` disk's paths are relative to its `root`, an absolute folder. The `local` disk's root is
|
|
15
|
+
`storage/app/private`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Storage } from '@orieljs/framework'
|
|
19
|
+
|
|
20
|
+
await Storage.disk('local').put('example.txt', 'Contents')
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A path that climbs out of the root with `../` throws.
|
|
24
|
+
|
|
25
|
+
### The public disk
|
|
26
|
+
|
|
27
|
+
The `public` disk is for files anyone may download: its root is `storage/app/public`, its files are written public, and
|
|
28
|
+
its `url` is `APP_URL` with `/storage`. To serve those files, link `public/storage` to `storage/app/public`:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
node oriel storage:link
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It always makes that one link. `--relative` makes it relative, and `--force` makes it again. The `links` entry of a new
|
|
35
|
+
app's `config/filesystems.ts` isn't read yet.
|
|
36
|
+
|
|
37
|
+
### The S3 driver
|
|
38
|
+
|
|
39
|
+
The `s3` disk reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`, `AWS_URL`,
|
|
40
|
+
`AWS_ENDPOINT` and `AWS_USE_PATH_STYLE_ENDPOINT` from your `.env`. For an S3-compatible service, such as MinIO or
|
|
41
|
+
Cloudflare R2, set `endpoint`, and `usePathStyleEndpoint` if it wants the bucket in the path.
|
|
42
|
+
|
|
43
|
+
## Obtaining disk instances
|
|
44
|
+
|
|
45
|
+
`Storage` works on the default disk; `Storage.disk(name)` on another:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Storage } from '@orieljs/framework'
|
|
49
|
+
|
|
50
|
+
await Storage.put('avatars/1.txt', 'Contents')
|
|
51
|
+
await Storage.disk('s3').put('avatars/1.txt', 'Contents')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Retrieving files
|
|
55
|
+
|
|
56
|
+
Every method is async. `get()` gives a file's contents as a string, `getBytes()` as a `Uint8Array`, and `readStream()`
|
|
57
|
+
as a web `ReadableStream`. A missing file throws.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Storage } from '@orieljs/framework'
|
|
61
|
+
|
|
62
|
+
const contents = await Storage.get('file.txt')
|
|
63
|
+
const bytes = await Storage.getBytes('photo.jpg')
|
|
64
|
+
const stream = await Storage.readStream('video.mp4')
|
|
65
|
+
|
|
66
|
+
if (await Storage.exists('file.txt')) {
|
|
67
|
+
// ...
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`missing()` is `exists()`'s opposite.
|
|
72
|
+
|
|
73
|
+
### File URLs
|
|
74
|
+
|
|
75
|
+
`url()` gives a file's public URL: the disk's `url` with the path. A `local` disk with no `url`, such as `local`,
|
|
76
|
+
throws.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { Storage } from '@orieljs/framework'
|
|
80
|
+
|
|
81
|
+
const url = Storage.disk('public').url('avatars/1.jpg')
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
On an S3 disk, `url()` encodes the key, segment by segment, and joins it to the configured `url` (a CDN's address, for
|
|
85
|
+
example). Without one, it uses the `endpoint`, else `https://s3.<region>.amazonaws.com` (`us-east-1` when no region is
|
|
86
|
+
set), with the bucket as a subdomain, or in the path when `usePathStyleEndpoint` is on.
|
|
87
|
+
|
|
88
|
+
> [!NOTE]
|
|
89
|
+
> Laravel leaves the key unencoded after a configured `url`; Oriel encodes it there too.
|
|
90
|
+
|
|
91
|
+
### Temporary URLs
|
|
92
|
+
|
|
93
|
+
On an S3 disk, `temporaryUrl()` gives a signed URL that works until the date you give:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { Storage } from '@orieljs/framework'
|
|
97
|
+
|
|
98
|
+
const url = await Storage.disk('s3').temporaryUrl('report.pdf', new Date(Date.now() + 5 * 60_000))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A `local` disk can't sign URLs, and throws.
|
|
102
|
+
|
|
103
|
+
### File metadata
|
|
104
|
+
|
|
105
|
+
`size()` gives a file's size in bytes, `lastModified()` a `Date`, and `mimeType()` its type, or `undefined`. On a
|
|
106
|
+
local disk, `path()` gives the file's absolute path.
|
|
107
|
+
|
|
108
|
+
## Storing files
|
|
109
|
+
|
|
110
|
+
`put()` writes a string, a `Uint8Array` or a web `ReadableStream`, creating the folders it needs:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { Storage } from '@orieljs/framework'
|
|
114
|
+
|
|
115
|
+
await Storage.put('file.txt', 'Contents')
|
|
116
|
+
await Storage.put('file.txt', 'Contents', { visibility: 'public' })
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`copy(from, to)` and `move(from, to)` copy and move a file on the same disk.
|
|
120
|
+
|
|
121
|
+
### File uploads
|
|
122
|
+
|
|
123
|
+
An uploaded file's `store()` saves it in a folder under a random name, with the extension its contents show, and gives
|
|
124
|
+
back the path. `storeAs()` takes the name. Both take a disk's name last:
|
|
125
|
+
|
|
126
|
+
```ts title="routes/web.ts"
|
|
127
|
+
import { request, Route } from '@orieljs/framework'
|
|
128
|
+
|
|
129
|
+
Route.post('/avatar', async () => {
|
|
130
|
+
const file = request().file('avatar')
|
|
131
|
+
|
|
132
|
+
const path = await file!.store('avatars')
|
|
133
|
+
await file!.storeAs('avatars', 'user-1.jpg', 's3')
|
|
134
|
+
|
|
135
|
+
return { path }
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`Storage.putFile(directory, file)` and `Storage.putFileAs(directory, file, name)` do the same from the disk's side.
|
|
140
|
+
|
|
141
|
+
### File visibility
|
|
142
|
+
|
|
143
|
+
A file is `public` or `private`. On a local disk that is the file's mode: `0644` or `0600` for files, and `0755` or
|
|
144
|
+
`0700` for the folders `makeDirectory()` makes; a folder `put()` makes has the default mode. A disk's `visibility`
|
|
145
|
+
setting is its default, and `put()`'s `visibility` option sets it for one file.
|
|
146
|
+
|
|
147
|
+
> [!NOTE]
|
|
148
|
+
> Oriel has no `getVisibility()` or `setVisibility()`: set it when you write the file.
|
|
149
|
+
|
|
150
|
+
## Deleting files
|
|
151
|
+
|
|
152
|
+
`delete()` takes a path or an array of paths:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { Storage } from '@orieljs/framework'
|
|
156
|
+
|
|
157
|
+
await Storage.delete('file.txt')
|
|
158
|
+
await Storage.delete(['file.jpg', 'file2.jpg'])
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Directories
|
|
162
|
+
|
|
163
|
+
`files(directory)` lists a folder's files, and `files(directory, true)` its subfolders' too; `directories()` does the
|
|
164
|
+
same for folders. `makeDirectory()` creates a folder and `deleteDirectory()` deletes one with everything in it.
|
|
165
|
+
|
|
166
|
+
## Live uploads
|
|
167
|
+
|
|
168
|
+
A Live component's upload is first stored as a temporary file, under `live-tmp` on your default disk, with a random
|
|
169
|
+
name. Your component's `store()` moves it to its place. When an upload finishes, temporary files older than a day are
|
|
170
|
+
deleted, except on an S3 disk, where listing the folder on every upload would be slow: give the bucket a lifecycle rule
|
|
171
|
+
for `live-tmp` instead. `live.temporary_file_upload.disk` and `directory` choose another place.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
`Storage.fake()` swaps a disk for one in memory, so a test writes no files:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { Storage } from '@orieljs/framework'
|
|
179
|
+
|
|
180
|
+
const disk = Storage.fake('avatars')
|
|
181
|
+
|
|
182
|
+
// Upload the avatar...
|
|
183
|
+
|
|
184
|
+
await disk.assertExists('avatar.jpg')
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
[Fakes](mocking.md) covers fakes in full.
|
|
188
|
+
|
|
189
|
+
## Custom filesystems
|
|
190
|
+
|
|
191
|
+
`Storage.extend(driver, factory)` adds a driver, in a provider's `boot()` method: the factory takes a disk's settings
|
|
192
|
+
and returns an object with the `Filesystem` contract's methods.
|