Authentication (Dashboard)

Dashboard login uses NextAuth v4 with the credentials provider and JWT sessions.

Files

  • src/lib/auth/options.ts — NextAuth config.
  • src/app/api/auth/[...nextauth]/route.ts — NextAuth catch-all.
  • src/app/api/auth/register/route.ts — signup.
  • middleware.ts — gate for /overview/*.
  • src/app/providers.tsx — client SessionProvider.
  • src/app/(auth)/login/page.tsx, register/page.tsx — UI.

Registration flow

POST /api/auth/register:

{ "name": "Jane", "email": "jane@acme.com", "password": "at-least-8" }

Creates:

  1. users row with bcrypt.hash(password, 12).
  2. organizations row — name "{name}'s Org", slug from email prefix + last 6 chars of orgId.
  3. org_members row with role: "owner".

All three inserts run sequentially — no transaction wrapper (improvement candidate).

Login flow

CredentialsProvider.authorize():

  • Look up user by email.
  • bcrypt.compare(password, user.passwordHash).
  • Return { id, email, name } on success, else null.

JWT callback

On first sign-in:

  • Set token.userId = user.id.
  • Look up user's first org_members.orgId (multi-org unsupported currently).
  • Set token.orgId.

Session callback

  • session.user.id = token.userId
  • session.user.orgId = token.orgId

Type augmentation in src/lib/auth/options.ts extends the NextAuth Session and JWT interfaces.

Dashboard gate

middleware.ts runs on /overview/:path*:

const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
if (!token) return redirect("/login?callbackUrl=...");

Dashboard layout.tsx double-checks with getServerSession and redirects if absent — defense in depth.

OAuth providers

Google and GitHub providers are conditionally registered when their env pairs are set:

  • GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
  • GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET

First-time OAuth sign-in calls findOrCreateUser(email, name):

  • If the email exists → return the existing user id (OAuth links to the existing account).
  • Else create user + organization + org_member (owner) in a single transaction and mark email verified.

2FA

Credentials provider accepts an optional totp field. If the user has two_factor_secrets.enabled = true:

  • Missing totp → throw new Error("2FA_REQUIRED") — client should re-prompt.
  • TOTP code (validates via verifyTotp()) OR one of the backup codes unlocks sign-in.
  • Used backup codes are filtered out of the stored array on successful login.

See two-factor-auth.md for setup flow.

Email verification & password reset

See email-verification.md and password-reset.md.

Multi-org

See team.md. Active org is tracked by cookie (sendoka_active_org) — no re-login needed to switch.

Remaining gaps

  • JWT still captures first orgId at sign-in — server components using session.user.orgId directly see that default, not the cookie-overridden active org. Migrate to getActiveOrgId() helper as needed.
  • No session invalidation on password reset.
  • No WebAuthn / passkeys.