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..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 all passwordless using NextAuth - with an option for passkeys and/or OAuth providers (initial support includes Google SSO). +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/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..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. +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 @@ -17,36 +19,27 @@ 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) +Authentication is SSO-only, with an optional demo-mode button for trials. -The platform uses NextAuth, so adding additional SSO providers would be pretty easy. +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, 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 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: 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. +- SSO users: log in with the matching email. Accounts must be created inside the platform; SSO logins for unknown emails will be rejected. @@ -55,10 +48,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.

- - {status === "success" && ( - Passkey registered successfully. - )} - {status === "error" && ( - Registration failed. Please try again. - )}
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}

- -
-
- - - 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)} /> -
@@ -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 && ( )} - {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);