From 420eb2ba66748f65c81d07b4983d33b601fd3fe1 Mon Sep 17 00:00:00 2001
From: initstring <26131150+initstring@users.noreply.github.com>
Date: Sat, 17 Jan 2026 14:33:00 +1100
Subject: [PATCH 1/3] Simplify auth to SSO with demo mode
---
.env.example | 6 +-
AGENTS.md | 2 +-
README.md | 2 +-
deploy/docker/.env.example | 5 +-
deploy/docker/docker-compose.yml | 2 +-
docs/dev/DESIGN.md | 2 +-
docs/development.md | 5 +-
docs/installation.md | 27 ++---
package-lock.json | 48 ++++++--
package.json | 3 -
scripts/generate-admin-login.ts | 48 --------
src/app/(protected-routes)/account/page.tsx | 49 ++------
src/app/(public-routes)/auth/signin/page.tsx | 7 +-
src/env.ts | 6 +-
.../settings/components/users-tab.tsx | 112 ++----------------
src/features/shared/auth/sign-in-page.tsx | 41 +++----
src/features/shared/users/user-validators.ts | 13 +-
src/server/api/routers/users.ts | 49 ++------
src/server/auth/config.ts | 59 ++++-----
src/server/auth/constants.ts | 1 -
src/server/auth/login-link.ts | 68 -----------
src/server/services/userService.ts | 2 -
src/test/auth-signin-callback.test.ts | 16 +--
src/test/users-create.test.ts | 21 +---
src/test/users-read.test.ts | 11 +-
src/test/users-security.test.ts | 46 -------
src/test/users-update-delete.test.ts | 3 -
27 files changed, 151 insertions(+), 503 deletions(-)
delete mode 100644 scripts/generate-admin-login.ts
delete mode 100644 src/server/auth/constants.ts
delete mode 100644 src/server/auth/login-link.ts
delete mode 100644 src/test/users-security.test.ts
diff --git a/.env.example b/.env.example
index bd78ca8..e708a3b 100644
--- a/.env.example
+++ b/.env.example
@@ -20,9 +20,9 @@ AUTH_TRUST_HOST=true
#GOOGLE_CLIENT_ID=
#GOOGLE_CLIENT_SECRET=
-# Optional: enable passkey auth (default: enabled)
-# Set to "true" to allow passkey login and enrollment.
-AUTH_PASSKEYS_ENABLED=true
+# Optional: enable demo login (default: disabled)
+# Set to "true" to expose a demo admin login button on the sign-in screen.
+ENABLE_DEMO_MODE=false
# Optional: logging level (default: debug in dev, info in prod)
#LOG_LEVEL=info
diff --git a/AGENTS.md b/AGENTS.md
index cb4482e..5aaa941 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,7 +72,7 @@ Use `eslint-plugin-boundaries` and `no-restricted-imports` to discourage cross
- Do not duplicate auth checks in child layouts/pages under the group. Rely on the group layout for auth.
- Keep `src/app/(protected-routes)/settings/layout.tsx` for the admin-only rule; it should only enforce `session.user.role === ADMIN` (assumes auth already passed).
- Keep public auth at `src/app/(public-routes)/auth/signin/**`.
- - Passkey enrollment happens from the account page after first login via a one-time link.
+ - Demo mode login is optional; when enabled it should expose a single button for the initial admin on the sign-in page.
- The homepage `/` is under the protected group and does not need page-level `auth()`.
## API Architecture
diff --git a/README.md b/README.md
index 621dcb7..447ea4b 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Initially based on the T3 Stack - Next.js, tRPC, Prisma, TypeScript. Type-safe A
Local development runs the Next.js dev server against a local PostgreSQL container. Production workloads also use Docker (web + Postgres) behind your own reverse proxy.
-Authentication is all passwordless using NextAuth - with an option for passkeys and/or OAuth providers (initial support includes Google SSO).
+Authentication is passwordless and SSO-first using NextAuth (currently Google OAuth). For development and trials you can enable a demo admin sign-in button via `ENABLE_DEMO_MODE`.
## Licensing
diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example
index 3226292..56546c1 100644
--- a/deploy/docker/.env.example
+++ b/deploy/docker/.env.example
@@ -1,7 +1,6 @@
# Full base URL where the app will be reachable (include http:// or https://).
# Cookies are sent over HTTPS only when this starts with https://.
# If you put a TLS-terminating proxy in front of rtap-web, use its public URL.
-# Important: Passkey auth works only over HTTPS or on localhost
RTAP_AUTH_URL=http://localhost:3000
# Secure values: generate with `openssl rand -base64 32`
@@ -9,8 +8,8 @@ RTAP_AUTH_SECRET=REPLACE_WITH_A_SECURE_RANDOM_VALUE
RTAP_INITIAL_ADMIN_EMAIL=admin@example.com
# Optional SSO
-# Toggle passkey provider (default enabled)
-RTAP_AUTH_PASSKEYS_ENABLED=true
+# Demo mode (default disabled): expose a demo admin login button
+RTAP_ENABLE_DEMO_MODE=false
# Register Google provider when present (optional)
#RTAP_GOOGLE_CLIENT_ID=
#RTAP_GOOGLE_CLIENT_SECRET=
diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml
index 280477a..56171f0 100644
--- a/deploy/docker/docker-compose.yml
+++ b/deploy/docker/docker-compose.yml
@@ -22,7 +22,7 @@ services:
AUTH_URL: ${RTAP_AUTH_URL}
AUTH_SECRET: ${RTAP_AUTH_SECRET}
INITIAL_ADMIN_EMAIL: ${RTAP_INITIAL_ADMIN_EMAIL}
- AUTH_PASSKEYS_ENABLED: ${RTAP_AUTH_PASSKEYS_ENABLED}
+ ENABLE_DEMO_MODE: ${RTAP_ENABLE_DEMO_MODE}
GOOGLE_CLIENT_ID: ${RTAP_GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${RTAP_GOOGLE_CLIENT_SECRET}
ports:
diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md
index b645370..f6a4419 100644
--- a/docs/dev/DESIGN.md
+++ b/docs/dev/DESIGN.md
@@ -18,7 +18,7 @@ Plan and execute red‑team operations and measure defensive effectiveness (dete
- Next.js 15 (App Router) + TypeScript
- tRPC v11 (Zod validation); Prisma targeting PostgreSQL (local dev uses a Docker container, production uses managed Postgres)
-- NextAuth (passkey-first, with optional OAuth)
+- NextAuth (SSO-first, with optional demo admin login in development)
- Access helpers enforce scoping and rights: `getAccessibleOperationFilter`, `checkOperationAccess`.
### Conventions (where things live)
diff --git a/docs/development.md b/docs/development.md
index d1b5a40..c97a84a 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -17,8 +17,7 @@ docker compose -f deploy/docker/docker-compose.dev.yml up -d
# Apply migrations and seed first-run admin + MITRE content
npm run init
-# If not using SSO, generate a one-time login URL to enroll your first passkey
-npm run generate-admin-login
+# Optional: enable demo admin login (set ENABLE_DEMO_MODE=true in .env)
# Optionally seed demo taxonomy/operation data (FOR DEMO PURPOSES ONLY)
npm run seed:demo
@@ -43,4 +42,4 @@ All PRs should pass the following:
npm run check
npm run test
npm run build
-```
\ No newline at end of file
+```
diff --git a/docs/installation.md b/docs/installation.md
index 9c55ced..d725290 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -17,36 +17,25 @@ docker compose up -d
# Optionally - seed demo taxonomy/operation data (FOR DEMO PURPOSES ONLY)
docker exec rtap-web npm run seed:demo
-# If not using SSO, generate 1-time login URL to set up your first passkey
-docker exec rtap-web npm run generate-admin-login
+# Optional: enable demo admin login for trials (see Authentication below)
```
## Authentication
### How it Works
-Let's be the change we want to see in the world. There is no support for passwords! Currently supported options are:
-
-- Passkeys (required TLS or localhost)
-- Google OAuth (SSO)
-
-The platform uses NextAuth, so adding additional SSO providers would be pretty easy.
+Let's be the change we want to see in the world. There is no support for passwords! Authentication is SSO-first (Google OAuth today), with an optional demo-mode button for trials.
**Admin bootstrap:**
- On first run, the application creates an admin account using `INITIAL_ADMIN_EMAIL` from your `.env`.
-- If using Google SSO, just sign in with the matching Google account.
-- If using passkeys, you must generate a one-time login URL (`npm run generate-admin-login`) and register a passkey for that account.
+- If using Google SSO, sign in with the matching Google account.
+- If using demo mode, click "Sign in as Demo Admin" (requires `ENABLE_DEMO_MODE=true`).
**Ongoing user management:**
- Once logged in as admin, you can create additional users.
-- Google SSO users: just log in with the matching Google email.
-- Passkey users: must receive a one-time login URL from the admin, then register a passkey.
-
-**Recovery:**
-
-- If locked out, re-run `npm run generate-admin-login` to obtain another single-use login URL for the initial admin account.
+- Google SSO users: log in with the matching Google email.
Accounts must be created inside the platform; SSO logins for unknown emails will be rejected.
@@ -55,10 +44,10 @@ Accounts must be created inside the platform; SSO logins for unknown emails will
Authentication options are configured in your `.env` file. The names are slightly different depending on whether you are doing local development or docker compose - the correct values are provided in the appropriate `.env-example` files.
```
-# Enable or disable passkey authentication
-AUTH_PASSKEYS_ENABLED=true
+# Demo mode: expose a demo admin login button on the sign-in page
+ENABLE_DEMO_MODE=false
-# Configuring the follow values will enable Google SSO
+# Configuring the following values will enable Google SSO
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```
diff --git a/package-lock.json b/package-lock.json
index cc227d4..3e6a11d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,8 +16,6 @@
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.19.0",
"@radix-ui/react-select": "^2.2.6",
- "@simplewebauthn/browser": "^9.0.1",
- "@simplewebauthn/server": "^9.0.3",
"@t3-oss/env-nextjs": "^0.13.0",
"@tanstack/react-query": "^5.90.11",
"@trpc/client": "^11.7.2",
@@ -1392,7 +1390,9 @@
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
@@ -1992,7 +1992,9 @@
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
@@ -2224,6 +2226,8 @@
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz",
"integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.5.0",
"asn1js": "^3.0.6",
@@ -2235,6 +2239,8 @@
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz",
"integrity": "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.5.0",
"@peculiar/asn1-x509": "^2.5.0",
@@ -2247,6 +2253,8 @@
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz",
"integrity": "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.5.0",
"@peculiar/asn1-x509": "^2.5.0",
@@ -2259,6 +2267,8 @@
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz",
"integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
@@ -2270,6 +2280,8 @@
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz",
"integrity": "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.5.0",
"asn1js": "^3.0.6",
@@ -3196,6 +3208,8 @@
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@simplewebauthn/types": "^9.0.1"
}
@@ -3205,6 +3219,8 @@
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz",
"integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
@@ -3225,7 +3241,9 @@
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
@@ -5006,6 +5024,8 @@
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
"license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true,
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
@@ -5469,6 +5489,8 @@
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"node-fetch": "^2.7.0"
}
@@ -8828,6 +8850,8 @@
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@@ -8854,19 +8878,25 @@
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause"
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
@@ -9591,6 +9621,8 @@
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.8.1"
}
@@ -9600,6 +9632,8 @@
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=6.0.0"
}
diff --git a/package.json b/package.json
index 5232b61..1d99de6 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,6 @@
"build": "next build",
"check": "eslint . && tsc --project tsconfig.check.json --noEmit",
"init": "tsx scripts/init.ts",
- "generate-admin-login": "tsx scripts/generate-admin-login.ts",
"db:migrate": "prisma migrate dev --schema prisma/schema.prisma",
"db:deploy": "prisma migrate deploy --schema prisma/schema.prisma",
"db:reset": "prisma migrate reset --force --skip-generate --skip-seed --schema prisma/schema.prisma && npm run init",
@@ -38,8 +37,6 @@
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.19.0",
"@radix-ui/react-select": "^2.2.6",
- "@simplewebauthn/browser": "^9.0.1",
- "@simplewebauthn/server": "^9.0.3",
"@t3-oss/env-nextjs": "^0.13.0",
"@tanstack/react-query": "^5.90.11",
"@trpc/client": "^11.7.2",
diff --git a/scripts/generate-admin-login.ts b/scripts/generate-admin-login.ts
deleted file mode 100644
index a9bd4e4..0000000
--- a/scripts/generate-admin-login.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Manual utility to generate a one-time login link for the initial admin user.
- *
- * - Requires DATABASE_URL and AUTH_SECRET to be configured (same as init script)
- * - Looks up the user defined by INITIAL_ADMIN_EMAIL (defaults to admin@example.com)
- * - Prints the generated login URL and expiry to stdout without emitting info-level logs
- */
-import 'dotenv/config';
-
-import { PrismaClient } from '@prisma/client';
-
-async function main() {
- const requiredEnv = ['AUTH_SECRET', 'DATABASE_URL'];
- const missing = requiredEnv.filter((key) => !process.env[key] || String(process.env[key]).trim() === '');
-
- if (missing.length > 0) {
- throw new Error(`Missing required env vars: ${missing.join(', ')}`);
- }
-
- const initialEmail = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase() ?? 'admin@example.com';
-
- const db = new PrismaClient({ log: ['error'] });
- try {
- const adminUser = await db.user.findUnique({ where: { email: initialEmail } });
- if (!adminUser) {
- throw new Error(
- `No user found for ${initialEmail}. Run \`npm run init\` first to provision the initial admin account.`,
- );
- }
-
- const { createLoginLink } = await import('@server/auth/login-link');
- const { url, expires } = await createLoginLink(db, {
- email: initialEmail,
- baseUrl: process.env.AUTH_URL,
- });
-
- console.log('Generated a one-time login link for the initial admin user.');
- console.log(url);
- console.log(`Expires at ${expires.toISOString()}`);
- } finally {
- await db.$disconnect();
- }
-}
-
-void main().catch((error) => {
- console.error('[generate-admin-login] Failed to generate admin login link:', error);
- process.exit(1);
-});
diff --git a/src/app/(protected-routes)/account/page.tsx b/src/app/(protected-routes)/account/page.tsx
index 39e77c9..e6d6d90 100644
--- a/src/app/(protected-routes)/account/page.tsx
+++ b/src/app/(protected-routes)/account/page.tsx
@@ -1,12 +1,10 @@
"use client";
-import { useState } from "react";
-import { signIn as registerPasskey } from "next-auth/webauthn";
import { api } from "@/trpc/react";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui";
-import { parseUserWithPasskey, type UserWithPasskey } from "@features/shared/users/user-validators";
+import { parseUserProfile, type UserProfile } from "@features/shared/users/user-validators";
-const renderLastLogin = (lastLogin: UserWithPasskey["lastLogin"]) => {
+const renderLastLogin = (lastLogin: UserProfile["lastLogin"]) => {
if (!lastLogin) return "Never";
if (lastLogin instanceof Date) {
@@ -25,27 +23,7 @@ const renderLastLogin = (lastLogin: UserWithPasskey["lastLogin"]) => {
export default function AccountPage() {
const { data: meData, refetch, isLoading } = api.users.me.useQuery();
- const me = parseUserWithPasskey(meData);
- const [status, setStatus] = useState<"idle" | "registering" | "success" | "error">("idle");
-
- const handleRegisterPasskey = async () => {
- setStatus("registering");
- try {
- const res = await registerPasskey("passkey", { action: "register", redirect: false });
- if (!res) {
- setStatus("error");
- return;
- }
- if (res.error) {
- setStatus("error");
- return;
- }
- setStatus("success");
- await refetch();
- } catch {
- setStatus("error");
- }
- };
+ const me = parseUserProfile(meData);
return (
@@ -72,29 +50,16 @@ export default function AccountPage() {
- Passkeys
+ Access
- {me?.passkeyCount && me.passkeyCount > 0
- ? `You have ${me.passkeyCount} passkey${me.passkeyCount === 1 ? "" : "s"} registered.`
- : "No passkeys registered yet."}
+ Access to RTAP is managed through your configured SSO provider.
-
diff --git a/src/app/(public-routes)/auth/signin/page.tsx b/src/app/(public-routes)/auth/signin/page.tsx
index 351fc3c..01b3ce1 100644
--- a/src/app/(public-routes)/auth/signin/page.tsx
+++ b/src/app/(public-routes)/auth/signin/page.tsx
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/server/auth";
+import { env } from "@/env";
import SignInPageClient from "@features/shared/auth/sign-in-page";
export default async function SignInPage(props: { searchParams?: Promise<{ callbackUrl?: string; error?: string }> }) {
@@ -9,13 +10,13 @@ export default async function SignInPage(props: { searchParams?: Promise<{ callb
}
const { callbackUrl = "/", error } = (await props.searchParams) ?? {};
- const passkeysEnabled = process.env.AUTH_PASSKEYS_ENABLED === "true";
- const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
+ const demoEnabled = env.ENABLE_DEMO_MODE === "true";
+ const googleEnabled = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
return (
diff --git a/src/env.ts b/src/env.ts
index 1a56196..8e5e357 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -20,8 +20,8 @@ export const env = createEnv({
// Logging: default to debug in dev, info in prod; override with LOG_LEVEL
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).optional(),
AUTH_URL: z.string().url().optional(),
- // Optional: toggle passkey provider (default disabled)
- AUTH_PASSKEYS_ENABLED: z.enum(["true", "false"]).optional(),
+ // Optional: demo-mode login button (default disabled)
+ ENABLE_DEMO_MODE: z.enum(["true", "false"]).optional(),
// Optional: Google OAuth client credentials (registers provider when present)
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
@@ -46,7 +46,7 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
LOG_LEVEL: process.env.LOG_LEVEL,
AUTH_URL: process.env.AUTH_URL,
- AUTH_PASSKEYS_ENABLED: process.env.AUTH_PASSKEYS_ENABLED,
+ ENABLE_DEMO_MODE: process.env.ENABLE_DEMO_MODE,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
},
diff --git a/src/features/settings/components/users-tab.tsx b/src/features/settings/components/users-tab.tsx
index 738ce82..b53fef3 100644
--- a/src/features/settings/components/users-tab.tsx
+++ b/src/features/settings/components/users-tab.tsx
@@ -1,48 +1,25 @@
"use client";
import { useState } from "react";
-import { z } from "zod";
import { api } from "@/trpc/react";
import { Button, Card, CardContent, Input, Label } from "@components/ui";
import ConfirmModal from "@components/ui/confirm-modal";
import SettingsHeader from "./settings-header";
import InlineActions from "@components/ui/inline-actions";
import { UserRole } from "@prisma/client";
-import { isUserRole, userWithPasskeySchema, type UserWithPasskey } from "@features/shared/users/user-validators";
+import { isUserRole, userProfileSchema, type UserProfile } from "@features/shared/users/user-validators";
-const EMPTY_USERS: UserWithPasskey[] = [];
-const loginLinkSchema = z.object({
- url: z.string(),
- expires: z.union([z.date(), z.string(), z.number()]),
-});
-const createUserResponseSchema = z.object({ user: userWithPasskeySchema, loginLink: loginLinkSchema });
-
-interface PendingLink {
- email: string;
- url: string;
- expires: string;
-}
-
-const toIsoString = (value: Date | string | number) => {
- if (typeof value === "number") {
- const parsed = new Date(value);
- return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
- }
-
- return value instanceof Date ? value.toISOString() : value;
-};
+const EMPTY_USERS: UserProfile[] = [];
export default function UsersTab() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
- const [editingUser, setEditingUser] = useState
(null);
- const [confirmDelete, setConfirmDelete] = useState(null);
- const [pendingLink, setPendingLink] = useState(null);
- const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">("idle");
+ const [editingUser, setEditingUser] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(null);
// Queries
const usersQuery = api.users.list.useQuery();
- const parsedUsers = userWithPasskeySchema.array().safeParse(usersQuery.data);
- let users: UserWithPasskey[] = EMPTY_USERS;
+ const parsedUsers = userProfileSchema.array().safeParse(usersQuery.data);
+ let users: UserProfile[] = EMPTY_USERS;
if (parsedUsers.success) {
users = parsedUsers.data;
}
@@ -52,17 +29,10 @@ export default function UsersTab() {
const utils = api.useUtils();
const createMutation = api.users.create.useMutation({
onSuccess: (data) => {
- const parsed = createUserResponseSchema.safeParse(data);
+ const parsed = userProfileSchema.safeParse(data);
void utils.users.invalidate();
- if (!parsed.success) {
- return;
- }
+ if (!parsed.success) return;
setIsCreateModalOpen(false);
- setPendingLink({
- email: parsed.data.user.email,
- url: parsed.data.loginLink.url,
- expires: toIsoString(parsed.data.loginLink.expires),
- });
},
});
@@ -80,21 +50,6 @@ export default function UsersTab() {
},
});
- const loginLinkMutation = api.users.issueLoginLink.useMutation({
- onSuccess: (data, variables) => {
- void utils.users.invalidate();
- const parsedLink = loginLinkSchema.safeParse(data);
- const user = users.find((u) => u.id === variables.id);
- if (parsedLink.success) {
- setPendingLink({
- email: user?.email ?? "",
- url: parsedLink.data.url,
- expires: toIsoString(parsedLink.data.expires),
- });
- }
- },
- });
-
const handleCreate = async (data: { name: string; email: string; role: UserRole }) => {
try {
await createMutation.mutateAsync(data);
@@ -111,22 +66,6 @@ export default function UsersTab() {
deleteMutation.mutate({ id });
};
- const handleIssueLink = (user: UserWithPasskey) => {
- loginLinkMutation.mutate({ id: user.id });
- };
-
- const copyLink = async () => {
- if (!pendingLink) return;
- try {
- await navigator.clipboard.writeText(pendingLink.url);
- setCopyStatus("copied");
- setTimeout(() => setCopyStatus("idle"), 1500);
- } catch {
- setCopyStatus("error");
- setTimeout(() => setCopyStatus("idle"), 1500);
- }
- };
-
const getRoleColor = (role: UserRole) => {
switch (role) {
case UserRole.ADMIN:
@@ -139,7 +78,7 @@ export default function UsersTab() {
}
};
- const renderLastLogin = (lastLogin: UserWithPasskey["lastLogin"]) => {
+ const renderLastLogin = (lastLogin: UserProfile["lastLogin"]) => {
if (!lastLogin) return "Never";
if (lastLogin instanceof Date) {
@@ -168,25 +107,6 @@ export default function UsersTab() {
setIsCreateModalOpen(true)} />
- {pendingLink && (
-
-
-
-
One-time login link for {pendingLink.email}
-
-
-
-
- {copyStatus === "copied" ? "Copied" : copyStatus === "error" ? "Copy failed" : "Copy link"}
-
-
- Expires at {new Date(pendingLink.expires).toLocaleString()}
-
-
-
-
- )}
-
{users.map((user) => (
@@ -203,21 +123,11 @@ export default function UsersTab() {
{user.email}
- Last login: {renderLastLogin(user.lastLogin)} • Passkeys {user.passkeyCount > 0 ? "Enrolled" : "Not enrolled"}
+ Last login: {renderLastLogin(user.lastLogin)}
setEditingUser(user)} onDelete={() => setConfirmDelete(user)} />
- handleIssueLink(user)}
- disabled={loginLinkMutation.isPending && loginLinkMutation.variables?.id === user.id}
- >
- {loginLinkMutation.isPending && loginLinkMutation.variables?.id === user.id
- ? "Generating..."
- : "Generate login link"}
-
@@ -271,7 +181,7 @@ type UserModalSubmitData = { name: string; email: string; role: UserRole };
interface UserModalProps {
title: string;
- initialData?: UserWithPasskey;
+ initialData?: UserProfile;
onSubmit: (data: UserModalSubmitData) => void;
onCancel: () => void;
isLoading: boolean;
diff --git a/src/features/shared/auth/sign-in-page.tsx b/src/features/shared/auth/sign-in-page.tsx
index a17b4a9..920718b 100644
--- a/src/features/shared/auth/sign-in-page.tsx
+++ b/src/features/shared/auth/sign-in-page.tsx
@@ -2,20 +2,19 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
-import { signIn as signInOauth } from "next-auth/react";
-import { signIn as signInPasskey } from "next-auth/webauthn";
+import { signIn } from "next-auth/react";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui";
interface Props {
- passkeysEnabled: boolean;
googleEnabled: boolean;
+ demoEnabled: boolean;
callbackUrl: string;
initialError?: string;
}
-export default function SignInPageClient({ passkeysEnabled, googleEnabled, callbackUrl, initialError }: Props) {
+export default function SignInPageClient({ googleEnabled, demoEnabled, callbackUrl, initialError }: Props) {
const router = useRouter();
- const [loading, setLoading] = useState<"passkey" | "google" | null>(null);
+ const [loading, setLoading] = useState<"demo" | "google" | null>(null);
const [error, setError] = useState(initialError ?? null);
const toMessage = (err?: string | null) => {
@@ -23,28 +22,24 @@ export default function SignInPageClient({ passkeysEnabled, googleEnabled, callb
switch (err) {
case "AccessDenied":
return "Access denied. Contact an administrator.";
- case "Verification":
- return "This login link has expired or was already used.";
+ case "CredentialsSignin":
+ return "Demo sign-in failed. Contact an administrator.";
default:
return "Sign-in failed. Please try again.";
}
};
- const handlePasskey = async () => {
- if (!passkeysEnabled) return;
- setLoading("passkey");
+ const handleDemo = async () => {
+ if (!demoEnabled) return;
+ setLoading("demo");
setError(null);
try {
- const res = await signInPasskey("passkey", { callbackUrl, redirect: false });
- if (!res) {
- setError("Passkey sign-in failed. Please try again.");
- } else if (res.error) {
+ const res = await signIn("demo", { callbackUrl, redirect: false });
+ if (res?.error) {
setError(toMessage(res.error));
- } else if (res.ok && res.url) {
+ } else if (res?.url) {
router.push(res.url);
}
- } catch {
- setError("Passkey sign-in failed. Please try again.");
} finally {
setLoading(null);
}
@@ -54,7 +49,7 @@ export default function SignInPageClient({ passkeysEnabled, googleEnabled, callb
setLoading("google");
setError(null);
try {
- const res = await signInOauth("google", { callbackUrl, redirect: false });
+ const res = await signIn("google", { callbackUrl, redirect: false });
if (res?.error) {
setError(toMessage(res.error));
} else if (res?.url) {
@@ -65,7 +60,7 @@ export default function SignInPageClient({ passkeysEnabled, googleEnabled, callb
}
};
- const nothingEnabled = !googleEnabled && !passkeysEnabled;
+ const nothingEnabled = !googleEnabled && !demoEnabled;
return (
@@ -84,18 +79,18 @@ export default function SignInPageClient({ passkeysEnabled, googleEnabled, callb
)}
- {passkeysEnabled && (
+ {demoEnabled && (
- {loading === "passkey" ? "Connecting…" : "Sign in with Passkey"}
+ {loading === "demo" ? "Signing in…" : "Sign in as Demo Admin"}
)}
- {googleEnabled && passkeysEnabled && (
+ {googleEnabled && demoEnabled && (
diff --git a/src/features/shared/users/user-validators.ts b/src/features/shared/users/user-validators.ts
index ffbe9fc..cb86afe 100644
--- a/src/features/shared/users/user-validators.ts
+++ b/src/features/shared/users/user-validators.ts
@@ -8,25 +8,24 @@ const lastLoginSchema = z
.nullable()
.optional();
-export const userWithPasskeySchema = z.object({
+export const userProfileSchema = z.object({
id: z.string(),
name: z.string().nullable().optional(),
email: z.string().email(),
role: userRoleSchema,
lastLogin: lastLoginSchema,
- passkeyCount: z.number().int().min(0),
});
-export type UserWithPasskey = z.infer;
+export type UserProfile = z.infer;
-const userListSchema = z.array(userWithPasskeySchema);
+const userListSchema = z.array(userProfileSchema);
-export function parseUserWithPasskey(value: unknown): UserWithPasskey | null {
- const result = userWithPasskeySchema.safeParse(value);
+export function parseUserProfile(value: unknown): UserProfile | null {
+ const result = userProfileSchema.safeParse(value);
return result.success ? result.data : null;
}
-export function parseUserWithPasskeyList(value: unknown): UserWithPasskey[] {
+export function parseUserProfileList(value: unknown): UserProfile[] {
const result = userListSchema.safeParse(value);
return result.success ? result.data : [];
}
diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts
index 05790f5..b9ea1f4 100644
--- a/src/server/api/routers/users.ts
+++ b/src/server/api/routers/users.ts
@@ -8,18 +8,12 @@ import {
} from "@/server/api/trpc";
import { createUser as createUserService, updateUser as updateUserService, defaultUserSelect } from "@/server/services/userService";
import { auditEvent, logger } from "@/server/logger";
-import { createLoginLink } from "@/server/auth/login-link";
-
-function mapUser(user: T) {
- const { _count, ...rest } = user;
- return { ...rest, passkeyCount: _count.authenticators };
-}
export const usersRouter = createTRPCRouter({
// List all users (Admin only)
list: adminProcedure.query(async ({ ctx }) => {
const users = await ctx.db.user.findMany({ select: defaultUserSelect(), orderBy: [{ role: "asc" }, { name: "asc" }] });
- return users.map(mapUser);
+ return users;
}),
// Get current user profile (any authenticated user)
@@ -32,11 +26,10 @@ export const usersRouter = createTRPCRouter({
email: true,
role: true,
lastLogin: true,
- _count: { select: { authenticators: true } },
},
});
if (!me) throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found" });
- return mapUser(me);
+ return me;
}),
// Create new user (Admin only)
@@ -48,17 +41,15 @@ export const usersRouter = createTRPCRouter({
}))
.mutation(async ({ ctx, input }) => {
const created = await createUserService(ctx.db, input);
- const loginLink = await createLoginLink(ctx.db, { email: created.email });
- const user = mapUser(created);
logger.info(
auditEvent(ctx, "sec.user.create", {
- targetUserId: user.id,
- targetEmail: user.email,
- targetName: user.name,
+ targetUserId: created.id,
+ targetEmail: created.email,
+ targetName: created.name,
}),
"User created",
);
- return { user, loginLink };
+ return created;
}),
// Update user (Admin only)
@@ -75,16 +66,15 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot remove admin role from your own account" });
}
const updated = await updateUserService(ctx.db, input);
- const user = mapUser(updated);
logger.info(
auditEvent(ctx, "sec.user.update", {
- targetUserId: user.id,
- targetEmail: user.email,
- targetName: user.name,
+ targetUserId: updated.id,
+ targetEmail: updated.email,
+ targetName: updated.name,
}),
"User updated",
);
- return user;
+ return updated;
}),
// Delete user (Admin only)
@@ -130,25 +120,6 @@ export const usersRouter = createTRPCRouter({
return deleted;
}),
- issueLoginLink: adminProcedure
- .input(z.object({ id: z.string() }))
- .mutation(async ({ ctx, input }) => {
- const user = await ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, email: true, name: true } });
- if (!user?.email) {
- throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- }
- const loginLink = await createLoginLink(ctx.db, { email: user.email });
- logger.info(
- auditEvent(ctx, "sec.user.login_link_issue", {
- targetUserId: user.id,
- targetEmail: user.email,
- targetName: user.name,
- }),
- "Admin issued user login link",
- );
- return loginLink;
- }),
-
// Get user statistics (Admin only)
stats: adminProcedure.query(async ({ ctx }) => {
const [totalUsers, adminCount, operatorCount, viewerCount] = await Promise.all([
diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts
index 214445d..0ba5745 100644
--- a/src/server/auth/config.ts
+++ b/src/server/auth/config.ts
@@ -2,8 +2,7 @@ import { type DefaultSession, type NextAuthConfig } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import type { JWT as NextAuthJWT } from "next-auth/jwt";
import GoogleProvider from "next-auth/providers/google";
-import Passkey from "next-auth/providers/passkey";
-import type { EmailConfig } from "next-auth/providers/email";
+import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { type UserRole } from "@prisma/client";
@@ -11,7 +10,6 @@ import { db } from "@/server/db";
import { auditEvent, logger } from "@/server/logger";
import { env } from "@/env";
import { headers } from "next/headers";
-import { LOGIN_LINK_PROVIDER_ID } from "./constants";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@@ -41,19 +39,7 @@ declare module "@auth/core/adapters" {
// Local extension for JWT to carry role information
type AugmentedJWT = NextAuthJWT & { role?: UserRole };
-const loginLinkProvider: EmailConfig = {
- id: LOGIN_LINK_PROVIDER_ID,
- type: "email",
- name: "One-time Link",
- maxAge: 60 * 60, // 1 hour
- async sendVerificationRequest() {
- // Login links are generated via scripts/admin tooling only.
- throw new Error("Login link generation is restricted to administrators.");
- },
- options: {},
-};
-
-const passkeysEnabled = env.AUTH_PASSKEYS_ENABLED === "true";
+const demoModeEnabled = env.ENABLE_DEMO_MODE === "true";
const isRecord = (value: unknown): value is Record =>
typeof value === "object" && value !== null;
@@ -147,7 +133,6 @@ async function resolveUserIdentity(user: {
export const authConfig = {
adapter: prismaAdapter,
useSecureCookies: env.AUTH_URL?.startsWith("https://") ?? false,
- experimental: passkeysEnabled ? { enableWebAuthn: true } : undefined,
// Route Auth.js logs through our Pino logger for concise, structured output
logger: {
error(error) {
@@ -175,8 +160,6 @@ export const authConfig = {
logger.error(payload, "Auth.js error");
},
warn(code) {
- if (code === "experimental-webauthn") return;
-
logger.warn({ event: "authjs.warn", code }, "Auth.js warn");
},
debug(message, metadata) {
@@ -188,8 +171,24 @@ export const authConfig = {
},
},
providers: [
- loginLinkProvider,
- ...(passkeysEnabled ? [Passkey({})] : []),
+ ...(demoModeEnabled
+ ? [
+ CredentialsProvider({
+ id: "demo",
+ name: "Demo Mode",
+ credentials: {},
+ async authorize() {
+ if (!demoModeEnabled) return null;
+ const email = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase() ?? "admin@example.com";
+ const user = await db.user.findUnique({
+ where: { email },
+ select: { id: true, name: true, email: true, role: true },
+ });
+ return user ?? null;
+ },
+ }),
+ ]
+ : []),
// Conditionally register Google provider when env credentials are available.
// Actual enablement is enforced via DB in the signIn callback/UI.
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
@@ -219,21 +218,9 @@ export const authConfig = {
const provider = account.provider;
- if (provider === LOGIN_LINK_PROVIDER_ID) {
- const emailAddr = (user as { email?: string | null } | undefined)?.email?.toLowerCase();
- if (!emailAddr) return false;
- try {
- const existing = await db.user.findUnique({ where: { email: emailAddr } });
- return Boolean(existing);
- } catch (error) {
- logger.warn({ event: "auth.login_link_validation_failed", email: emailAddr, error }, "Blocked login-link sign-in due to validation error");
- return false;
- }
- }
-
- if (provider === "passkey") {
- if (!passkeysEnabled) {
- logger.warn({ event: "auth.passkey_disabled" }, "Blocked passkey sign-in because provider is disabled");
+ if (provider === "demo") {
+ if (!demoModeEnabled) {
+ logger.warn({ event: "auth.demo_disabled" }, "Blocked demo sign-in because demo mode is disabled");
return false;
}
const resolved = await resolveUserIdentity(user as { id?: string | null; email?: string | null });
diff --git a/src/server/auth/constants.ts b/src/server/auth/constants.ts
deleted file mode 100644
index cabb7f4..0000000
--- a/src/server/auth/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const LOGIN_LINK_PROVIDER_ID = "login-link" as const;
diff --git a/src/server/auth/login-link.ts b/src/server/auth/login-link.ts
deleted file mode 100644
index a21eaee..0000000
--- a/src/server/auth/login-link.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import crypto from "node:crypto";
-import type { PrismaClient } from "@prisma/client";
-
-import { env } from "@/env";
-import { logger } from "@/server/logger";
-
-import { LOGIN_LINK_PROVIDER_ID } from "./constants";
-
-export { LOGIN_LINK_PROVIDER_ID } from "./constants";
-const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour
-
-function getBaseUrl(provided?: string) {
- const base = provided ?? env.AUTH_URL ?? "http://localhost:3000";
- try {
- return new URL(base);
- } catch (error) {
- logger.warn(
- { event: "auth.login_link.invalid_base", base, error },
- "Invalid AUTH_URL provided, falling back to http://localhost:3000",
- );
- return new URL("http://localhost:3000");
- }
-}
-
-function hashToken(token: string, secret: string) {
- return crypto.createHash("sha256").update(`${token}${secret}`).digest("hex");
-}
-
-export async function createLoginLink(
- db: PrismaClient,
- params: {
- email: string;
- callbackPath?: string;
- expiresInSeconds?: number;
- baseUrl?: string;
- },
-) {
- const email = params.email.trim().toLowerCase();
- if (!email) {
- throw new Error("Cannot generate login link without an email address");
- }
-
- const secret = env.AUTH_SECRET;
- const ttl = params.expiresInSeconds ?? DEFAULT_TTL_SECONDS;
- const expires = new Date(Date.now() + ttl * 1000);
- const token = crypto.randomBytes(32).toString("hex");
- const hashed = hashToken(token, secret);
-
- // Only allow a single active login link per email to reduce risk of reuse.
- await db.verificationToken.deleteMany({ where: { identifier: email } });
- await db.verificationToken.create({
- data: {
- identifier: email,
- token: hashed,
- expires,
- },
- });
-
- const baseUrl = getBaseUrl(params.baseUrl);
- const callbackDestination = params.callbackPath ?? "/";
- const callbackTarget = new URL(callbackDestination, baseUrl);
- const link = new URL(`/api/auth/callback/${LOGIN_LINK_PROVIDER_ID}`, baseUrl);
- link.searchParams.set("callbackUrl", callbackTarget.toString());
- link.searchParams.set("token", token);
- link.searchParams.set("email", email);
-
- return { url: link.toString(), expires };
-}
diff --git a/src/server/services/userService.ts b/src/server/services/userService.ts
index 2824145..25c11c5 100644
--- a/src/server/services/userService.ts
+++ b/src/server/services/userService.ts
@@ -56,7 +56,5 @@ export function defaultUserSelect() {
email: true,
role: true,
lastLogin: true,
- _count: { select: { authenticators: true } },
} as const;
}
-
diff --git a/src/test/auth-signin-callback.test.ts b/src/test/auth-signin-callback.test.ts
index d2d6c8c..6434783 100644
--- a/src/test/auth-signin-callback.test.ts
+++ b/src/test/auth-signin-callback.test.ts
@@ -22,29 +22,17 @@ describe("NextAuth signIn callback", () => {
expect(result).toBe(true);
});
- it("denies passkey sign-in when passkeys are disabled", async () => {
+ it("denies demo sign-in when demo mode is disabled", async () => {
const { authConfig } = await import("@/server/auth/config");
const signInCb = authConfig.callbacks?.signIn;
if (!signInCb) throw new Error("signIn callback missing");
const res = await signInCb({
- account: { provider: "passkey" } as any,
+ account: { provider: "demo" } as any,
user: { id: "u1", email: "user@test.com" } as AdapterUser,
});
expect(res).toBe(false);
});
- it("allows login link when user exists", async () => {
- mockDb.user.findUnique.mockResolvedValue({ id: "u1", email: "user@test.com", role: "ADMIN" });
- const { authConfig } = await import("@/server/auth/config");
- const signInCb = authConfig.callbacks?.signIn;
- if (!signInCb) throw new Error("signIn callback missing");
- const res = await signInCb({
- account: { provider: "login-link" } as any,
- user: { email: "user@test.com" } as AdapterUser,
- });
- expect(res).toBe(true);
- });
-
it("allows Google sign-in for existing user and marks email verified", async () => {
mockDb.user.findUnique.mockResolvedValue({ id: "u1", emailVerified: null });
mockDb.user.update.mockResolvedValue({ id: "u1" } as never);
diff --git a/src/test/users-create.test.ts b/src/test/users-create.test.ts
index 5ac465a..fcf904f 100644
--- a/src/test/users-create.test.ts
+++ b/src/test/users-create.test.ts
@@ -10,25 +10,15 @@ vi.mock("@/server/db", () => ({
},
}));
-vi.mock("@/server/auth/login-link", () => ({
- LOGIN_LINK_PROVIDER_ID: "login-link",
- createLoginLink: vi.fn().mockResolvedValue({
- url: "https://app/api/auth/callback/login-link?token=abc",
- expires: new Date("2025-01-01T00:00:00Z"),
- }),
-}));
-
const { db } = await import("@/server/db");
const mockDb = vi.mocked(db, true);
-const { createLoginLink } = await import("@/server/auth/login-link");
-const mockCreateLoginLink = vi.mocked(createLoginLink);
describe("Users Router — create & validation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
- it("creates new user and returns login link", async () => {
+ it("creates new user", async () => {
const newUserData = { email: "newuser@test.com", name: "New User", role: UserRole.OPERATOR } as const;
const mockCreatedUser = {
id: "new-user-id",
@@ -36,7 +26,6 @@ describe("Users Router — create & validation", () => {
email: newUserData.email,
role: newUserData.role,
lastLogin: null,
- _count: { authenticators: 0 },
};
mockDb.user.findUnique.mockResolvedValue(null);
mockDb.user.create.mockResolvedValue(mockCreatedUser);
@@ -44,16 +33,13 @@ describe("Users Router — create & validation", () => {
const caller = usersRouter.createCaller(ctx);
const result = await caller.create(newUserData);
- expect(result.user).toEqual({
+ expect(result).toEqual({
id: "new-user-id",
name: "New User",
email: "newuser@test.com",
role: UserRole.OPERATOR,
lastLogin: null,
- passkeyCount: 0,
});
- expect(result.loginLink.url).toContain("token=abc");
- expect(mockCreateLoginLink).toHaveBeenCalledWith(mockDb, { email: newUserData.email });
});
it("normalizes email casing before persisting", async () => {
@@ -65,7 +51,6 @@ describe("Users Router — create & validation", () => {
email: normalizedEmail,
role: newUserData.role,
lastLogin: null,
- _count: { authenticators: 0 },
};
mockDb.user.findUnique.mockResolvedValue(null);
mockDb.user.create.mockResolvedValue(mockCreatedUser);
@@ -77,7 +62,6 @@ describe("Users Router — create & validation", () => {
expect(mockDb.user.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ email: normalizedEmail }) }),
);
- expect(mockCreateLoginLink).toHaveBeenCalledWith(mockDb, { email: normalizedEmail });
});
it("throws when email already exists", async () => {
@@ -97,7 +81,6 @@ describe("Users Router — create & validation", () => {
email: "test@test.com",
role: UserRole.VIEWER,
lastLogin: null,
- _count: { authenticators: 0 },
});
const ctx = createTestContext(mockDb, UserRole.ADMIN);
const caller = usersRouter.createCaller(ctx);
diff --git a/src/test/users-read.test.ts b/src/test/users-read.test.ts
index fd501fd..48179d1 100644
--- a/src/test/users-read.test.ts
+++ b/src/test/users-read.test.ts
@@ -21,16 +21,16 @@ describe("Users Router — read", () => {
describe("list", () => {
it("returns all users for admin", async () => {
const mockUsers = [
- { id: "1", name: "Admin User", email: "admin@test.com", role: UserRole.ADMIN, lastLogin: null, _count: { authenticators: 1 } },
- { id: "2", name: "Operator User", email: "operator@test.com", role: UserRole.OPERATOR, lastLogin: null, _count: { authenticators: 0 } },
+ { id: "1", name: "Admin User", email: "admin@test.com", role: UserRole.ADMIN, lastLogin: null },
+ { id: "2", name: "Operator User", email: "operator@test.com", role: UserRole.OPERATOR, lastLogin: null },
];
mockDb.user.findMany.mockResolvedValue(mockUsers);
const ctx = createTestContext(mockDb, UserRole.ADMIN);
const caller = usersRouter.createCaller(ctx);
const result = await caller.list();
expect(result).toEqual([
- { id: "1", name: "Admin User", email: "admin@test.com", role: UserRole.ADMIN, lastLogin: null, passkeyCount: 1 },
- { id: "2", name: "Operator User", email: "operator@test.com", role: UserRole.OPERATOR, lastLogin: null, passkeyCount: 0 },
+ { id: "1", name: "Admin User", email: "admin@test.com", role: UserRole.ADMIN, lastLogin: null },
+ { id: "2", name: "Operator User", email: "operator@test.com", role: UserRole.OPERATOR, lastLogin: null },
]);
});
@@ -45,7 +45,7 @@ describe("Users Router — read", () => {
describe("me", () => {
it("returns current user profile", async () => {
- const mockUser = { id: "user-123", name: "Test User", email: "test@example.com", role: UserRole.OPERATOR, lastLogin: null, _count: { authenticators: 2 } };
+ const mockUser = { id: "user-123", name: "Test User", email: "test@example.com", role: UserRole.OPERATOR, lastLogin: null };
mockDb.user.findUnique.mockResolvedValue(mockUser);
const ctx = createTestContext(mockDb, UserRole.OPERATOR, "user-123");
const caller = usersRouter.createCaller(ctx);
@@ -56,7 +56,6 @@ describe("Users Router — read", () => {
email: "test@example.com",
role: UserRole.OPERATOR,
lastLogin: null,
- passkeyCount: 2,
});
});
});
diff --git a/src/test/users-security.test.ts b/src/test/users-security.test.ts
deleted file mode 100644
index 4d480bf..0000000
--- a/src/test/users-security.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { TRPCError } from "@trpc/server";
-import { UserRole } from "@prisma/client";
-import { usersRouter } from "@/server/api/routers/users";
-import { createTestContext } from "@/test/utils/context";
-
-vi.mock("@/server/db", () => ({
- db: {
- user: { findUnique: vi.fn() },
- },
-}));
-
-vi.mock("@/server/auth/login-link", () => ({
- LOGIN_LINK_PROVIDER_ID: "login-link",
- createLoginLink: vi.fn().mockResolvedValue({
- url: "https://app/api/auth/callback/login-link?token=abc",
- expires: new Date("2025-01-01T00:00:00Z"),
- }),
-}));
-
-const { db } = await import("@/server/db");
-const mockDb = vi.mocked(db, true);
-const { createLoginLink } = await import("@/server/auth/login-link");
-const mockCreateLoginLink = vi.mocked(createLoginLink);
-
-describe("Users Router — login links", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("issues login link for existing user", async () => {
- mockDb.user.findUnique.mockResolvedValue({ id: "u1", email: "user@test.com", name: "User" });
- const ctx = createTestContext(mockDb, UserRole.ADMIN);
- const caller = usersRouter.createCaller(ctx);
- const res = await caller.issueLoginLink({ id: "u1" });
- expect(res.url).toContain("token=abc");
- expect(mockCreateLoginLink).toHaveBeenCalledWith(mockDb, { email: "user@test.com" });
- });
-
- it("throws when user not found", async () => {
- mockDb.user.findUnique.mockResolvedValue(null);
- const ctx = createTestContext(mockDb, UserRole.ADMIN);
- const caller = usersRouter.createCaller(ctx);
- await expect(caller.issueLoginLink({ id: "missing" })).rejects.toThrow(new TRPCError({ code: "NOT_FOUND", message: "User not found" }));
- });
-});
diff --git a/src/test/users-update-delete.test.ts b/src/test/users-update-delete.test.ts
index fab4d1b..53e19f2 100644
--- a/src/test/users-update-delete.test.ts
+++ b/src/test/users-update-delete.test.ts
@@ -27,7 +27,6 @@ describe("Users Router — update/delete", () => {
email: updateData.email,
role: updateData.role,
lastLogin: null,
- _count: { authenticators: 1 },
};
mockDb.user.findFirst.mockResolvedValue(null);
mockDb.user.update.mockResolvedValue(mockUpdatedUser);
@@ -40,7 +39,6 @@ describe("Users Router — update/delete", () => {
email: updateData.email,
role: updateData.role,
lastLogin: null,
- passkeyCount: 1,
});
});
@@ -59,7 +57,6 @@ describe("Users Router — update/delete", () => {
email: normalizedEmail,
role: updateData.role,
lastLogin: null,
- _count: { authenticators: 0 },
});
const ctx = createTestContext(mockDb, UserRole.ADMIN);
const caller = usersRouter.createCaller(ctx);
From f54abf6c416bc65db237d44090894d9c96e61c15 Mon Sep 17 00:00:00 2001
From: initstring <26131150+initstring@users.noreply.github.com>
Date: Fri, 2 Jan 2026 23:12:30 +1100
Subject: [PATCH 2/3] Update docs
---
README.md | 4 ++--
docs/installation.md | 10 ++++++----
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 447ea4b..b16ea6a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Red Team Assessment Platform (RTAP) is built for internal Red Teams to plan and
User Docs:
- [Installation](docs/installation.md)
-- [Getting Started Workflow](docs/getting-started.md)
+- [Getting Started Workflow](docs/getting-started.md) (look here for UI screenshots)
Development Docs:
- [Development](docs/development.md)
@@ -41,7 +41,7 @@ Initially based on the T3 Stack - Next.js, tRPC, Prisma, TypeScript. Type-safe A
Local development runs the Next.js dev server against a local PostgreSQL container. Production workloads also use Docker (web + Postgres) behind your own reverse proxy.
-Authentication is passwordless and SSO-first using NextAuth (currently Google OAuth). For development and trials you can enable a demo admin sign-in button via `ENABLE_DEMO_MODE`.
+Authentication is passwordless and SSO-only using NextAuth. For development and trials you can enable a demo admin sign-in button via `ENABLE_DEMO_MODE=true`.
## Licensing
diff --git a/docs/installation.md b/docs/installation.md
index d725290..3e032d5 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,6 +1,6 @@
# Installation
-Follow these instructions to set up Red Team Assessment Platform (RTAP) in local development or production environments.
+Follow these instructions to set up Red Team Assessment Platform (RTAP) in local development or production environments. For development environments, additional information is available [here](./development.md).
## Docker Installation
@@ -24,18 +24,20 @@ docker exec rtap-web npm run seed:demo
### How it Works
-Let's be the change we want to see in the world. There is no support for passwords! Authentication is SSO-first (Google OAuth today), with an optional demo-mode button for trials.
+Authentication is SSO-only, with an optional demo-mode button for trials.
+
+Currently, only Google SSO is enabled. However, [NextAuth supports tons of providers](https://next-auth.js.org/v3/configuration/providers#oauth-providers). Open an issue and I will add providers for you.
**Admin bootstrap:**
- On first run, the application creates an admin account using `INITIAL_ADMIN_EMAIL` from your `.env`.
-- If using Google SSO, sign in with the matching Google account.
+- If using SSO, sign in with the matching account and it will just work.
- If using demo mode, click "Sign in as Demo Admin" (requires `ENABLE_DEMO_MODE=true`).
**Ongoing user management:**
- Once logged in as admin, you can create additional users.
-- Google SSO users: log in with the matching Google email.
+- SSO users: log in with the matching email.
Accounts must be created inside the platform; SSO logins for unknown emails will be rejected.
From a26ebf872bda7b55485243cfcbb0c99184eb4846 Mon Sep 17 00:00:00 2001
From: initstring <26131150+initstring@users.noreply.github.com>
Date: Fri, 2 Jan 2026 23:41:46 +1100
Subject: [PATCH 3/3] Small docs clarification
---
docs/installation.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/installation.md b/docs/installation.md
index 3e032d5..d6e63c3 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,6 +1,8 @@
# Installation
-Follow these instructions to set up Red Team Assessment Platform (RTAP) in local development or production environments. For development environments, additional information is available [here](./development.md).
+Follow these instructions to set up Red Team Assessment Platform (RTAP) for production or local testing purposes. This uses pre-built Docker containers.
+
+For development environments, you'll probably instead want to run a local npm dev server - not a pre-built container. Additional information is available [here](./development.md).
## Docker Installation