Skip to content

Implement Better Auth as full IDP in TanStack Start (Postgres, plugins, admin/user APIs, gated root UX) - #1

Draft
JackatDJL with Copilot wants to merge 4 commits into
foundation-idfrom
copilot/add-authentication-implementation
Draft

Implement Better Auth as full IDP in TanStack Start (Postgres, plugins, admin/user APIs, gated root UX)#1
JackatDJL with Copilot wants to merge 4 commits into
foundation-idfrom
copilot/add-authentication-implementation

Conversation

Copilot AI commented Mar 4, 2026

Copy link
Copy Markdown

This PR turns the fresh TanStack Start app into a Better Auth-based identity provider for DJL Foundation, with root-route gating, multi-method auth, user/admin management surfaces, and plugin-backed platform capabilities. It wires the requested provider/plugin stack and exposes operational endpoints needed for IDP use.

  • Auth core + provider stack

    • Upgraded auth integration to Better Auth full-mode with PostgreSQL (pg) support.
    • Configured auth methods and provider hooks for:
      • email/password
      • username
      • passkeys
      • Google OAuth
      • GitHub OAuth
    • Added production-safe auth defaults (secure cookies in prod, required secret in prod, trusted origins/rate-limit/session config).
  • Better Auth plugin enablement

    • Enabled and wired:
      • twoFactor
      • organization
      • admin (list/edit/delete/impersonate endpoints consumed from UI)
      • apiKey
      • openAPI
      • captcha (Turnstile when secret is present)
      • haveIBeenPwned
      • lastLoginMethod
      • jwt
    • Extended auth client plugins to match server-side capabilities.
  • Root route behavior + signed-in dashboard

    • Replaced starter homepage with IDP-specific root UX:
      • signed out: root-only informational state + auth entry points
      • signed in: compact dashboard for account, plugin actions, and admin actions
    • Kept non-root route (/about) access gated when signed out.
  • User + admin API surfaces

    • Added user deletion API endpoint required by spec:
      • DELETE /api/user/delete-account500 "Unimplemented"
    • Added internal API-key-protected utility endpoint for key auth URLs:
      • GET /api/internal/auth-urls (requires valid API key; verifies via Better Auth API key plugin)
  • Email delivery + templates

    • Implemented verification/reset email handlers with React Email + Resend via a dedicated mail module.
  • i18n/docs/config touchpoints

    • Set German as default Paraglide locale (de, with en supported).
    • Updated env schema and README with required IDP variables.
    • Added a concrete plan file (PLAN.md) per request.
  • Focused contract test

    • Added route-level unit test for required delete behavior:
      • src/routes/api/user/-delete-account.test.ts
// DELETE /api/user/delete-account contract
export function createUnimplementedDeleteResponse() {
  return new Response('Unimplemented', {
    status: 500,
    headers: { 'content-type': 'text/plain; charset=utf-8' },
  })
}

Updated IDP root/dashboard UI

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • cdn.jsdelivr.net
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vitest run (dns block)
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite build (dns block)
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite dev --port 3000 --host 0.0.0.0 --port 3000 (dns block)
  • eu.posthog.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vitest run (dns block)
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite build (dns block)
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite dev --port 3000 --host 0.0.0.0 --port 3000 (dns block)

If you need me to access, download, or install something from one of these locations, you can either:


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 3 commits March 4, 2026 22:10
Co-authored-by: JackatDJL <71508487+JackatDJL@users.noreply.github.com>
Co-authored-by: JackatDJL <71508487+JackatDJL@users.noreply.github.com>
Co-authored-by: JackatDJL <71508487+JackatDJL@users.noreply.github.com>
Copilot AI changed the title [WIP] Add authentication feature using Better Auth Implement Better Auth as full IDP in TanStack Start (Postgres, plugins, admin/user APIs, gated root UX) Mar 4, 2026
@JackatDJL
JackatDJL requested a review from Copilot March 5, 2026 08:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR transforms a blank TanStack Start application into a Better Auth-backed Identity Provider (IDP) for DJL Foundation, replacing the starter homepage with auth-aware UX, wiring a full plugin stack (passkeys, API keys, 2FA, organization, admin, OpenAPI, captcha, etc.), adding PostgreSQL support, email delivery via Resend/React Email, and exposing user and admin API endpoints.

