Skip to content

Submitter accounts

Markdown

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.

  1. Switch accounts on, next to submissions.

    site.config.ts
    features: { submissions: true, accounts: true },
    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.

    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.

    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.

  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:

    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.

    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”.

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

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

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.

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
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
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, providers. The two Google secrets are runtime secrets — Environment and secrets.

  • site.config.ts features.accounts
  • .dev.vars.example the two Google keys and where each comes from
  • drizzle/0001_accounts.sql the migration
  • Directorysrc/
    • db/schema.ts users, sessions, login_tokens, listings.user_id
    • middleware.ts reads the session, gates /account/
    • Directoryserver/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
    • Directorypages/
      • 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
    • Directorytemplates/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