# Sign-in for your app's users

Source: https://dabadata.com/docs/end-user-auth/ (HTML) - https://dabadata.com/docs/end-user-auth/index.md (this page as markdown)

A project can let dabadata keep the accounts of its own users: sign up with an email and a password, sign in, sign-in links by mail, password resets, with hosted pages if you want them and `db.auth` in the JavaScript client if you do not. You need no auth provider and no backend of your own. A signed-in user gets an ordinary dabadata user token, so rules, the trace and live subscriptions work exactly as with any other token.

## What you get

- **Accounts per environment.** Live and test each have their own users; an account on test does not exist on live. These are your app's users, never people with a console login ([Accounts and teams](https://dabadata.com/docs/accounts/index.md)).
- **Normal tokens.** Signing in answers the same pair as [`POST /v1/users/token`](https://dabadata.com/docs/api/index.md#users-token): an access token for 1 hour and a refresh token for 30 days, for the user `{ id, name, role, issuer: "hosted" }`. Ids look like `usr_k3x9m2p7q4w8z1ab`. Refreshing works as always.
- **Hosted pages** at `/auth/<project id>/` for sign in, sign up, links and resets, or the [JS client](https://dabadata.com/docs/end-user-auth/index.md#client) to build your own forms.
- **Social sign-in** with Google, Apple, Microsoft, GitHub and more, through your own OAuth apps, when you add `providers` ([Sign in with Google, Apple and more](https://dabadata.com/docs/sign-in-providers/index.md)).
- **A role per user**, one of your project's roles. New users get `defaultRole`; you change it in the console or with the secret key.

## Turn it on

Add an `auth.users` block to the environment's definition, next to `tables`:

```json
"auth": {
  "users": {
    "enabled": true,
    "signup": "open",
    "defaultRole": "editor",
    "verifyEmail": true,
    "redirectUrls": ["http://localhost:5173/", "https://tasks.example.com/"]
  }
}
```

| Key | Default | Meaning |
| --- | --- | --- |
| `enabled` | `false` | Only `true` switches the routes and the hosted pages on. Off, every route and page answers 404. |
| `signup` | `"open"` | `"open"`: anyone may sign up. `"invite"`: only people you add ([Managing users](https://dabadata.com/docs/end-user-auth/index.md#managing)); sign-up answers 403 `signup_closed`. |
| `defaultRole` | required | A role the definition declares. Every new user gets it. |
| `verifyEmail` | `true` | Sign-in waits until the address is confirmed by a mailed link. `false`: sign-up signs in at once. |
| `redirectUrls` | `[]` | Where links and hosted pages may send people back: absolute `http(s)` URLs without a `#`, at most 20. Anything else asked for is refused. |
| `invite` | none | `{ rule, roles? }`: which signed-in users may invite people from your app, and which roles they may give ([Invite people from your app](https://dabadata.com/docs/end-user-auth/index.md#invites)). Without it nobody invites from the app. |
| `manage` | none | A rule: which signed-in users may list, change and remove users from your app ([App admins](https://dabadata.com/docs/end-user-auth/index.md#app-admins)). Without it nobody manages users from the app. |
| `manageRoles` | every role | With `manage`: the roles a manager may give, and the roles of the users they may change. |
| `profiles` | none | `{ read, fields? }`: a read-only table `users` with every user's id, name (and role, if you ask) that rows and pages can show ([Profiles](https://dabadata.com/docs/end-user-auth/index.md#profiles)). |
| `onSignUp` | none | `{ table, values }`: one row the server inserts whenever a user is created ([A row for every new user](https://dabadata.com/docs/end-user-auth/index.md#on-signup)). |
| `providers` | none | Sign-in with Google, Apple, Microsoft, GitHub, GitLab, Discord, LinkedIn and Slack, through your own OAuth app at each: `{ google: { clientId, secret }, ... }` ([Sign in with Google, Apple and more](https://dabadata.com/docs/sign-in-providers/index.md)). |

Unknown keys are errors. `auth.issuers` may sit beside `users` or be left out. Edit it on the console's Definition page, or apply it with the environment's secret key: `PUT /v1/schema` takes the whole definition, so send the tables too - here the `tasks` template every new account starts with:

```bash
curl -X PUT https://dabadata.com/v1/schema \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'EOF'
{ "definition": {
    "roles": ["admin", "editor", "viewer"],
    "tables": {
      "tasks": {
        "columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false },
                     "owner": { "type": "userId" }, "due": { "type": "date", "optional": true },
                     "notes": { "type": "json", "optional": true } },
        "rules": { "read": "authenticated", "create": { "role": ["admin", "editor"] },
                   "update": { "any": [{ "role": "admin" }, { "owner": "owner" }] }, "delete": { "role": "admin" } }
      }
    },
    "auth": { "users": { "enabled": true, "defaultRole": "editor",
                         "redirectUrls": ["http://localhost:5173/"] } }
  }
}
EOF
```

Do it per environment: test first, live when you are ready. The console's **Users** page shows the block while hosted accounts are off, and the list of users once they are on.

## The routes

Every route is under `/v1/auth/users/` and needs only the environment's **publishable key** (`X-Api-Key`); `me`, `signout`, the invite routes and the `admin/` routes also need the user's token. Bodies are JSON.

| Route | Body | Answer |
| --- | --- | --- |
| `POST signup` | `{ email, password, name?, redirectUrl? }` | `{ ok, verify: true, signupSecret }` and a confirmation mail; with `verifyEmail: false`, `{ ok, verify: false, tokens, user }` |
| `POST verify` | `{ token, signupSecret? }` | `{ tokens, user, redirectUrl }` |
| `POST signin` | `{ email, password }` | `{ tokens, user }` |
| `POST magic` | `{ email, redirectUrl? }` | `{ ok: true }`, and a 15-minute sign-in link by mail |
| `POST magic/verify` | `{ token }` | `{ tokens, user, redirectUrl }` |
| `POST forgot` | `{ email, redirectUrl? }` | `{ ok: true }`, and a 1-hour reset link |
| `POST reset` | `{ token, password }` | `{ tokens, user, redirectUrl }`; every earlier token of the user stops working |
| `GET me` | - | `{ user }` |
| `PATCH me` | `{ name?, password?, currentPassword? }` | `{ user }`, plus new `tokens` after a password change |
| `POST signout` | `{ refreshToken? }` or `{ everywhere: true }` | `{ ok, revoked: { tokens, refreshTokens } }` |
| `POST invite` | `{ email, name?, role?, redirectUrl? }` | `{ ok: true }`, whether or not the address has an account ([invites](https://dabadata.com/docs/end-user-auth/index.md#invites)) |
| `GET invites` | - | `{ invites: [{ id, email, name, role, createdAt, expiresAt }] }`: the caller's open invites |
| `DELETE invites/:id` | - | `{ ok: true }`: cancels one of them |
| `GET admin/users` | - | `{ users, next }`: the users, for [app admins](https://dabadata.com/docs/end-user-auth/index.md#app-admins) (`?query=&limit=&after=`) |
| `PATCH admin/users/:id` | `{ role?, name?, disabled? }` | `{ user }` |
| `DELETE admin/users/:id` | - | `{ ok, revoked }` |

`user` is `{ id, email, name, role, emailVerified, hasPassword, createdAt }`. `hasPassword` is false for a user who signed in by link or with a provider and never set a password: show them a "set a password" form if your app wants one. Passwords follow the console's rules: 10 to 200 characters, not one character repeated, not the email. `signup`, `magic` and `forgot` answer the same whether or not the address has an account; a known address that signs up again gets a "you already have an account" mail with a sign-in link, never a second account. Every route, with its errors, is in the [HTTP API reference](https://dabadata.com/docs/api/index.md#end-users).

## Hosted pages

With users on, the environment has sign-in pages on the API's host, titled with your project's name ([rename it](https://dabadata.com/docs/end-user-auth/index.md#app-name) to your app's name):

```
https://dabadata.com/auth/prj_example12345/           sign in (live)
https://dabadata.com/auth/prj_example12345_test/      sign in (test)
https://dabadata.com/auth/prj_example12345/signup     sign up
https://dabadata.com/auth/prj_example12345/magic      email me a sign-in link
https://dabadata.com/auth/prj_example12345/forgot     forgot password
```

With [`providers`](https://dabadata.com/docs/sign-in-providers/index.md) set, the sign-in and sign-up pages also show a "Continue with Google" (GitHub, ...) button for each provider that is on; it keeps the page's `?redirect=`. The `verify`, `reset` and `invite` views are where mailed links land; they show a button, so a mail scanner that opens the link does not use it up. Add `?redirect=<one of your redirectUrls>` and, after a successful sign-in, the page sends the person there with the tokens in the URL fragment:

```
https://tasks.example.com/#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...
```

Call [`db.auth.completeLink()`](https://dabadata.com/docs/end-user-auth/index.md#client) on that page and the client takes the tokens and clears the fragment. Without a redirect the page simply says "You are signed in". The pages are plain HTML with one script and no third-party code, under a strict Content Security Policy; they cannot be framed and send no referrer.

## Your app's name and logo in mails and pages

Every mail your app's users get (sign-in links, confirmations, password resets, invitations) and every hosted page is titled with the **project's name**, not your site's name or domain: "Sign in to my-first-app", with the first letter in a square as the logo. A new project is called `my-first-app` or whatever you named it when you created it, so rename it to the name your users know before you invite anyone:

- In the console: **Settings** > **Project** > **General**, change the name, save.
- Or with the secret key: `PATCH /v1/project` `{ "title": "TripMates" }` (1 to 80 characters).

The name is shared by live and test and takes effect for the next mail. Only the title changes: the project id, the keys and the site's address stay the same. Mails come from dabadata's no-reply address; the name is in the subject, the heading and the footer ("Sent by dabadata on behalf of TripMates").

Add a **logo** and it replaces the letter: next to the name at the top of every mail, and above the name on the hosted pages. A square PNG or JPEG, 128 to 512 px, at most 256 KB, shared by live and test:

- In the console: **Settings** > **Project** > **General** > **Upload logo** (**Replace logo**, **Remove**).
- Or with the secret key: `PUT /v1/project/logo` `{ "contentType": "image/png", "data": "<base64>" }`; `DELETE /v1/project/logo` removes it.

The logo is served publicly at `/v1/logos/<project id>/<hash>` (the project's `logoUrl`); a new logo gets a new address, so mails already sent keep showing the logo they were sent with until it is replaced. Mail apps that block images until the reader allows them show the name's first letter instead. While the project is still called `my-first-app` the console says so on Overview, Authentication and Sites, and **Authentication** > **Sign-in methods** shows the mail's header as your users will see it.

## Redirect URLs and app mode

A link in a mail goes to one of two places:

| `redirectUrl` you send | The link in the mail | Then |
| --- | --- | --- |
| none, or a listed URL | The hosted page: `/auth/<env>/verify?token=...` | The person presses the button; the page signs them in and sends them to `redirectUrl` with the tokens, if there is one. |
| a listed URL plus `?mode=app` | Your page: `<listed URL>#token=...&type=verify` | Your page posts the token itself: `db.auth.completeLink()` does it. `type` is `verify`, `magic`, `reset` or `invite`. |

The URL without `mode=app` must be in `redirectUrls` exactly (the query compared in normal form), else the call is a 400 whose message lists the URLs that are allowed, e.g. `redirectUrl is not one of the project's auth.users.redirectUrls (listed: http://localhost:5173/)`. `http://localhost:5173/` and `http://127.0.0.1:5173/` are different URLs: list the one your dev server opens. The page's origin must also be in the environment's [allowed origins](https://dabadata.com/docs/projects/index.md#cors), or the browser blocks the call before you can read that message. The console's **Authentication - URL configuration - App URLs** sets both for an address in one step. A link answers the `redirectUrl` it was requested with only if that URL is still listed when it is used.

## The JavaScript client

Give `createClient` the environment's publishable key, and `db.auth` does the rest: it calls the routes, stores the tokens, refreshes them and reconnects your live subscriptions as the new user.

| Method | Does |
| --- | --- |
| `signUp({ email, password, name?, redirectUrl? })` | Creates the account. Resolves `{ user, verify, signupSecret }`: with `verify` a confirmation link is on its way and `user` is null; without, the person is signed in. |
| `signIn({ email, password })` | Signs in and resolves the user. |
| `signInWithLink({ email, redirectUrl? })` | Mails a 15-minute sign-in link. |
| `providers()`, `signInWithProvider(provider, { redirectUrl? })` | Which providers are on, and leaving the page to sign in with one ([Sign in with Google, Apple and more](https://dabadata.com/docs/sign-in-providers/index.md#client)). |
| `signInWithIdToken({ provider, idToken, nonce?, name? })` | Signs in with a Google or Apple ID token your app got itself ([native apps](https://dabadata.com/docs/sign-in-providers/index.md#native)). |
| `completeLink({ url?, password?, signupSecret? })` | Finishes whatever `#token=` the page URL carries: the hosted pages' redirect, or an app-mode link. Resolves the user, or null when there is no token. Reset and invite links need `password` (else it rejects with `code: "password_required"` and keeps the link). |
| `resetPassword({ email, redirectUrl? })` | Mails a 1-hour reset link. |
| `signOut({ everywhere? })` | Signs out this session (or all of the user's), forgets the tokens and reconnects as anonymous. |
| `user({ force? })` | The signed-in user, or null. Cached; `force` asks the server again. |
| `update({ name?, password?, currentPassword? })` | Changes the signed-in user's name and / or password and resolves the user. A new password needs `currentPassword` when there is one; it signs every other session out, and the client keeps the new token pair it gets back, so this page stays signed in. |
| `onAuthChange(cb)` | `cb(user, event)` on `"signIn"`, `"signOut"`, `"refresh"` and `"update"` (after `update()`). Returns the function that stops it. |
| `invite({ email, name?, role?, redirectUrl? })` | Invites someone as the signed-in user ([below](https://dabadata.com/docs/end-user-auth/index.md#invites)). |
| `invites()` | The signed-in user's open invites, newest first. |
| `cancelInvite(id)` | Cancels one of them. |
| `admin.listUsers({ query?, limit?, after? })` | For [app admins](https://dabadata.com/docs/end-user-auth/index.md#app-admins): resolves `{ users, next }`, newest first. |
| `admin.setRole(id, role)`, `admin.setName(id, name)` | Changes a user's role or name; resolves the user. |
| `admin.disable(id)`, `admin.enable(id)` | Disables a user (signed out everywhere) or lets them in again. |
| `admin.remove(id)` | Deletes a user. |

The session is kept in `localStorage` (option `storage: "local"`, the default) and picked up by the next page load, or only in memory with `storage: "memory"`. A refresh the server refuses ends the session and fires `signOut`. The [client reference](https://dabadata.com/docs/client/index.md#auth) has every option.

## A complete example

One HTML file: sign up, finish the mailed link, sign in, show who is signed in, sign out, and a task list that fills in once someone is signed in. It assumes the definition above on the test environment, with the page served at `http://localhost:5173/` (any static server will do).

```html
<!doctype html>
<meta charset="utf-8">
<title>Tasks</title>

<p id="who">Loading...</p>
<form id="signup">
  <input name="email" type="email" placeholder="Email" required>
  <input name="password" type="password" placeholder="Password, 10+ characters" minlength="10" required>
  <button>Sign up</button>
</form>
<form id="signin">
  <input name="email" type="email" placeholder="Email" required>
  <input name="password" type="password" placeholder="Password" required>
  <button>Sign in</button>
</form>
<button id="signout" hidden>Sign out</button>
<form id="add" hidden><input name="title" placeholder="New task" required> <button>Add</button></form>
<ul id="tasks"></ul>

<script src="https://dabadata.com/client/dist/dabadata.iife.js"></script>
<script>
  const db = dabadata.createClient({
    url: "https://dabadata.com",
    key: "pk_test_EXAMPLEexample0000000000", // your test publishable key
  });
  const $ = (id) => document.getElementById(id);

  function show(user) {
    $("who").textContent = user ? `Signed in as ${user.email} (${user.role})` : "Not signed in";
    $("signout").hidden = $("add").hidden = !user;
    $("signup").hidden = $("signin").hidden = !!user;
  }
  db.auth.onAuthChange((user) => show(user));

  // A mailed link (app mode) or the hosted pages brought us back with #token=...
  db.auth.completeLink()
    .then(() => db.auth.user())
    .then(show)
    .catch((err) => { alert(err.message); db.auth.user().then(show); });

  $("signup").onsubmit = async (e) => {
    e.preventDefault();
    const f = new FormData(e.target);
    try {
      const res = await db.auth.signUp({
        email: f.get("email"),
        password: f.get("password"),
        redirectUrl: location.origin + "/?mode=app", // the link comes straight back here
      });
      if (res.verify) alert("Check your mail and open the link in this browser.");
    } catch (err) { alert(err.message); }
  };

  $("signin").onsubmit = async (e) => {
    e.preventDefault();
    const f = new FormData(e.target);
    try {
      await db.auth.signIn({ email: f.get("email"), password: f.get("password") });
    } catch (err) {
      // invalid_login, email_unverified, user_disabled, rate_limited
      alert(err.code === "email_unverified" ? "Confirm your email first." : err.message);
    }
  };

  $("signout").onclick = () => db.auth.signOut();

  $("add").onsubmit = async (e) => {
    e.preventDefault();
    await db.insert("tasks", { title: new FormData(e.target).get("title") });
    e.target.reset();
  };

  // Live list. Signed out, the read rule ("authenticated") refuses it;
  // after a sign-in the client subscribes again as the user and the rows arrive.
  const tasks = new Map();
  const render = () => $("tasks").replaceChildren(
    ...[...tasks.values()].map((t) => Object.assign(document.createElement("li"), { textContent: t.title })));
  db.subscribe("tasks", {}, {
    onRows(rows) { tasks.clear(); for (const r of rows) tasks.set(r.id, r); render(); },
    onChange({ op, row }) { if (op === "delete") tasks.delete(row.id); else tasks.set(row.id, row); render(); },
    onError() { tasks.clear(); render(); },
  });
</script>
```

Open the page in two browsers with two accounts: a task added in one appears in the other. Each insert is in the trace with the user's id, their role `editor` and the issuer `hosted`.

## The sign-up secret

Anyone can sign up with any address and a password of their choosing - including an address that belongs to someone else. So the password chosen at sign-up only survives if the confirmation comes from where the sign-up was made:

- Every sign-up that waits for confirmation answers a `signupSecret` (and sets it as a cookie for the hosted pages). The client keeps it for 24 hours and `completeLink()` sends it with the confirmation link: the password and name stay.
- If the address is first proven any other way - the confirmation link opened in another browser, a sign-in link, a wrong secret - the user is still confirmed and signed in, but **without** the password and name from the sign-up. Whoever signed up may not own the address. The user that comes back has `hasPassword: false`; offer a "set a password" form and call `db.auth.update({ password })` (no `currentPassword` is needed while there is none), or let the person use "forgot password" later.
- If your app carries the secret itself (say, to a native app), pass it: `completeLink({ signupSecret })`.
- For the same reason the right password on an unconfirmed account answers `email_unverified` and mails nothing; the owner can always ask for a sign-in link.

## Invite people from your app

Your app's own signed-in users can invite others, with only the publishable key in the browser and no backend of yours. You decide who may invite, and which roles an invite may give, in `auth.users.invite`:

```json
"auth": {
  "users": {
    "enabled": true,
    "defaultRole": "viewer",
    "redirectUrls": ["http://localhost:5173/"],
    "invite": { "rule": { "role": ["admin", "editor"] }, "roles": ["viewer", "editor"] }
  }
}
```

- `rule` is a [rule](https://dabadata.com/docs/rules/index.md) like a table's (`"authenticated"`, `{ "role": [...] }`, `any`, `all`), checked against the inviter's current role. `owner` has no row to look at here and is refused. Without an `invite` block nobody invites from the app: 403 `invite_not_allowed`, the same as a rule that says no.
- `roles` are the roles an invite may give; the default is `defaultRole` alone. An invite without `role` gives `defaultRole`; any role not listed answers 403 `role_not_allowed`.

With someone signed in through `db.auth`, a SolidJS component that invites and lists the open invites:

```tsx
// db.ts: export const db = dabadata.createClient({ url: "https://dabadata.com", key: "pk_test_..." });
import { createResource, createSignal, For, Show } from "solid-js";
import { db } from "./db";

export function Invites() {
  const [invites, { refetch }] = createResource(() => db.auth.invites());
  const [email, setEmail] = createSignal("");
  const [error, setError] = createSignal("");

  async function send(e: SubmitEvent) {
    e.preventDefault();
    setError("");
    try {
      await db.auth.invite({
        email: email(),
        role: "viewer",
        redirectUrl: location.origin + "/?mode=app", // the link comes back to this app
      });
      setEmail("");
      refetch();
    } catch (err: any) {
      setError(err.message); // invite_not_allowed, role_not_allowed, rate_limited, validation
    }
  }

  return (
    <section>
      <form onSubmit={send}>
        <input type="email" required placeholder="Email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
        <button>Invite</button>
      </form>
      <Show when={error()}><p role="alert">{error()}</p></Show>
      <ul>
        <For each={invites()}>
          {(i) => (
            <li>
              {i.email} ({i.role}), open until {new Date(i.expiresAt).toLocaleDateString()}
              <button onClick={() => db.auth.cancelInvite(i.id).then(refetch)}>Cancel</button>
            </li>
          )}
        </For>
      </ul>
    </section>
  );
}
```

What the invited address gets:

- **A new address**: an account with the role and name you sent, no password and the address not yet confirmed, and a 7-day link by mail - "Dana invited you to Shop", with the inviter's name (or email). The link opens the hosted `invite` page, or with `?mode=app` your own page with `#token=...&type=invite`. Choosing a password confirms the address, signs the person in and sends them to `redirectUrl`.
- **An address invited before that never signed in**: the old link stops working and a new one is mailed. The account itself (role, name) stays as it was.
- **An address with an account**: nothing changes. The mail says they already have an account and links to the sign-in page, never with a token. A disabled account gets no mail.

The answer is `{ ok: true }` every time, so inviting does not tell anyone whether an address has an account. (The inviter's own list can: an address that already had one never shows up in it.) `invites()` holds the signed-in user's open invites - not used, not expired, the person has not signed in - newest first, and `cancelInvite(id)` drops the link and removes the account if the person never got in. Each user sees and cancels only their own.

On the page the link opens in app mode, finish it with a password:

```ts
// The page the invite link opens in app mode: #token=...&type=invite
const type = new URLSearchParams(location.hash.slice(1)).get("type");
if (type === "invite" || type === "reset") {
  // Show a "choose a password" form, then:
  await db.auth.completeLink({ password }); // address confirmed, signed in
} else {
  await db.auth.completeLink();
}
```

Limits per day: 20 invites per inviter, 3 per invited address and 200 per environment.

## App admins

An admin page in your app can list the users, change their roles and names, and disable or remove them, with nothing but the admin's own sign-in. Say who may, with a rule:

```json
"auth": {
  "users": {
    "enabled": true,
    "defaultRole": "member",
    "manage": { "role": "admin" },
    "manageRoles": ["member", "moderator"]
  }
}
```

- `manage` is a rule like a table's (`"authenticated"`, `{ "role": [...] }`, `any`, `all`), checked against the caller's current role. `owner` has no row to look at here, so it is an error.
- `manageRoles` (default: every declared role) are the roles a manager may give, and the roles of the users they may change, disable or remove. Above, an admin manages members and moderators but cannot touch another admin, nor make anyone an admin.
- A manager cannot change their own role, nor disable or remove themselves (403 `self_not_allowed`); their own name they may change.
- Everything else is as the secret-key routes of [Managing users](https://dabadata.com/docs/end-user-auth/index.md#managing): disabling signs the user out everywhere, a new role or name reaches their next call, and every change is in the workspace's [audit log](https://dabadata.com/docs/accounts/index.md#audit) with the admin as the actor.

```ts
// On the admin page, signed in as an admin.
const { users, next } = await db.auth.admin.listUsers({ query: "example.com" });
await db.auth.admin.setRole(users[0].id, "moderator");
await db.auth.admin.disable(users[1].id);   // signed out everywhere
await db.auth.admin.enable(users[1].id);
await db.auth.admin.remove(users[2].id);
```

The list holds each user's email, role, `disabled`, `lastSignInAt`, `hasPassword` and `providers`. Errors: 403 `manage_not_allowed` (no `manage`, or the rule refuses the caller), 403 `role_not_allowed`, 403 `self_not_allowed`, 404 for an unknown user.

## Profiles: the users table

A comment, an attendee list or a "created by" needs other people's names, but an account is private. `profiles` gives your rules a table to read instead:

```json
"users": {
  "enabled": true,
  "defaultRole": "member",
  "profiles": { "read": "authenticated", "fields": ["name", "role"] }
}
```

- The environment gets a read-only table `users`: one row per user, `id` (the user's id, the same value `userId` columns hold), `name`, `created_at`, and `role` only when `fields` lists it (default `["name"]`). Never the email.
- `read` is the table's read rule (a rule on the caller; `owner` is an error). Nobody writes it, not even the secret key: the server keeps it, as users sign up, are invited, renamed, given a role or deleted. Disabled and invited-but-not-yet-joined users have a row too.
- Read it like any table: `db.query("users")`, `GET /v1/db/users`, `db.subscribe("users")` for names that change live. A rename reaches every subscriber.
- With `profiles` on, no declared table may be called `users`. Turning it on fills the table from the existing users. A `users` table already in the database that dabadata did not make (an old declared table) stops the apply until you drop it; turning `profiles` off leaves the table behind as drift.

```ts
// Show the author's name next to each comment.
const names = new Map((await db.query("users")).map((u) => [u.id, u.name]));
for (const c of await db.query("comments")) console.log(names.get(c.author) ?? "someone", c.body);
```

## A row for every new user (onSignUp)

Many apps keep a row per member (a points balance, settings, a profile with more than a name). `onSignUp` makes dabadata insert it, with values the client cannot fake:

```json
"onSignUp": {
  "table": "members",
  "values": { "user": "$user.id", "email": "$user.email", "name": "$user.name",
              "joined": "$now", "points": 0 }
}
```

- It runs whenever a user is created: sign-up, a sign-in link that creates the account, an invite from the app, and a user you add (console or `POST /v1/project/users`).
- `values` are literals or one of `$user.id` (a `userId` or `text` column), `$user.email` (`text`), `$user.name` (`text`; the column must be `optional`, a user may have no name) and `$now` (`datetime` or `date`). Every required column without a default must be given; the definition is refused otherwise.
- The row is written as the server: the table's rules are not asked, so `create` can stay `"nobody"`. It goes in the same transaction as the account: if the row fails (a unique or check constraint), no account is made and the call answers 409 `on_signup_failed`.
- The values are the ones at creation; a later name change is not copied (use [profiles](https://dabadata.com/docs/end-user-auth/index.md#profiles) for names that follow). The insert is in the [trace](https://dabadata.com/docs/trace/index.md) with `values._onSignUp` = the new user's id.

## Managing users

In the console, **Users** lists the environment's accounts (viewers can read it; developers and up change it): search by email or name, add someone, change a role, disable, delete. With the secret key, the same under `/v1/project/users`:

```bash
# list, newest first; ?query= searches email and name, ?after= pages
curl "https://dabadata.com/v1/project/users?query=example.com&limit=50" \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY"

# add someone and mail them a 7-day link to choose a password
curl -X POST https://dabadata.com/v1/project/users \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"someone@example.com","name":"Someone","role":"viewer","invite":true}'

# change a role, or disable (signs them out everywhere)
curl -X PATCH https://dabadata.com/v1/project/users/usr_k3x9m2p7q4w8z1ab \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"disabled":true}'
```

- Each user in the list (and the answers of `PATCH`) carries `disabled`, `lastSignInAt`, `hasPassword` and `providers`: the sign-in providers linked to the user, such as `["google", "github"]`, in the order of the [provider table](https://dabadata.com/docs/sign-in-providers/index.md#providers).
- Users you add count as confirmed: the project vouches for the address. Give a `password`, or `invite: true` to mail a link to the hosted `invite` page. This is how people join when `signup` is `"invite"`. With `invite: true`, `redirectUrl` (one of yours; `?mode=app` sends the link straight to your page) is where the person lands once their password is set.
- Disabling revokes every token of the user and drops their unused links; they cannot sign in until you enable them again. Deleting removes the account and its tokens; rows they own stay in your tables.
- A new role or name reaches the user's next call and next refresh; a socket already open keeps the identity it opened with.
- Changes made in the console are in the workspace's [audit log](https://dabadata.com/docs/accounts/index.md#audit).

### Resending an invitation

Someone who was invited but never chose a password shows as `invited` in the console's **Users**. If their mail got lost or the 7-day link expired, press **Resend invite** (or [`POST /v1/project/users/:id/invite`](https://dabadata.com/docs/api/index.md#project-users-invite) with the secret key): a new link goes to the same account, so whatever your app linked to it stays, and the old link stops working. They can also type their address on your sign-in screen and use Forgot password, which works for an account with no password yet.

### Copy users to live

When an app built on **test** goes live, the people who used it there can come along. In the console, on the test environment's **Users**, tick the users (or none, for everyone) and press **Copy to live**. Each gets a live account with the same email, name and role, and live mails them an invitation to choose a password.

- Passwords are never copied, nor Google or other linked sign-ins: everyone chooses a new password from the mail.
- The test accounts stay, and so do the rows they own in test; nothing else moves to live.
- Skipped, with the reason shown: disabled users, addresses live already has, and roles the live definition does not declare. Apply your definition to live first.
- Console only (developers and up), at most 200 users at a time: [`POST /v1/project/users/to-live`](https://dabadata.com/docs/api/index.md#project-users-to-live).

## The test inbox

In a **test** environment, every mail your users would get - the confirmation, sign-in links, password resets and invitations - is also kept in the environment's test inbox for 7 days (the newest 200). Open it in the console under **Authentication - Test inbox**: each message with its link, whether the link is unused, used or expired, and buttons to open or copy it.

- **Stop sending test mail.** Turn off "Also send test mail to the real inboxes" (or set `"testMail": "inbox"` in `auth.users`): test sign-ups then need no real address, and nothing leaves dabadata. Live environments ignore the setting; their mail always goes out and is never kept.
- **Try the failure answers.** "Make it expired" and "Mark it used" change a mailed link on the spot, so your app's handling of `400 link_invalid` can be tested without waiting 24 hours. "Copy a broken link" gives the same link with one character of its token changed.

A script or a test suite can read it too, with the test environment's secret key:

```bash
curl "https://dabadata.com/v1/project/test-mail?to=someone@example.com&limit=1" \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"
# -> { "messages": [ { "id": "tm_...", "to": "someone@example.com", "subject": "Confirm your email for Pulse",
#      "purpose": "verify", "link": "https://dabadata.com/auth/prj_..._test/verify?token=...",
#      "linkState": { "state": "unused", "expiresAt": "...", "usedAt": null }, "createdAt": "..." } ],
#      "sending": false }

curl -X POST https://dabadata.com/v1/project/test-mail/tm_.../link \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{"state":"expired"}'

# After a test run: delete its messages only (or one: DELETE .../test-mail/tm_...)
curl -X DELETE "https://dabadata.com/v1/project/test-mail?to=someone@example.com" \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"
```

The messages hold working sign-in links, so only the secret key and console members with the developer role or above can read them. Details: [HTTP API: test inbox](https://dabadata.com/docs/api/index.md#test-mail).

## Rate limits and link lifetimes

| Route | Limit (per environment) |
| --- | --- |
| `signup` | 20 per IP address per hour |
| `magic` | 20 per IP address per hour |
| `signup` and `magic` together | 5 per email address per hour |
| `signin` (and the current-password check of `PATCH me`) | 10 per email address and 30 per IP address per 15 minutes, every attempt counted |
| `forgot` | 5 per email address per hour |
| `invite` | 20 per inviter, 3 per invited address and 200 in all, per day |

Over a limit: 429 `{ "error": "rate_limited", "retryAfter" }` with `Retry-After`. The routes also count toward the publishable key's [request rate](https://dabadata.com/docs/limits/index.md#rate). Links work once: a confirmation link for 24 hours, a sign-in link for 15 minutes, a reset link for an hour and an invitation for 7 days.

## Errors

| Status | `error` | When |
| --- | --- | --- |
| 400 | `validation` | A malformed address, a password that breaks the rules, a `redirectUrl` that is not listed. Every problem in `messages`. |
| 400 | `link_invalid` | The link's token is unknown, used or expired. |
| 401 | `invalid_login` | Wrong email or password (the same answer for an address with no account). |
| 401 | `unauthorized` | `me`, `signout`, an invite or `admin/` route without a live token of a hosted user of this environment; a wrong publishable key. |
| 403 | `email_unverified` | The right password, but the address is not confirmed yet. |
| 403 | `user_disabled` | The account is disabled. |
| 403 | `signup_closed` | Sign-up is `"invite"`. |
| 403 | `invalid_login` | `PATCH me`: the current password is wrong. |
| 403 | `invite_not_allowed` | The project has no `auth.users.invite`, or its rule refuses this user. |
| 403 | `role_not_allowed` | An invite asked for a role outside `auth.users.invite.roles`; an app admin gave a role, or touched a user whose role is, outside `auth.users.manageRoles`. |
| 403 | `manage_not_allowed` | The project has no `auth.users.manage`, or its rule refuses this user. |
| 403 | `self_not_allowed` | An app admin tried to change their own role, or disable or remove themselves. |
| 409 | `on_signup_failed` | The `onSignUp` row could not be inserted; the account was not created. |
| 404 | `not found` | Hosted accounts are not enabled in this environment; `DELETE invites/:id` for an invite that is not one of yours, or no longer open. |
| 429 | `rate_limited` | See above. |

## With identity providers

Hosted accounts are one more way in, next to the [others](https://dabadata.com/docs/identity/index.md#choosing): a provider's JWT, a token your backend mints, and anonymous. To dabadata a hosted user is simply a user whose `issuer` is `hosted`.

- **Pick hosted accounts** when your app has no sign-in yet. **Keep your provider** (Google, Clerk, Auth0, Firebase, Supabase, your own) when it already has one: declare it under `auth.issuers` and send its tokens as they are.
- **Social sign-in or a provider's tokens?** With [`providers`](https://dabadata.com/docs/sign-in-providers/index.md), people sign in with Google or GitHub and become hosted users that dabadata keeps. With `auth.issuers`, the provider keeps the accounts and dabadata only checks its tokens.
- **Both at once** is fine: `auth: { users, issuers }`. For example, staff sign in with your company's Google and customers with hosted accounts. Rules see a role either way; a rule never looks at the issuer.
- The issuer name `hosted` is reserved for hosted users, so no entry of `auth.issuers` may use it. One older name is reserved too (see [the note on the product's earlier name](https://dabadata.com/docs/client/index.md#migrating)).
- A hosted user's role is stored with the account (set by `defaultRole`, changed by you); a provider user's role comes from a claim in their token.
