Conversation
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>
There was a problem hiding this comment.
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
/aboutroute (src/routes/index.tsx,src/routes/about.tsx,src/components/Header.tsx) - Added new API routes (
DELETE /api/user/delete-accountreturning 500 Unimplemented,GET /api/internal/auth-urlswith 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.
| ctaUrl: string | ||
| }) { | ||
| if (!resend) { | ||
| return |
There was a problem hiding this comment.
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.
| 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.') |
| 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, |
There was a problem hiding this comment.
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.
| trustedOrigins, | ||
| emailVerification: { | ||
| sendOnSignUp: true, | ||
| sendOnSignIn: true, |
There was a problem hiding this comment.
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.
| sendOnSignIn: true, | |
| sendOnSignIn: false, |
| await resend.emails.send({ | ||
| from: process.env.RESEND_FROM_EMAIL ?? 'DJL Foundation <noreply@djl.foundation>', | ||
| to, | ||
| subject, | ||
| html, | ||
| }) |
There was a problem hiding this comment.
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.
| 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}`) | ||
| }) |
There was a problem hiding this comment.
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.
| - [ ] 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 |
There was a problem hiding this comment.
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.
| - [ ] 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 |
| username, | ||
| }) | ||
| .then((result) => { | ||
| setStatus(result.error ? result.error.message : 'Registrierung gestartet.') |
There was a problem hiding this comment.
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.
| setStatus(result.error ? result.error.message : 'Registrierung gestartet.') | |
| setStatus( | |
| result.error | |
| ? result.error.message ?? 'Registrierung fehlgeschlagen.' | |
| : 'Registrierung gestartet.', | |
| ) |
| 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.') |
There was a problem hiding this comment.
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.
| setStatus(result.error ? result.error.message : 'Account aktualisiert.') | |
| setStatus(result.error ? (result.error.message ?? 'Account konnte nicht aktualisiert werden.') : 'Account aktualisiert.') |
| color: 'inherit', | ||
| }} | ||
| aria-label={m.language_label()} | ||
| > |
There was a problem hiding this comment.
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>.
| <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> |
There was a problem hiding this comment.
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.
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
pg) support.Better Auth plugin enablement
twoFactororganizationadmin(list/edit/delete/impersonate endpoints consumed from UI)apiKeyopenAPIcaptcha(Turnstile when secret is present)haveIBeenPwnedlastLoginMethodjwtRoot route behavior + signed-in dashboard
/about) access gated when signed out.User + admin API surfaces
DELETE /api/user/delete-account→500 "Unimplemented"GET /api/internal/auth-urls(requires valid API key; verifies via Better Auth API key plugin)Email delivery + templates
i18n/docs/config touchpoints
de, withensupported).PLAN.md) per request.Focused contract test
src/routes/api/user/-delete-account.test.tsWarning
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/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vitest run(dns block)/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite build(dns block)/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/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vitest run(dns block)/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/id/id/node_modules/.bin/vite build(dns block)/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.