Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,18 @@ version, see the upgrade guides in `docs/`.
- **server:** `UserMixin.login()` reads the credentials only from the request body, where Passport's local strategy also read them from the query string.
- **server:** Dito.js logs users in and restores them from the session itself, instead of through koa-passport. Passport's `ctx.req.user` mirror of `ctx.state.user` and custom Passport strategies through Dito.js are gone.
- **server:** `UsersController` only treats users of its own model class as logged in, not users of its sub-classes, which have their own tables and ids. This affects apps that serve sub-classed user models through the parent's `UsersController`: their `session`, `self` and `logout` actions no longer mistake such users for the parent's user with the same id.
- **server:** `Controller.setup()` is internal, like `Model.setup()`, and may be asynchronous: apps override `initialize()` instead.
- **server:** `UserMixin.sessionQuery()` is internal: `sessionScope` sets the scopes of the users of logins and sessions.
- **admin:** Dialogs validate their data when submitted, like forms, and only resolve once it's valid, where they resolved with invalid data, e.g. with empty `required` fields.

### New

- **server:** Users log in with passkeys, with `app.auth.passkeys`, sessions stored on the server and the optional peer dependency `@simplewebauthn/server`. `UsersController` adds `'post passkey-login-options'` and `'post passkey-login'`, and lets users manage their own passkeys through `'get passkeys'`, `'post passkey-options'`, `'post passkeys'` and `'delete passkeys/:id'`, see [Passkeys](docs/guide/server/controllers.md#passkeys). Passkeys are named after their providers, e.g. `'1Password'`, through the new dependency `aaguids`.
- **server:** `UsersController` lets users change their password through `'post password'`, with the current password. Subclasses that set `collection.allow` list it to route it.
- **server:** `CredentialMixin` and `CredentialModel` store the credentials of all user models in one table, set in `app.auth.credentials`. Deleting users deletes their credentials.
- **admin:** `login: { passkeys: true }` adds a passkey login to the login dialog, and a Security dialog to the account menu that changes the user's password, and lists, adds and removes the user's passkeys. Requires the new peer dependency `@simplewebauthn/browser`.
- **admin:** The `empty` option of lists shows a row with its text while the list has no items, e.g. `empty: 'No books yet.'`.
- **admin:** `context.reload()` reloads the items of the list or object that the component is or is in, e.g. after a list button changed them on the server.

### New

Expand All @@ -43,6 +54,7 @@ version, see the upgrade guides in `docs/`.

### Fixes

- **admin:** Lists outside of routes, e.g. in dialogs, keep their scope, page, order and filters to themselves, instead of loading the query of the current route and navigating to their changes. Lists in routes still change them through the route.
- **router:** A trailing slash of the `prefix` option is removed unless in strict mode, so `new Router({ prefix: '/api/' })` routes `/users` as `/api/users` instead of `/api//users` (#336).
- **router:** `**` routes added after a `*` route at the same position match, e.g. `/a/**` after `/a/*`, instead of being nested under the `*` route and never matching (#330).
- **router:** Params and `*` at the same position are both tried, e.g. `/a/1/y` finds `/a/*/y` next to `/a/:id/x`, where only the route added first was tried. A param and `*` that end at the same position, e.g. `/a/:id` and `/a/*`, are the same route, so the one added last wins, as with two params, where the one added first won (#331).
Expand Down
20 changes: 14 additions & 6 deletions docs/api/admin/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ new DitoAdmin(element, options)
| `options.dito` | [`GlobalConfig`](#globalconfig) | The global configuration. Defaults to `globalThis.dito`, which `AdminController` provides. |
| `options.login.additionalComponents` | `Components` | Components added to the login dialog after `username` and `password`. Their values are sent with the credentials. |
| `options.login.redirectAfterLogin` | `string` | A URL to load after a successful login, instead of resolving the views in place. |
| `options.login.passkeys` | `boolean` | Adds a Sign In with Passkey button to the login dialog, and a Security item to the account menu, whose dialog changes the user's password through [`api.users.password`](#users), and lists, adds and removes the user's passkeys, named after their providers. Needs passkeys on the server, see [Passkeys](../../guide/server/controllers.md#passkeys), and the peer dependency `@simplewebauthn/browser`. |
| `options.spinner` | `{ size?: string, color?: string } \| null` | Size and color of the loading spinner in the header. `null` hides it. |
| `options.account.settings` | `Form \| Promise \| () => …` | The form of the account settings, which the account menu opens at `/account/settings`, to edit the logged-in user through [`api.users.self`](#users). Also a promise or a function returning it, e.g. a dynamic `import()`. Its `label` is the view's label, default `'Settings'`. See [Account settings](../../guide/admin/views.md#account-settings). |

Expand Down Expand Up @@ -147,15 +148,21 @@ api: {
login: { path: 'login', method: 'post' },
logout: { path: 'logout', method: 'post' },
session: { path: 'session', method: 'get' },
self: { path: 'self' }
self: { path: 'self' },
password: { path: 'password', method: 'post' },
passkeyLoginOptions: { path: 'passkey-login-options', method: 'post' },
passkeyLogin: { path: 'passkey-login', method: 'post' },
passkeyOptions: { path: 'passkey-options', method: 'post' },
passkeys: { path: 'passkeys' }
}
}
```

`users.path` is the collection of the user model; `users: 'users'` is short
for `users: { path: 'users' }`. `login`, `logout`, `session` and `self` are
resources nested under it, shown here with their defaults, so the admin logs
in through `POST users/login`. The server's `UsersController` provides them.
for `users: { path: 'users' }`. `login`, `logout`, `session`, `self`,
`password` and the passkey resources of `login.passkeys` are resources nested
under it, shown here with their defaults, so the admin logs in through
`POST users/login`. The server's `UsersController` provides them.
The admin reads `user` from the responses of `login` and `session`, and
`success` from the response of `logout`. The
[account settings](#parameters) load the logged-in user from `self` and patch
Expand Down Expand Up @@ -352,8 +359,9 @@ responses cached with `request({ cache: 'global' })`.
session.login(): Promise<AdminUser | null>
```

Shows the login dialog and posts the credentials to `api.users.login`, asking
again after each failure, until it succeeds or the user cancels. With
Shows the login dialog and posts the credentials to `api.users.login`, or
logs in with a passkey through `api.users.passkeyLogin`, asking again after
each failure, until it succeeds or the user cancels. With
`login.redirectAfterLogin`, a successful login loads that URL instead.

##### Return value
Expand Down
29 changes: 27 additions & 2 deletions docs/api/admin/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,31 @@ buttons of sources and forms.

Whether the submit succeeded.

### reload()

```ts
context.reload(): Promise<void>
```

Reloads the items of the list or object that the component is or is in, e.g.
of a list button after it changed the items on the server, and resolves once
they're loaded. Sources without their own `resource` are part of the data
around them and have nothing to load. Throws outside of lists and objects.

#### Example

```js
buttons: {
import: {
text: 'Import',
async onClick({ request, reload }) {
await request({ method: 'post', resource: { path: 'import' } })
await reload()
}
}
}
```

### closeForm()

```ts
Expand Down Expand Up @@ -712,8 +737,8 @@ Also available outside of schemas as `admin.context.showDialog()`, see
| `options.settings.width` | `number` | The CSS max-width of the dialog, in pixels. Defaults to `480`. |
| `options.settings.clickToClose` | `boolean` | Whether clicking outside of the dialog cancels it. Defaults to `false`. |

Submitting doesn't validate the data. Escape cancels dialogs that have a
`cancel` button or no buttons.
Submitting validates the data first, like forms do. Escape cancels dialogs
that have a `cancel` button or no buttons.

#### Return value

Expand Down
1 change: 1 addition & 0 deletions docs/api/admin/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,7 @@ loads as too.
| ------ | ---- | ------- | ----------- |
| `type` | `'list'` | | Required. |
| `filters` | `object` | | Filters shown in a panel in the sidebar, see [Filters](#filters). |
| `empty` | `SchemaValue<string>` | | The text of a row shown while the list has no items, e.g. `'No books yet.'`. Without it, empty lists show no row. |

```js
books: {
Expand Down
27 changes: 27 additions & 0 deletions docs/api/server/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,33 @@ augmentation of `ApplicationConfig` for project-specific settings.
| `bodyParser` | `object` | | Options of [koa-bodyparser](https://github.com/koajs/bodyparser#options). |
| `session` | `boolean \| object` | `false` | Sessions through [koa-session](https://github.com/koajs/session). `modelClass` (a name or a model class) stores them in a model, e.g. one using [`SessionMixin`](model.md#sessionmixin). `autoCommit` (default `true`) commits the session after each request, on transacted routes only when the request succeeds. |
| `passport` | `boolean` | | Deprecated, without effect: users are always handled, see [`UserMixin`](model.md#usermixin). |
| `auth` | `object` | | Authentication beyond passwords, see [`config.app.auth`](#configappauth). |

### config.app.auth

```js
app: {
session: { modelClass: 'Session' },
auth: {
credentials: { modelClass: 'Credential' },
passkeys: {
name: 'Example',
id: 'example.com',
origins: ['https://example.com', 'https://admin.example.com']
}
}
}
```

| Key | Type | Description |
| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ |
| `credentials.modelClass` | `string \| Class<Model>` | The model that stores the credentials of all user models, using [`CredentialMixin`](model.md#credentialmixin). |
| `passkeys` | `object` | Enables the passkeys of [`UsersController`](controllers.md#userscontroller). Needs `credentials` and a `session` stored on the server, through `modelClass` or `store`, else the setup fails. |
| `passkeys.name` | `string` | The name of the app that authenticators show. |
| `passkeys.id` | `string` | The domain that the passkeys belong to, the WebAuthn relying party id. Frontends on this domain or its subdomains can use them. |
| `passkeys.origins` | `string[]` | The origins of the frontends that log in with passkeys, which may differ from the API's. |

Passkeys need the optional peer dependency `@simplewebauthn/server`.

### config.admin

Expand Down
44 changes: 35 additions & 9 deletions docs/api/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ exported by `@ditojs/server`:
| [`CollectionController`](#collectioncontroller) | Base class of model and relation controllers. Not extended directly. |
| [`ModelController`](#modelcontroller) | CRUD routes for a model's collection and members, plus its relations. |
| [`RelationController`](#relationcontroller) | Routes for a relation of a model controller. Created from `relations`, never extended. |
| [`UsersController`](#userscontroller) | A model controller with login, logout and session actions. |
| [`UsersController`](#userscontroller) | A model controller with login, logout, session and passkey actions. |
| [`AdminController`](#admincontroller) | Serves Dito.js Admin. |

Controllers are registered through the `controllers` option of the
Expand Down Expand Up @@ -286,8 +286,9 @@ application. Sets up `hooks`, `name`, `path` and `url`. Call
controller.setup()
```

Sets up the routes of the actions and asset uploads. Call `super.setup()`.
*Hook.*
Sets up the routes of the actions and asset uploads, before `initialize()`.
Internal: dito's controllers override it, and may do so asynchronously. Apps
override [`initialize()`](#initialize) instead, which needs no `super`.

### initialize()

Expand Down Expand Up @@ -786,19 +787,44 @@ Extends [`ModelController`](#modelcontroller) for models with
[`UserMixin`](./model.md#usermixin), with these collection actions. They are
inherited actions, see [`allow`](#allow): a subclass that sets
`collection.allow` has to list them too, e.g.
`allow: ['get', 'post login', 'post logout', 'get session', 'get self']`.
`allow: ['get', 'post login', 'post logout', 'get session', 'get self']`, and
`'post password'` to let users change their password.

| Action | Response |
| -------------------- | ------------------------------------------------------- |
| `'post login'` | Logs in through [`modelClass.login(ctx)`](./model.md#static-login) and sets `lastLogin`: `{ success, authenticated, user, error }`. A failed login responds with the error's status, by default 401, and emits it as `'error'` on the app. |
| `'post logout'` | `{ success, authenticated }` |
| `'get session'` | `{ authenticated, user }` |
| `'get self'` | The authenticated user through `member.get`, or `null`. |

The controller's [`authorize`](#authorize) doesn't apply to `login`, `logout`
and `session`, which authenticate users; `self` follows it. Its member actions
default to `authorize: ['$self']`, and are routed when listed in `allow` like
on other model controllers. In member routes, `self` stands for the
| `'post password'` | Changes the password of the authenticated user to `password` in the body, after verifying `currentPassword`: `{ success }`, or 400 if `currentPassword` is wrong. The new password is validated by the user model, e.g. its `minLength`. |

With [`app.auth.passkeys`](./application.md#configappauth), users log in with
passkeys and manage their own ones through these collection actions, which
aren't routed without it:

| Action | Response |
| ------------------------------ | ------------------------------------------------------- |
| `'post passkey-login-options'` | The options of a passkey login for `startAuthentication()` of `@simplewebauthn/browser`, without a username. |
| `'post passkey-login'` | Logs in with the response of `startAuthentication()` in the body, like `'post login'`: `{ success, authenticated, user, error }`. |
| `'get passkeys'` | The passkeys of the authenticated user: `[{ id, name, createdAt, lastUsedAt }]`. |
| `'post passkey-options'` | The options of a new passkey for `startRegistration()`, which excludes the user's passkeys. |
| `'post passkeys'` | Stores the passkey of the response of `startRegistration()` in the body, named after its provider, e.g. `'1Password'`, or `'Passkey'` for unknown ones: `{ id, name, createdAt, lastUsedAt }`, or 409 for passkeys that are already registered. |
| `'delete passkeys/:id'` | Deletes a passkey of the authenticated user: `{ success }`, or 404 for other passkeys. |

A subclass that sets `collection.allow` has to list the passkey actions too,
else they aren't routed. The challenges of the options are kept in the
session, and can be answered once within 5 minutes. Failed passkey logins
answer `'Passkey is incorrect'`, and emit the reason as `'error'` on the app.
Passkeys are discoverable and verify the user, e.g. with a fingerprint, so a
passkey login counts as two factors. It only logs in users of the controller's
`modelClass`, through their `sessionScope`, and fires the
[login events](./model.md#login-events).

The controller's [`authorize`](#authorize) doesn't apply to `login`, `logout`,
`session`, `password` and the passkey actions, which authenticate users, or
are only allowed for the authenticated user; `self` follows it. Its member
actions default to `authorize: ['$self']`, and are routed when listed in
`allow` like on other model controllers. In member routes, `self` stands for the
authenticated user, see [`getMemberId()`](#getmemberid-1), e.g.
`PATCH /users/self` runs `member.patch` on them.

Expand Down
29 changes: 29 additions & 0 deletions docs/api/server/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,7 @@ import {
TimestampedMixin,
UserMixin,
SessionMixin,
CredentialMixin,
AssetMixin
} from '@ditojs/server'
```
Expand All @@ -885,6 +886,7 @@ already applied to [`Model`](#class-model), as a ready-made base class:
| `TimestampedMixin` | `TimestampedModel` |
| `UserMixin` | `UserModel` |
| `SessionMixin` | `SessionModel` |
| `CredentialMixin` | `CredentialModel` |
| `AssetMixin` | `AssetModel` |

The properties a mixin adds merge with the model's own
Expand Down Expand Up @@ -1076,6 +1078,33 @@ const app = new Application({
Sessions are read and written in the transaction of the request, if there is
one.

### CredentialMixin

```js
import { CredentialModel } from '@ditojs/server'

export class Credential extends CredentialModel {}
```

A model to store the credentials of users beyond their password, for now
their [passkeys](controllers.md#userscontroller). One table holds the
credentials of all user models. Use it with `app.auth.credentials.modelClass`,
the model's name or class, see [`config.app`](application.md#configapp).

| Property | Description |
| ------------ | --------------------------------------------------------------------------- |
| `type` | `string`, required: `'passkey'`. |
| `userModel` | `string`, required: the name of the user's model class. |
| `userId` | `string`, required: the id of the user, also of integer ids. |
| `key` | `string`, nullable, unique per `userModel`: the credential id of passkeys. |
| `name` | `string`, nullable: the name of the provider of passkeys, e.g. `'1Password'`. |
| `data` | `object`, hidden: the public key, signature counter and transports of passkeys. |
| `createdAt` | `timestamp`, set on insert. |
| `lastUsedAt` | `timestamp`, nullable: the time of the last login with the credential. |

Credentials refer to their users without foreign keys, so deleting users
through their model deletes their credentials too.

### AssetMixin

```js
Expand Down
9 changes: 5 additions & 4 deletions docs/guide/admin/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,11 @@ Inside the `itemLabel()` callback itself, it's `null`.
`isDirty`, `isCreating`, `validate()`, `isValid()`, `clearErrors()`,
`markDirty()`, `resetItem()` and `submit()` act on the data of the closest
form, view, dialog or panel with its own data. `closeForm()` closes the form
and `closeDialog()` the dialog, and `selectedTab` names the selected tab of
the form. Where they don't apply, the methods throw, e.g. `closeForm()`
outside of forms, `isDirty` and `isCreating` are `false`, and `selectedTab` is
`null`, also in forms without tabs. They're described in
and `closeDialog()` the dialog, `reload()` reloads the closest list or object,
and `selectedTab` names the selected tab of the form. Where they don't apply,
the methods throw, e.g. `closeForm()` outside of forms, `isDirty` and
`isCreating` are `false`, and `selectedTab` is `null`, also in forms without
tabs. They're described in
[Forms](./forms.md#acting-on-the-form-from-code),
[Validation](./validation.md), [Dirty state](./dirty-state.md),
[Buttons](./buttons.md) and [Dialogs](./dialogs.md).
Expand Down
Loading
Loading