Changes:

  • Upgraded Better Auth to full IDP mode with PostgreSQL, plugin stack, social/email/passkey providers, and production-safe defaults (src/lib/auth.ts, src/lib/auth-client.ts, src/lib/auth-emails.tsx)
  • Replaced the root route homepage with auth-gated dashboard UX (signed-out landing + signed-in dashboard with account, plugin, and admin actions) and added auth gating to the /about route (src/routes/index.tsx, src/routes/about.tsx, src/components/Header.tsx)
  • Added new API routes (DELETE /api/user/delete-account returning 500 Unimplemented, GET /api/internal/auth-urls with API key auth), a contract test, and updated env schema and README

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
src/lib/auth.ts Full Better Auth server config with DB, plugins, social providers, email verification, and security settings
src/lib/auth-client.ts Auth client extended with plugin clients (passkey, apiKey, username, admin, organization, twoFactor)
src/lib/auth-emails.tsx React Email template + Resend integration for verification/reset emails
src/routes/index.tsx Root route replaced: signed-out landing page with auth forms; signed-in dashboard with account/plugin/admin panels
src/routes/about.tsx Added session check to gate the route for signed-in users only
src/routes/api/user/delete-account.ts New DELETE endpoint returning 500 Unimplemented
src/routes/api/user/-delete-account.test.ts Contract test for the unimplemented delete response factory
src/routes/api/internal/auth-urls.ts API-key-protected utility endpoint returning auth URL map
src/routeTree.gen.ts Auto-generated route tree updated with new routes
src/components/Header.tsx Header conditionally shows/hides "About" nav link based on session
src/integrations/better-auth/header-user.tsx Sign-in link target updated from /demo/better-auth to /, added type="button"
src/components/LocaleSwitcher.tsx Removed i18n-aware aria-label and message import; hardcoded German label
src/env.ts New env vars added to schema (auth, DB, OAuth, Turnstile, Resend)
project.inlang/settings.json Base locale changed to de, locales reordered
package.json Added pg, resend, @better-auth/api-key, @better-auth/passkey, @react-email/*; upgraded better-auth
README.md Added required IDP environment variables section
PLAN.md New plan file tracking implementation tasks (all still unchecked)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/auth-emails.tsx
ctaUrl: string
}) {
if (!resend) {
return

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When RESEND_API_KEY is not set, sendAuthEmail silently returns without sending any email. However, requireEmailVerification: true is enabled in emailAndPassword, meaning new users will be required to verify their email before signing in. Without a Resend API key configured, the verification email is never sent, so the user will be stuck in an unverifiable state with no indication of what went wrong. Consider logging a warning when the email is skipped, or throw an error in that case so the auth library can surface a meaningful failure to the caller.

Suggested change
return
console.warn(
'sendAuthEmail: RESEND_API_KEY is not set, cannot send authentication email. ' +
'Ensure RESEND_API_KEY is configured in the environment.',
)
throw new Error('Email service not configured: RESEND_API_KEY is missing.')

Copilot uses AI. Check for mistakes.
Comment thread src/lib/auth.ts
Comment on lines +19 to +85
const isProduction = process.env.NODE_ENV === 'production'
const secret = process.env.BETTER_AUTH_SECRET

if (isProduction && !secret) {
throw new Error('BETTER_AUTH_SECRET must be set in production')
}

const database = process.env.DATABASE_URL
? new Pool({
connectionString: process.env.DATABASE_URL,
ssl: isProduction ? { rejectUnauthorized: true } : undefined,
})
: undefined

const trustedOrigins = [process.env.BETTER_AUTH_URL, process.env.SERVER_URL].filter(
(origin): origin is string => Boolean(origin),
)

const socialProviders = {
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
}
: {}),
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
}

const plugins = [
tanstackStartCookies(),
username(),
passkey(),
twoFactor(),
organization(),
admin(),
apiKey({
defaultPrefix: 'djl_',
apiKeyHeaders: ['x-api-key'],
}),
openAPI({ path: '/openapi' }),
jwt(),
lastLoginMethod({ storeInDatabase: true }),
haveIBeenPwned(),
]

if (process.env.TURNSTILE_SECRET_KEY) {
plugins.push(
captcha({
provider: 'cloudflare-turnstile',
secretKey: process.env.TURNSTILE_SECRET_KEY,
}),
)
}

export const auth = betterAuth({
database,
secret,
baseURL: process.env.BETTER_AUTH_URL ?? process.env.SERVER_URL,

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env.ts schema defines and validates environment variables via @t3-oss/env-core, but src/lib/auth.ts reads all its environment variables directly via process.env instead of using the typed env object. This bypasses the schema validation and type safety provided by env.ts, meaning misconfigured variables (wrong type, missing required value) won't be caught at startup. Consider importing env from #/env and using it in auth.ts for all env lookups.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/auth.ts
trustedOrigins,
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sendOnSignIn: true setting in emailVerification causes a verification email to be sent every time a user signs in, not just on sign-up. Combined with requireEmailVerification: true, this means that if the email is re-sent on sign-in, previously-verified users may get unexpected verification emails each time they log in, and there's potential for the email sending to interfere with the sign-in flow. Check whether sendOnSignIn: true is actually intended here — typically this is used to re-verify emails after a password change, not on every sign-in.

Suggested change
sendOnSignIn: true,
sendOnSignIn: false,

Copilot uses AI. Check for mistakes.
Comment thread src/lib/auth-emails.tsx
Comment on lines +84 to +89
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL ?? 'DJL Foundation <noreply@djl.foundation>',
to,
subject,
html,
})

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sendAuthEmail function does not have any error handling around resend.emails.send(). If the Resend API call fails (network error, invalid key, rate limit, etc.), the promise will reject and propagate up into the Better Auth email handlers, which are called as await sendAuthEmail(...). This unhandled rejection will result in an uncaught server-side error during sign-up or password reset flows. Consider wrapping the send call in a try/catch and logging the error, or letting it propagate intentionally with a clear error message.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/index.tsx
Comment on lines +311 to +325
onClick={() => {
void fetch('/api/auth/admin/list-users')
.then((response) => response.json())
.then((users) => {
const response = users as AdminUsersResponse
const normalized = (response.users ?? []).map((user) => ({
id: user.id,
email: user.email,
}))
setAdminUsers(normalized)
if (normalized[0]?.id) {
setSelectedUserId(normalized[0].id)
}
setStatus(`Users geladen: ${normalized.length}`)
})

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch('/api/auth/admin/list-users') call has no error handling. If the request fails (network error, non-2xx response, non-JSON body), it will throw an unhandled promise rejection. A .catch() handler similar to the one on the delete-account call should be added so the user gets feedback rather than a silent failure.

Copilot uses AI. Check for mistakes.
Comment thread PLAN.md
Comment on lines +1 to +8
- [ ] Install and wire Better Auth dependencies for PostgreSQL, passkeys, API keys, and email delivery
- [ ] Extend Better Auth server config with required providers, plugins, and security defaults
- [ ] Add auth-aware root route behavior (public signed-out state and signed-in dashboard)
- [ ] Add user account management UI and deletion API that returns 500 "Unimplemented"
- [ ] Add admin user management UI (list, impersonate, edit, delete) backed by Better Auth endpoints
- [ ] Expose OpenAPI and key-protected utility endpoints
- [ ] Add/update environment variable definitions and docs for setup
- [ ] Run targeted checks, capture UI screenshot, then run code review and CodeQL

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All items in PLAN.md are still marked as unchecked (- [ ]) even though all planned work appears to be implemented in this PR. The plan file should reflect the completed state (- [x]) for the work that has been done, so it doesn't look like the tasks are still outstanding to future readers.

Suggested change
- [ ] Install and wire Better Auth dependencies for PostgreSQL, passkeys, API keys, and email delivery
- [ ] Extend Better Auth server config with required providers, plugins, and security defaults
- [ ] Add auth-aware root route behavior (public signed-out state and signed-in dashboard)
- [ ] Add user account management UI and deletion API that returns 500 "Unimplemented"
- [ ] Add admin user management UI (list, impersonate, edit, delete) backed by Better Auth endpoints
- [ ] Expose OpenAPI and key-protected utility endpoints
- [ ] Add/update environment variable definitions and docs for setup
- [ ] Run targeted checks, capture UI screenshot, then run code review and CodeQL
- [x] Install and wire Better Auth dependencies for PostgreSQL, passkeys, API keys, and email delivery
- [x] Extend Better Auth server config with required providers, plugins, and security defaults
- [x] Add auth-aware root route behavior (public signed-out state and signed-in dashboard)
- [x] Add user account management UI and deletion API that returns 500 "Unimplemented"
- [x] Add admin user management UI (list, impersonate, edit, delete) backed by Better Auth endpoints
- [x] Expose OpenAPI and key-protected utility endpoints
- [x] Add/update environment variable definitions and docs for setup
- [x] Run targeted checks, capture UI screenshot, then run code review and CodeQL

Copilot uses AI. Check for mistakes.
Comment thread src/routes/index.tsx
username,
})
.then((result) => {
setStatus(result.error ? result.error.message : 'Registrierung gestartet.')

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same result.error.message issue as line 79 — if the error object has no message property, this will render the literal string "undefined" as the status. Should use result.error.message ?? 'Registrierung fehlgeschlagen.' or the result?.error?.message ?? 'fallback' pattern used elsewhere.

Suggested change
setStatus(result.error ? result.error.message : 'Registrierung gestartet.')
setStatus(
result.error
? result.error.message ?? 'Registrierung fehlgeschlagen.'
: 'Registrierung gestartet.',
)

Copilot uses AI. Check for mistakes.
Comment thread src/routes/index.tsx
className="rounded-full bg-[var(--lagoon-deep)] px-4 py-2 text-white"
onClick={() => {
void authClient.updateUser({ name: displayName }).then((result) => {
setStatus(result.error ? result.error.message : 'Account aktualisiert.')

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same result.error.message issue — if the error object has no message property, the status will display "undefined". Should use a null-coalescing fallback consistent with the rest of the file.

Suggested change
setStatus(result.error ? result.error.message : 'Account aktualisiert.')
setStatus(result.error ? (result.error.message ?? 'Account konnte nicht aktualisiert werden.') : 'Account aktualisiert.')

Copilot uses AI. Check for mistakes.
color: 'inherit',
}}
aria-label={m.language_label()}
>

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LocaleSwitcher container <div> previously had aria-label={m.language_label()} for accessibility. That aria-label was removed in this change and replaced with a visible text span. While the visible "Sprache: {locale}" text may substitute for sighted users, the container element (which acts as a landmark/group) no longer has an accessible name for screen reader users who rely on labelled regions. Consider adding a static aria-label string or wrapping the buttons in a <fieldset> with a <legend>.

Copilot uses AI. Check for mistakes.
Comment thread src/routes/index.tsx
Comment on lines +292 to +371
<article className="island-shell rounded-2xl p-5">
<p className="island-kicker mb-2">Admin & API</p>
{adminUsers.length > 0 ? (
<select
className="mb-2 w-full rounded-lg border border-[var(--line)] bg-white/80 px-3 py-2"
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
>
{adminUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.email}
</option>
))}
</select>
) : null}
<div className="space-y-2 text-sm">
<button
type="button"
className="rounded-full bg-[var(--lagoon)] px-4 py-2 text-[var(--sea-ink)]"
onClick={() => {
void fetch('/api/auth/admin/list-users')
.then((response) => response.json())
.then((users) => {
const response = users as AdminUsersResponse
const normalized = (response.users ?? []).map((user) => ({
id: user.id,
email: user.email,
}))
setAdminUsers(normalized)
if (normalized[0]?.id) {
setSelectedUserId(normalized[0].id)
}
setStatus(`Users geladen: ${normalized.length}`)
})
}}
>
Users listen
</button>
<button
type="button"
className="rounded-full bg-[var(--lagoon)] px-4 py-2 text-[var(--sea-ink)]"
onClick={() => {
void fetch('/api/auth/admin/impersonate-user', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId: selectedUserId || session.user.id }),
}).then(() => setStatus('Impersonation-Request gesendet.'))
}}
>
Impersonate
</button>
<button
type="button"
className="rounded-full bg-[var(--lagoon)] px-4 py-2 text-[var(--sea-ink)]"
onClick={() => {
void fetch('/api/auth/admin/update-user', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userId: selectedUserId || session.user.id,
data: { name: displayName },
}),
}).then(() => setStatus('Admin update ausgelöst.'))
}}
>
Admin Edit
</button>
<button
type="button"
className="rounded-full border border-red-600 px-4 py-2 text-red-600"
onClick={() => {
void fetch('/api/auth/admin/remove-user', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId: selectedUserId || session.user.id }),
}).then(() => setStatus('Admin delete ausgelöst.'))
}}
>
Admin Delete
</button>

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Admin Delete" and "Impersonate" buttons in the dashboard are visible to any authenticated user, not only admins. A non-admin calling /api/auth/admin/remove-user or /api/auth/admin/impersonate-user will get an authorization error from the server, but these dangerous-looking buttons should ideally be hidden from non-admin users on the client side to avoid confusion. Consider checking session.user.role (or similar admin flag) before rendering these controls.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants