# Submitter accounts

> Let submitters sign in with an emailed link or Google, tie each listing to an account, and give them an /account/ page that tracks their submissions.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/accounts/

---

With accounts on, submitting means being signed in: a visitor gets a one-time link by email, or
continues with Google, and `/account/` lists everything they submitted with its review status.
`features.accounts: true` in `site.config.ts` turns it on; Google is optional on top.

> **Free edition**
>
> The free edition has no Worker and no D1, so it ships without `/login/`, `/account/` and
> `/auth/`.

## Setup

1. Switch accounts on, next to submissions.

   ```ts title="site.config.ts"
   features: { submissions: true, accounts: true },
   ```

   ```sh title="Terminal"
   pnpm check:config
   ```

   `site.config.ts OK — <your site name> (https://your-domain.com)`.

2. Apply the migration that adds `users`, `sessions`, `login_tokens` and `listings.user_id`.

   ```sh title="Terminal"
   pnpm dev
   ```

   Locally, `pnpm dev` applies it before the dev server starts and prints a `dev: applied …`
   line. In production: `pnpm db:migrate:remote`, or `pnpm cf:setup`, which runs it.

3. Deploy, then open `/submit/` signed out.

   ```sh title="Terminal"
   pnpm deploy
   ```

   The browser is sent to `/login/?next=%2Fsubmit%2F`, and the header now carries an
   "Account" link. Sign-in by emailed link is complete at this point; it sends through whatever
   `providers.email` is — [Email](https://zerodirs.com/docs/submissions/email/).

4. Google, optionally: create the OAuth client.

   Google Cloud console → APIs & Services → OAuth consent screen. The site asks for the
   `openid`, `email` and `profile` scopes; a consent screen left in Testing admits only the
   testers listed on it. Then Credentials → Create credentials → OAuth client ID → Web
   application, with the authorised redirect URI:

   ```text
   https://<your domain>/auth/google/callback/
   ```

   Keep the trailing slash: the callback lives at that exact path, and the slash-less spelling is
   a redirect. Copy the client id (it ends in `.apps.googleusercontent.com`) and the secret (it
   begins with `GOCSPX-`).

5. Put both on the Worker.

   ```sh title="Terminal"
   pnpm cf:secrets GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET
   ```

   One prompt each, nothing echoed. A swapped pair is refused before upload: "this is the client
   id — it belongs in GOOGLE_CLIENT_ID; the secret begins with GOCSPX-". `/login/` now shows
   "Continue with Google".

## Verify

| What to check | Where | You should see |
| --- | --- | --- |
| The gate | `/account/` or `/submit/`, signed out | a 302 to `/login/?next=…` with the page you came from |
| The sign-in page | `/login/` | 200, `Cache-Control: no-store`, an email field; "Continue with Google" once both secrets are set |
| A link | ask for one, then `email_outbox` | a `login-link` row whose payload `url` is `/auth/callback/?token=…`; opening it lands on the `next` page or `/account/` |
| Single use | open the same link again | 410 and "This link no longer works" |
| Google | `/auth/google/` | a 303 to Google's consent screen, then back through `/auth/google/callback/` to `/account/` |
| A submission | `/submit/`, signed in | "Submitting as" your address; the row's `user_id` is set; `/account/` lists it with a Details link |
| Sign out | the button on `/account/` | you land on `/login/`, and `/account/` gates again |

## How it works

![The demo's sign-in page: an email field and the "Email me a sign-in link" button; "Continue with Google" appears once the two secrets are set](../../../assets/screenshots/login.png)

**The link.** `/login/` posts an address to `auth.requestLink`. The server stores the SHA-256
of a random token in `login_tokens` with the return path, queues a `login-link` mail carrying
`/auth/callback/?token=…`, and flushes the outbox at once so the link arrives now rather than at
the next hourly tick. The link works for twenty minutes and once: the row is claimed before the
session is created, so two clicks at the same moment sign in once. One address may ask for five
links an hour; the page says "Check your inbox" whatever happened, so the form cannot reveal
which addresses exist. A used, expired or forged link gets "This link no longer works" with a
410.

**The session.** Signing in writes a `sessions` row and a `zd_session` cookie — `HttpOnly`,
`SameSite=Lax`, `Secure` on HTTPS — that lasts 30 days. The cookie carries a random value and
the row its SHA-256, so a copy of the database cannot be turned into a cookie. It is read only
when the cookie is present; anonymous page views cost nothing. Sign out deletes the row and
expires the cookie. The hourly job (`sweepAuth`) deletes expired sessions and links.

**Google.** `/auth/google/` sets a `zd_oauth` cookie for ten minutes holding a random state
and the return path, then sends the browser to Google's consent screen with
`prompt=select_account`. The callback checks the state against the cookie, exchanges the code,
fetches the profile, and only accepts an address Google marks `email_verified`; anything else is
"Sign-in did not complete" with a 400. The session it starts is the same one a link starts, and
the `users` row is the same too — one row per address, whichever way it was first verified,
with the name and picture filled in when Google supplies them.

**The form and the account page.** With a session, the address on a submission comes from the
account — a hidden field, never typed — and the row records `user_id`. Receipts and status
pages are unchanged. `/account/` shows who is signed in, a Sign out button, and the latest
submissions: name (linked once live), plan, date, "payment not completed" where it applies, the
status as In review, Live or Not listed, and a Details link to the status page with a token
minted for that render, so no internal id is ever shown.

**Reserved paths.** `login`, `account` and `auth` cannot be used as a `routes.*Base`;
`robots.txt` disallows `/login/`, `/account/` and `/auth/`; every response on those paths
carries `X-Robots-Tag: noindex` and `Cache-Control: no-store`.

**Switching off.** `features.accounts: false` makes the pages answer 404 and the form ask for
an email again. The tables stay — migrations only add — and existing `user_id` values are kept.

## When it is off or degraded

| Configuration | What a visitor sees | What `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `features.accounts: false` | `/login/`, `/account/` and `/auth/callback/` answer 404; `/auth/google/` redirects to `/login/` | nothing | set it to `true` |
| one or both Google secrets missing | no "Continue with Google" button; `/auth/google/` answers a 303 to `/login/` | nothing — this pair is deliberately not a configuration gap | `pnpm cf:secrets GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET` |
| `providers.email: 'console'` | no link arrives; the log line names the recipient and subject but not the link — read `url` from the `login-link` row in `email_outbox` | warning: `providers.email is "console": submitters get no mail, it is only logged.` | [Email](https://zerodirs.com/docs/submissions/email/) |
| the Google address is not verified | "Sign-in did not complete" | nothing | sign in with the emailed link |
| more than five links an hour for one address | "Check your inbox", and nothing is sent | nothing | wait an hour |
| the migration is not applied | the pages fail with a `no such table` error | nothing | `pnpm db:migrate:local`, or `pnpm db:migrate:remote` |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.accounts` | `false` | the gate on `/submit/`, the pages under `/login/`, `/account/` and `/auth/`, the header link |
| `providers.email` | `'console'` | the provider that carries the sign-in link |
| `site.url` | — | the redirect URI is built from it, so it must be the domain the OAuth client knows |

Reference: [features](https://zerodirs.com/docs/configure/reference/#features), [providers](https://zerodirs.com/docs/configure/reference/#providers).
The two Google secrets are runtime secrets — [Environment and secrets](https://zerodirs.com/docs/deploy/environment/#runtime-secrets).

## Files

- site.config.ts `features.accounts`
- .dev.vars.example the two Google keys and where each comes from
- drizzle/0001_accounts.sql the migration
- src/
  - db/schema.ts `users`, `sessions`, `login_tokens`, `listings.user_id`
  - middleware.ts reads the session, gates `/account/`
  - server/auth/
    - index.ts links, sessions, the Google exchange, `sweepAuth`
    - cookies.ts `Set-Cookie` strings into `Astro.cookies`
  - actions/auth.ts `auth.requestLink` and `auth.logout`
  - pages/
    - login/index.astro
    - account/index.astro
    - auth/callback.astro where a link lands
    - auth/google/index.astro to Google
    - auth/google/callback.astro back from Google
  - templates/account/ LoginPage, AccountPage, LinkExpiredPage
  - server/email/templates/login-link.ts the mail
- tests/e2e/accounts.spec.ts the gate, one link, sign out, in a browser

## Related

- [The submission form](https://zerodirs.com/docs/submissions/form/) — What changes on /submit/ once the address comes from the account.
- [Email](https://zerodirs.com/docs/submissions/email/) — The provider that has to deliver the sign-in link.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/) — Where GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET live.
- [Recipe: configure accounts](https://zerodirs.com/docs/agents/recipes/configure-accounts/) — The same setup, written for a coding agent.
