diff --git a/README.md b/README.md index d6932108..b54acd11 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pnpm dev:init # Start the development server pnpm turbo run dev ``` -To start and stop the development environment, use `pnpm dev:start-env` and `pnpm dev:stop-env` respectively. To completely tear down the development environment (including removing all Docker volumes), use `pnpm dev:down-env`. +To start and stop the development environment, use `pnpm dev:start-env` and `pnpm dev:stop-env` respectively. ## 🛠️ Deployment Lapse is meant to be deployed via Docker. In order to deploy the main frontend/backend microservice, use `Dockerfile.web`, located in the root of this repo. diff --git a/apps/web/.env.example b/apps/web/.env.example index ae124a52..0ad8ca44 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -6,6 +6,9 @@ # See the README to get an example command on how to get a PostgreSQL database running locally! DATABASE_URL=postgresql://postgres:postgres@localhost:5432/lapse?schema=public +# The port the web server will run on. +PORT=3000 + # These are used for Hackatime OAuth authentication. These both are exposed to the public - but if you want to # use your own client, and you aren't a Hackatime super-admin, you'll probably need to host it locally... NEXT_PUBLIC_HACKATIME_CLIENT_ID=s1Eken7aZWPdh2LuzyoEgiuGQs_3OOLdQuyhRDFNPtA @@ -15,7 +18,7 @@ NEXT_PUBLIC_HACKATIME_URL=https://hackatime.hackclub.com HACKATIME_REDIRECT_URI=http://localhost:3000/api/auth-hackatime # Slack bot token used to fetch user profile information (including profile pictures). -# Create a Slack app, enable the "users:read" scope, and generate a bot token. +# Create a Slack app, enable the "users:read" scope, and generate a bot token. The toke will be in the format `xoxb-.....` SLACK_BOT_TOKEN= # We use S3 to store all user content (timelapses, thumbnails, etc...) - if you don't have an S3 bucket at hand, you can set up diff --git a/apps/web/etc/scripts/trim-long-handles.js b/apps/web/etc/scripts/trim-long-handles.js new file mode 100644 index 00000000..0f34f520 --- /dev/null +++ b/apps/web/etc/scripts/trim-long-handles.js @@ -0,0 +1,107 @@ +// @ts-check +"use strict"; + +import { parseArgs } from "node:util"; + +import { confirm } from "@inquirer/prompts"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../../src/generated/prisma/client.js"; + +const MAX_HANDLE_LENGTH = 16; + +async function main() { + const args = parseArgs({ + options: { + "database-url": { type: "string" }, + "dry-run": { type: "boolean", default: false } + } + }); + + console.log(""); + + const databaseUrl = args.values["database-url"]; + const dryRun = args.values["dry-run"] ?? false; + + if (!databaseUrl) { + console.error("(error) Missing required parameter: --database-url"); + return; + } + + const adapter = new PrismaPg({ connectionString: databaseUrl }); + const prisma = new PrismaClient({ adapter }); + + try { + console.log("(info) Finding users with handles longer than 16 characters..."); + + const usersWithLongHandles = await prisma.user.findMany({ + where: { + handle: { + not: { + // Prisma doesn't support length filters directly, so we fetch all and filter + } + } + }, + select: { id: true, handle: true, displayName: true } + }); + + const affectedUsers = usersWithLongHandles.filter(user => user.handle.length > MAX_HANDLE_LENGTH); + + if (affectedUsers.length === 0) { + console.log("(info) No users found with handles longer than 16 characters. Nothing to do."); + return; + } + + console.log(`(info) Found ${affectedUsers.length} user(s) with handles longer than 16 characters:`); + console.log(""); + + for (const user of affectedUsers) { + const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH); + console.log(` - [${user.id}] "${user.handle}" (${user.handle.length} chars) -> "${trimmedHandle}"`); + } + + console.log(""); + + if (dryRun) { + console.log("(info) Dry run mode. No changes were made."); + return; + } + + if (!await confirm({ message: `Do you wish to trim ${affectedUsers.length} handle(s)? (Y/N)` })) { + console.log("(info) Aborted. No changes were made."); + return; + } + + let successCount = 0; + let failureCount = 0; + + for (const user of affectedUsers) { + const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH); + + try { + await prisma.user.update({ + where: { id: user.id }, + data: { handle: trimmedHandle } + }); + + console.log(`(info) [${user.id}] Handle updated: "${user.handle}" -> "${trimmedHandle}"`); + successCount++; + } + catch (error) { + console.error(`(error) [${user.id}] Failed to update handle:`, error); + failureCount++; + } + } + + console.log(""); + console.log(`(info) Completed. ${successCount} handle(s) trimmed, ${failureCount} failure(s).`); + } + finally { + await prisma.$disconnect(); + } +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }); \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 75532c64..7284502d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,9 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", + "test:oauth": "vitest src/__tests__/oauthFlow.test.ts", + "dev:create-client": "tsx scripts/create-service-client.ts", + "dev:obo-flow": "tsx scripts/obo-flow.ts", "preinstall": "npx only-allow pnpm", "postinstall": "prisma generate", "db:migrate": "prisma migrate deploy", @@ -52,6 +55,7 @@ "pretty-bytes": "^7.1.0", "react": "19.2.3", "react-dom": "19.2.3", + "trpc-to-openapi": "^3.1.0", "webpack": "^5.104.1", "zod": "^4.2.1" }, @@ -92,6 +96,7 @@ "js-yaml": "^4.1.1", "jsdom": "^27.4.0", "msw": "^2.12.7", + "node-mocks-http": "^1.17.2", "ora": "^9.1.0", "prisma": "^7.2.0", "supertest": "^7.1.4", diff --git a/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql b/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql new file mode 100644 index 00000000..e5bf1132 --- /dev/null +++ b/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isBanned" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "User" ADD COLUMN "bannedAt" TIMESTAMP(3); +ALTER TABLE "User" ADD COLUMN "bannedReason" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "User" ADD COLUMN "bannedReasonInternal" TEXT NOT NULL DEFAULT ''; diff --git a/apps/web/prisma/migrations/20260120172246_add_service_clients/migration.sql b/apps/web/prisma/migrations/20260120172246_add_service_clients/migration.sql new file mode 100644 index 00000000..a36d583f --- /dev/null +++ b/apps/web/prisma/migrations/20260120172246_add_service_clients/migration.sql @@ -0,0 +1,60 @@ +-- CreateTable +CREATE TABLE "ServiceClient" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecretHash" TEXT NOT NULL, + "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "redirectUris" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "ServiceClient_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServiceTokenAudit" ( + "id" TEXT NOT NULL, + "serviceClientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "ip" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ServiceTokenAudit_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServiceGrant" ( + "id" TEXT NOT NULL, + "serviceClientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scopes" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "ServiceGrant_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceClient_clientId_key" ON "ServiceClient"("clientId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceGrant_serviceClientId_userId_key" ON "ServiceGrant"("serviceClientId", "userId"); + +-- AddForeignKey +ALTER TABLE "ServiceTokenAudit" ADD CONSTRAINT "ServiceTokenAudit_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceTokenAudit" ADD CONSTRAINT "ServiceTokenAudit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceGrant" ADD CONSTRAINT "ServiceGrant_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceGrant" ADD CONSTRAINT "ServiceGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20260120180233_oauth_app_trust/migration.sql b/apps/web/prisma/migrations/20260120180233_oauth_app_trust/migration.sql new file mode 100644 index 00000000..95566be4 --- /dev/null +++ b/apps/web/prisma/migrations/20260120180233_oauth_app_trust/migration.sql @@ -0,0 +1,36 @@ +/* + Warnings: + + - Added the required column `createdByUserId` to the `ServiceClient` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "ServiceClientTrustLevel" AS ENUM ('UNTRUSTED', 'TRUSTED'); + +-- AlterTable +ALTER TABLE "ServiceClient" ADD COLUMN "createdByUserId" TEXT NOT NULL, +ADD COLUMN "description" TEXT NOT NULL DEFAULT '', +ADD COLUMN "homepageUrl" TEXT NOT NULL DEFAULT '', +ADD COLUMN "iconUrl" TEXT NOT NULL DEFAULT '', +ADD COLUMN "trustLevel" "ServiceClientTrustLevel" NOT NULL DEFAULT 'UNTRUSTED'; + +-- CreateTable +CREATE TABLE "ServiceClientReview" ( + "id" TEXT NOT NULL, + "serviceClientId" TEXT NOT NULL, + "reviewedByUserId" TEXT NOT NULL, + "status" "ServiceClientTrustLevel" NOT NULL, + "notes" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ServiceClientReview_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ServiceClient" ADD CONSTRAINT "ServiceClient_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceClientReview" ADD CONSTRAINT "ServiceClientReview_serviceClientId_fkey" FOREIGN KEY ("serviceClientId") REFERENCES "ServiceClient"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceClientReview" ADD CONSTRAINT "ServiceClientReview_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web/prisma/promote.ts b/apps/web/prisma/promote.ts index f784eb77..ecf699b9 100644 --- a/apps/web/prisma/promote.ts +++ b/apps/web/prisma/promote.ts @@ -12,22 +12,30 @@ async function main() { const args = parseArgs({ options: { email: { type: "string" } - } + }, + allowPositionals: true }); console.log(""); - if (!args.values.email) { - console.error("(error) No e-mail specified. Aborting."); + const identifier = args.values.email || args.positionals[0]; + + if (!identifier) { + console.error("(error) No e-mail or handle specified. Usage: node promote.mjs "); return; } const user = await prisma.user.findFirst({ - where: { email: args.values.email } + where: { + OR: [ + { email: identifier }, + { handle: identifier } + ] + } }); if (!user) { - console.error(`(error) No user with e-mail ${args.values.email} exists!`); + console.error(`(error) No user with e-mail or handle "${identifier}" exists!`); return; } diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 2dab4b51..36749911 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -23,6 +23,12 @@ enum VideoContainerKind { MP4 } +enum ServiceClientTrustLevel { + UNTRUSTED + TRUSTED +} + + /// Represents a device with an encryption passkey that is unknown to the server. model KnownDevice { id String @id @default(uuid()) @@ -52,11 +58,49 @@ model User { slackId String? lastHeartbeat DateTime @default(now()) + isBanned Boolean @default(false) + timelapses Timelapse[] devices KnownDevice[] postedComments Comment[] uploadTokens UploadToken[] draftTimelapses DraftTimelapse[] + + banRecords BanRecord[] @relation("BanRecordTarget") + performedBanRecords BanRecord[] @relation("BanRecordPerformer") + + serviceTokenAudits ServiceTokenAudit[] + serviceGrants ServiceGrant[] + serviceClients ServiceClient[] + serviceClientReviews ServiceClientReview[] +} + +enum BanAction { + BAN + UNBAN +} + +/// Represents a single ban or unban action performed on a user. +model BanRecord { + id String @id @default(nanoid(12)) + createdAt DateTime @default(now()) + + /// The action that was performed. + action BanAction + + /// The public reason for the action (shown to user). + reason String @default("") + + /// The internal reason for the action (only visible to admins). + reasonInternal String @default("") + + /// The user that was banned or unbanned. + targetId String + target User @relation("BanRecordTarget", fields: [targetId], references: [id], onDelete: Cascade) + + /// The admin who performed the action. + performedById String + performedBy User @relation("BanRecordPerformer", fields: [performedById], references: [id], onDelete: Cascade) } /// Represents a timelapse that has not yet been uploaded to the server. @@ -122,7 +166,7 @@ model Timelapse { id String @id @default(nanoid(12)) /// The date when the timelapse was created. - createdAt DateTime + createdAt DateTime /// The S3 key that points to the object. If `isPublished` is `false`, this refers to the /// encrypted bucket - otherwise, the public bucket. @@ -193,3 +237,64 @@ model Snapshot { timelapseId String timelapse Timelapse @relation(fields: [timelapseId], references: [id]) } + +model ServiceClient { + id String @id @default(uuid()) + name String + description String @default("") + homepageUrl String @default("") + iconUrl String @default("") + clientId String @unique + clientSecretHash String + scopes String[] @default([]) + redirectUris String[] @default([]) + trustLevel ServiceClientTrustLevel @default(UNTRUSTED) + createdByUserId String + createdByUser User @relation(fields: [createdByUserId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + revokedAt DateTime? + + tokenAudits ServiceTokenAudit[] + grants ServiceGrant[] + reviews ServiceClientReview[] +} + +model ServiceTokenAudit { + id String @id @default(uuid()) + serviceClientId String + serviceClient ServiceClient @relation(fields: [serviceClientId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + scope String + ip String? + userAgent String? + createdAt DateTime @default(now()) +} + +model ServiceGrant { + id String @id @default(uuid()) + serviceClientId String + serviceClient ServiceClient @relation(fields: [serviceClientId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + scopes String[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + revokedAt DateTime? + lastUsedAt DateTime? + + @@unique([serviceClientId, userId]) +} + +model ServiceClientReview { + id String @id @default(uuid()) + serviceClientId String + serviceClient ServiceClient @relation(fields: [serviceClientId], references: [id]) + reviewedByUserId String + reviewedByUser User @relation(fields: [reviewedByUserId], references: [id]) + status ServiceClientTrustLevel + notes String @default("") + createdAt DateTime @default(now()) +} diff --git a/apps/web/scripts/create-service-client.ts b/apps/web/scripts/create-service-client.ts new file mode 100644 index 00000000..28bb8199 --- /dev/null +++ b/apps/web/scripts/create-service-client.ts @@ -0,0 +1,57 @@ +import "@/server/allow-only-server"; + +import { createServiceClient } from "@/server/services/serviceClientService"; +import { database } from "@/server/db"; + +function getArg(flag: string) { + const index = process.argv.indexOf(flag); + if (index === -1) + return null; + + return process.argv[index + 1] ?? null; +} + +function requiredArg(flag: string) { + const value = getArg(flag); + if (!value) + throw new Error(`Missing ${flag}`); + + return value; +} + +async function main() { + const userId = requiredArg("--user"); + const name = requiredArg("--name"); + const homepageUrl = requiredArg("--homepage"); + const redirectUrisRaw = requiredArg("--redirect"); + const scopesRaw = requiredArg("--scopes"); + + const description = getArg("--description") ?? ""; + const iconUrl = getArg("--icon") ?? ""; + + const redirectUris = redirectUrisRaw.split(",").map(uri => uri.trim()).filter(Boolean); + const scopes = scopesRaw.split(",").map(scope => scope.trim()).filter(Boolean); + + const user = await database.user.findFirst({ where: { id: userId } }); + if (!user) + throw new Error("User not found."); + + const { client, clientSecret } = await createServiceClient({ + name, + description, + homepageUrl, + iconUrl, + redirectUris, + scopes, + createdByUserId: userId + }); + + console.log("Service client created:"); + console.log(`Client ID: ${client.clientId}`); + console.log(`Client Secret: ${clientSecret}`); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/scripts/generate-build-info.ts b/apps/web/scripts/generate-build-info.ts index 5b36f7e2..246d953d 100644 --- a/apps/web/scripts/generate-build-info.ts +++ b/apps/web/scripts/generate-build-info.ts @@ -182,6 +182,7 @@ async function getContributorsFromGit(commits: GitHubCommit[]): Promise<{ contri } async function main() { + return; const useGit = hasGit(); console.log(`Using ${useGit ? "git + GitHub API" : "GitHub API only"}`); diff --git a/apps/web/scripts/obo-flow.ts b/apps/web/scripts/obo-flow.ts new file mode 100644 index 00000000..2524c7f4 --- /dev/null +++ b/apps/web/scripts/obo-flow.ts @@ -0,0 +1,34 @@ +import "@/server/allow-only-server"; + +import { createServiceClient } from "@/server/services/serviceClientService"; +import { database } from "@/server/db"; +import { generateJWT } from "@/server/auth"; + +async function main() { + const user = await database.user.findFirst(); + if (!user) + throw new Error("No user found. Create a user first."); + + const { client, clientSecret } = await createServiceClient({ + name: "Test OBO App", + description: "", + homepageUrl: "https://example.com", + iconUrl: "", + redirectUris: ["https://example.com/callback"], + scopes: ["timelapse:read"], + createdByUserId: user.id + }); + + const jwt = generateJWT(user.id, user.email); + + console.log("Client ID:", client.clientId); + console.log("Client Secret:", clientSecret); + console.log("User JWT:", jwt); + console.log("Authorize URL:"); + console.log(`http://localhost:3000/oauth/authorize?client_id=${client.clientId}&scope=timelapse:read&redirect_uri=https://example.com/callback&state=test`); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/scripts/setup-dev-env.ts b/apps/web/scripts/setup-dev-env.ts index d6e091c6..bdbdbdfe 100644 --- a/apps/web/scripts/setup-dev-env.ts +++ b/apps/web/scripts/setup-dev-env.ts @@ -11,7 +11,16 @@ import yaml from "js-yaml"; const DOCKER_STARTUP_DELAY = 1500; -const DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/lapse?schema=public"; +const DEFAULT_DATABASE_PORT = 5432; +const DEFAULT_WEBSERVER_PORT = 3000; + +let databasePort = DEFAULT_DATABASE_PORT; +let webserverPort = DEFAULT_WEBSERVER_PORT; + +function getDatabaseUrl() { + return `postgresql://postgres:postgres@localhost:${databasePort}/lapse?schema=public`; +} + let S3_ENDPOINT = "s3.localhost.localstack.cloud:4566"; let S3_PUBLIC_URL_PUBLIC = "http://lapse-public.s3.localhost.localstack.cloud:4566"; let S3_PUBLIC_URL_ENCRYPTED = "http://lapse-encrypted.s3.localhost.localstack.cloud:4566"; @@ -75,6 +84,19 @@ async function askLocalstackOrR2() { }); } +async function askForPort(name: string, defaultPort: number): Promise { + const portStr = await input({ + message: `Enter ${name} port (default: ${defaultPort}): `, + default: String(defaultPort), + }); + + const port = parseInt(portStr); + if (isNaN(port) || port < 1 || port > 65535) + throw new Error(`Invalid port number: ${portStr}`); + + return port; +} + async function checkDockerRunning() { const spinner = ora({ text: chalk.gray("Checking Docker daemon status..."), @@ -183,7 +205,7 @@ async function pushPrismaSchema() { try { await execa("pnpm", ["db:push"], { cwd: webDir, - env: { ...process.env, DATABASE_URL }, + env: { ...process.env, DATABASE_URL: getDatabaseUrl() }, }); spinner.succeed(chalk.green("Prisma schema pushed successfully")); @@ -229,7 +251,7 @@ async function updateEnvFile(envVars: Record) { try { env = await fs.readFile(resolve(webDir, ".env.example"), "utf-8"); for (const [key, value] of Object.entries(envVars)) { - env = env.replace(`${key}=`, `${key}=${value}`); + env = env.replace(new RegExp(`^${key}=.*$`, "m"), `${key}=${value}`); } await fs.writeFile(resolve(webDir, ".env"), env); @@ -284,8 +306,28 @@ async function updateDockerComposeFile(localstackImage: string | null) { }).start(); let composeContent = await fs.readFile(composeFile, "utf-8"); + const composeObject = yaml.load(composeContent) as Record; + + // Update database port if non-default + if (databasePort !== DEFAULT_DATABASE_PORT && composeObject.services) { + const services = composeObject.services as Record; + if (services["lapse-db"]?.ports) { + services["lapse-db"].ports = [`${databasePort}:5432`]; + } + } + + composeContent = yaml.dump(composeObject, { + indent: 2, + lineWidth: -1, + noRefs: true, + }); + if (localstackImage) { composeContent = buildLocalstackDockerComposeSection(composeContent, localstackImage); + + // Ensure the init-s3.sh script is executable (required for LocalStack init hooks) + const initS3Path = resolve(repoRoot, "init-s3.sh"); + await fs.chmod(initS3Path, 0o755); } // new name: lapse.dev.yaml @@ -297,7 +339,7 @@ async function updateDockerComposeFile(localstackImage: string | null) { async function runSetup() { console.clear(); - const TOTAL_STEPS = 7; + const TOTAL_STEPS = 8; let currentStep = 0; try { @@ -308,6 +350,10 @@ async function runSetup() { // always use the original docker compose file for init composeFile = resolve(repoRoot, "docker-compose.dev.yaml"); + logStep(++currentStep, TOTAL_STEPS, "Configuring ports..."); + databasePort = await askForPort("database", DEFAULT_DATABASE_PORT); + webserverPort = await askForPort("web server", DEFAULT_WEBSERVER_PORT); + logStep(++currentStep, TOTAL_STEPS, "Configuring storage backend..."); localstackORr2 = await askLocalstackOrR2(); @@ -346,6 +392,9 @@ async function runSetup() { logStep(++currentStep, TOTAL_STEPS, "Updating environment variables..."); await updateEnvFile({ + "DATABASE_URL": getDatabaseUrl(), + "PORT": webserverPort.toString(), + "HACKATIME_REDIRECT_URI": `http://localhost:${webserverPort}/api/auth-hackatime`, "SLACK_BOT_TOKEN": SLACK_BOT_TOKEN, "S3_ENDPOINT": S3_ENDPOINT, "S3_ACCESS_KEY_ID": S3_ACCESS_KEY_ID, @@ -360,7 +409,7 @@ async function runSetup() { console.log(); console.log(chalk.white(" Next steps:")); console.log(chalk.gray(" 1. Run ") + chalk.cyan("pnpm turbo run dev") + chalk.gray(" to start the development server")); - console.log(chalk.gray(" 2. Open ") + chalk.cyan("http://localhost:3000") + chalk.gray(" in your browser")); + console.log(chalk.gray(" 2. Open ") + chalk.cyan(`http://localhost:${webserverPort}`) + chalk.gray(" in your browser")); divider(); } diff --git a/apps/web/src/__tests__/comments.test.ts b/apps/web/src/__tests__/comments.test.ts index 7281bbea..3954f241 100644 --- a/apps/web/src/__tests__/comments.test.ts +++ b/apps/web/src/__tests__/comments.test.ts @@ -182,12 +182,17 @@ describe("comment router", () => { it("returns NO_PERMISSION when deleting another user's comment", async () => { const user = testFactory.user({ id: "user-1" }); const other = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); const commentEntity = testFactory.comment({ id: "comment-1", authorId: other.id, + timelapseId: timelapse.id, }); - mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity); + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); const caller = createCaller(createMockContext(user)); const result = await caller.delete({ commentId: commentEntity.id }); @@ -199,14 +204,69 @@ describe("comment router", () => { expect(mockDatabase.comment.delete).not.toHaveBeenCalled(); }); + it("allows admin to delete any comment", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const other = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); + const commentEntity = testFactory.comment({ + id: "comment-1", + authorId: other.id, + timelapseId: timelapse.id, + }); + + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); + mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); + + const caller = createCaller(createMockContext(admin)); + const result = await caller.delete({ commentId: commentEntity.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.comment.delete).toHaveBeenCalledWith({ + where: { id: commentEntity.id }, + }); + }); + + it("allows timelapse owner to delete comments on their timelapse", async () => { + const owner = testFactory.user({ id: "owner-1" }); + const commenter = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: owner.id }); + const commentEntity = testFactory.comment({ + id: "comment-1", + authorId: commenter.id, + timelapseId: timelapse.id, + }); + + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); + mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); + + const caller = createCaller(createMockContext(owner)); + const result = await caller.delete({ commentId: commentEntity.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.comment.delete).toHaveBeenCalledWith({ + where: { id: commentEntity.id }, + }); + }); + it("deletes owned comment", async () => { const user = testFactory.user({ id: "user-1" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); const commentEntity = testFactory.comment({ id: "comment-1", authorId: user.id, + timelapseId: timelapse.id, }); - mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity); + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); const caller = createCaller(createMockContext(user)); diff --git a/apps/web/src/__tests__/factories.ts b/apps/web/src/__tests__/factories.ts index 124867ba..4f813244 100644 --- a/apps/web/src/__tests__/factories.ts +++ b/apps/web/src/__tests__/factories.ts @@ -7,8 +7,9 @@ import type { Comment, UploadToken, DraftTimelapse, + BanRecord, } from "@/generated/prisma/client"; -import { PermissionLevel, TimelapseVisibility, VideoContainerKind } from "@/generated/prisma/client"; +import { PermissionLevel, TimelapseVisibility, VideoContainerKind, BanAction } from "@/generated/prisma/client"; /** * Generates a Nano ID-like string (12 characters). @@ -39,6 +40,10 @@ export const testFactory = { hackatimeAccessToken: overrides.hackatimeAccessToken ?? null, lastHeartbeat: faker.date.recent(), hackatimeRefreshToken: overrides?.hackatimeRefreshToken ?? null, + isBanned: false, + bannedAt: null, + bannedReason: "", + bannedReasonInternal: "", ...overrides, }), @@ -122,4 +127,18 @@ export const testFactory = { thumbnailTokenId: faker.string.uuid(), ...overrides, }), + + /** + * Creates a mock BanRecord object. + */ + banRecord: (overrides: Partial = {}): BanRecord => ({ + id: nanoid(12), + createdAt: faker.date.recent(), + action: BanAction.BAN, + reason: faker.lorem.sentence(), + reasonInternal: faker.lorem.sentence(), + targetId: nanoid(12), + performedById: nanoid(12), + ...overrides, + }), }; diff --git a/apps/web/src/__tests__/mocks/database.ts b/apps/web/src/__tests__/mocks/database.ts index fcf39fd7..f70527ac 100644 --- a/apps/web/src/__tests__/mocks/database.ts +++ b/apps/web/src/__tests__/mocks/database.ts @@ -47,6 +47,11 @@ export type MockDatabase = { comment: MockPrismaModel; uploadToken: MockPrismaModel; draftTimelapse: MockPrismaModel; + banRecord: MockPrismaModel; + serviceClient: MockPrismaModel; + serviceGrant: MockPrismaModel; + serviceTokenAudit: MockPrismaModel; + serviceClientReview: MockPrismaModel; $transaction: Mock<(fn: (tx: MockDatabase) => Promise) => Promise>; $connect: Mock<() => Promise>; $disconnect: Mock<() => Promise>; @@ -66,6 +71,11 @@ export function createMockDatabase(): MockDatabase { comment: createMockModel(), uploadToken: createMockModel(), draftTimelapse: createMockModel(), + banRecord: createMockModel(), + serviceClient: createMockModel(), + serviceGrant: createMockModel(), + serviceTokenAudit: createMockModel(), + serviceClientReview: createMockModel(), $transaction: vi.fn((fn) => fn(mockDatabase)), $connect: vi.fn(), $disconnect: vi.fn(), @@ -79,13 +89,49 @@ export function createMockDatabase(): MockDatabase { */ export const mockDatabase = createMockDatabase(); +/** + * Creates a mock database with realistic constraints simulation. + */ +export function createConstrainedMockDatabase() { + const db = createMockDatabase(); + + // Simulate database constraints and realistic behavior + db.user.findFirst.mockImplementation(async (query: any) => { + if (query.where?.email === "taken@example.com" || query.where?.handle === "taken-handle") { + return null; // Email or handle already taken + } + + // Return default user for other cases + const defaultImpl = db.user.findFirst.getMockImplementation(); + return defaultImpl ? defaultImpl(query) : null; + }); + + db.serviceClient.findFirst.mockImplementation(async (query: any) => { + if (query.where?.clientId?.startsWith?.("revoked_")) { + return null; // Client is revoked + } + const defaultImpl = db.serviceClient.findFirst.getMockImplementation(); + return defaultImpl ? defaultImpl(query) : null; + }); + + return db; +} + /** * Resets all mock functions in the database mock. Call this in `beforeEach` to ensure - * tests are isolated. + * tests are isolated and prevents test interference. */ export function resetMockDatabase(): void { + // Clear all mocks completely to prevent test interference + vi.clearAllMocks(); + + // Reset individual model mocks while preserving their mock implementations const resetModel = (model: MockPrismaModel) => { - Object.values(model).forEach((fn) => fn.mockReset()); + Object.values(model).forEach((fn) => { + if (typeof fn === "function" && "mockReset" in fn) { + fn.mockReset(); + } + }); }; resetModel(mockDatabase.user); @@ -95,12 +141,15 @@ export function resetMockDatabase(): void { resetModel(mockDatabase.comment); resetModel(mockDatabase.uploadToken); resetModel(mockDatabase.draftTimelapse); + resetModel(mockDatabase.banRecord); + resetModel(mockDatabase.serviceClient); + resetModel(mockDatabase.serviceGrant); + resetModel(mockDatabase.serviceTokenAudit); + resetModel(mockDatabase.serviceClientReview); mockDatabase.$transaction.mockReset(); + + // Reset transaction mock to use the current database state mockDatabase.$transaction.mockImplementation((fn) => fn(mockDatabase)); - mockDatabase.$connect.mockReset(); - mockDatabase.$disconnect.mockReset(); - mockDatabase.$executeRaw.mockReset(); - mockDatabase.$queryRaw.mockReset(); } /** diff --git a/apps/web/src/__tests__/mocks/env.ts b/apps/web/src/__tests__/mocks/env.ts index 2fcbf5fa..abd4fed7 100644 --- a/apps/web/src/__tests__/mocks/env.ts +++ b/apps/web/src/__tests__/mocks/env.ts @@ -22,6 +22,16 @@ export const mockEnv = { // Legacy/unused in current server code; kept for compatibility. HACKATIME_API_KEY: "test-hackatime-api-key", + + NEXT_PUBLIC_HACKATIME_CLIENT_ID: "test-hackatime-client-id", + NEXT_PUBLIC_HACKATIME_URL: "https://hackatime.example.com", + HACKATIME_REDIRECT_URI: "https://hackatime.example.com/callback", + UPLOAD_TOKEN_PRIVATE_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + UPLOAD_TOKEN_IV: "0123456789abcdef0123456789abcdef", + NEXT_PUBLIC_SENTRY_DSN: "test-sentry-dsn", + SENTRY_ORG: "test-sentry-org", + SENTRY_PROJECT: "test-sentry-project", + SLACK_BOT_TOKEN: "test-slack-bot-token", }; export function setupEnvMock(): void { diff --git a/apps/web/src/__tests__/mocks/trpc.ts b/apps/web/src/__tests__/mocks/trpc.ts index 58784e09..498992e3 100644 --- a/apps/web/src/__tests__/mocks/trpc.ts +++ b/apps/web/src/__tests__/mocks/trpc.ts @@ -19,6 +19,7 @@ export function createMockContext(user: User | null = null): Context { json: vi.fn(), } as unknown as NextApiResponse, user, + scopes: [] }; } diff --git a/apps/web/src/__tests__/oauthFlow.test.ts b/apps/web/src/__tests__/oauthFlow.test.ts new file mode 100644 index 00000000..536bfefd --- /dev/null +++ b/apps/web/src/__tests__/oauthFlow.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { setupEnvMock } from "./mocks/env"; +import { setupDatabaseMock, mockDatabase, resetMockDatabase } from "./mocks/database"; + +setupDatabaseMock(); +setupEnvMock(); + +let oauthAuthorize: (req: NextApiRequest, res: NextApiResponse) => Promise; +let oauthToken: (req: NextApiRequest, res: NextApiResponse) => Promise; +let generateJWT: (userId: string, email: string) => string; +let generateOAuthCode: (userId: string, clientId: string, scopes: string[], redirectUri: string, ttlSeconds: number) => string; +let hashServiceSecret: (secret: string) => string; + +function createRes() { + const headers: Record = {}; + + return { + statusCode: 200, + headers, + body: null as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + setHeader(name: string, value: string) { + headers[name] = value; + }, + }; +} + +function createReq(options: { method: string; body?: unknown; headers?: Record }) { + return { + method: options.method, + body: options.body, + headers: options.headers ?? {}, + }; +} + +beforeEach(async () => { + resetMockDatabase(); + oauthAuthorize = await import("@/pages/api/oauth/authorize").then(mod => mod.default); + oauthToken = await import("@/pages/api/oauth/token").then(mod => mod.default); + + const authModule = await import("@/server/auth"); + generateJWT = authModule.generateJWT; + generateOAuthCode = authModule.generateOAuthCode; + hashServiceSecret = authModule.hashServiceSecret; +}); + +afterEach(() => { + resetMockDatabase(); +}); + +describe("oauth flow", () => { + it("rejects invalid redirect URI", async () => { + const user = { + id: "oauth-user-1", + email: "test@example.com", + }; + + const client = { + id: "oauth-client-1", + clientId: "svc_test_1", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + trustLevel: "UNTRUSTED", + name: "Sample App", + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + + const token = generateJWT(user.id, user.email); + + const authedRes = createRes(); + const authedReq = createReq({ + method: "POST", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/other", + scope: ["timelapse:read"], + state: "state", + }, + headers: { + authorization: `Bearer ${token}`, + }, + }); + + await oauthAuthorize( + authedReq as unknown as NextApiRequest, + authedRes as unknown as NextApiResponse, + ); + expect(authedRes.statusCode).toBe(400); + }); + + it("requires grant for token exchange", async () => { + const user = { + id: "oauth-user-2", + email: "test2@example.com", + }; + + const client = { + id: "oauth-client-2", + clientId: "svc_test2", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(null as never); + + const authCode = generateOAuthCode( + user.id, + client.clientId, + ["timelapse:read"], + client.redirectUris[0], + 300, + ); + + const tokenRes = createRes(); + const tokenReq = createReq({ + method: "POST", + body: { + grant_type: "authorization_code", + code: authCode, + redirect_uri: client.redirectUris[0], + }, + headers: { + authorization: `Basic ${Buffer.from(`${client.clientId}:secret`).toString("base64")}`, + "content-type": "application/json", + }, + }); + + await oauthToken( + tokenReq as unknown as NextApiRequest, + tokenRes as unknown as NextApiResponse, + ); + + expect(tokenRes.statusCode).toBe(403); + }); + + it("rejects OBO tokens with empty scopes", async () => { + const user = { + id: "oauth-user-3", + email: "test3@example.com", + }; + + const client = { + id: "oauth-client-3", + clientId: "svc_test3", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue({ + scopes: [""], + } as never); + + const authModule = await import("@/server/auth"); + const emptyScopeToken = authModule.generateOboJWT( + user.id, + user.email, + client.id, + [""], + 900, + ); + + const res = createRes(); + const req = createReq({ + method: "POST", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/callback", + scope: ["timelapse:read"], + state: "state", + }, + headers: { + authorization: `Bearer ${emptyScopeToken}`, + }, + }); + + await oauthAuthorize( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + + expect(res.statusCode).toBe(401); + }); + + it("rejects duplicate scopes on consent", async () => { + const user = { + id: "oauth-user-4", + email: "test4@example.com", + }; + + const client = { + id: "oauth-client-4", + clientId: "svc_test4", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read", "snapshot:read"], + redirectUris: ["https://example.com/callback"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + + const userToken = generateJWT(user.id, user.email); + + const res = createRes(); + const req = createReq({ + method: "PUT", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/callback", + scope: ["timelapse:read", "timelapse:read"], + state: "state", + consent: true, + }, + headers: { + authorization: `Bearer ${userToken}`, + }, + }); + + await oauthAuthorize( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + + expect(res.statusCode).toBe(400); + }); + + it("rejects duplicate scopes on token exchange", async () => { + const user = { + id: "oauth-user-5", + email: "test5@example.com", + }; + + const client = { + id: "oauth-client-5", + clientId: "svc_test5", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + const grant = { + id: "grant-5", + scopes: ["timelapse:read", "timelapse:read"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(grant as never); + + const authCode = generateOAuthCode( + user.id, + client.clientId, + ["timelapse:read", "timelapse:read"], + client.redirectUris[0], + 300, + ); + + const tokenRes = createRes(); + const tokenReq = createReq({ + method: "POST", + body: { + grant_type: "authorization_code", + code: authCode, + redirect_uri: client.redirectUris[0], + }, + headers: { + authorization: `Basic ${Buffer.from(`${client.clientId}:secret`).toString("base64")}`, + "content-type": "application/json", + }, + }); + + await oauthToken( + tokenReq as unknown as NextApiRequest, + tokenRes as unknown as NextApiResponse, + ); + + expect(tokenRes.statusCode).toBe(400); + }); + + it("rejects OBO tokens for token exchange", async () => { + const user = { + id: "oauth-user-6", + email: "test6@example.com", + }; + + const client = { + id: "oauth-client-6", + clientId: "svc_test6", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + const grant = { + id: "grant-6", + scopes: ["timelapse:read"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(grant as never); + + const authModule = await import("@/server/auth"); + const oboToken = authModule.generateOboJWT( + user.id, + user.email, + client.id, + ["timelapse:read"], + 900, + ); + + const tokenRes = createRes(); + const tokenReq = createReq({ + method: "POST", + body: { + grant_type: "authorization_code", + code: oboToken, + redirect_uri: client.redirectUris[0], + }, + headers: { + authorization: `Basic ${Buffer.from(`${client.clientId}:secret`).toString("base64")}`, + "content-type": "application/json", + }, + }); + + await oauthToken( + tokenReq as unknown as NextApiRequest, + tokenRes as unknown as NextApiResponse, + ); + + expect(tokenRes.statusCode).toBe(400); + }); + + it("rejects OBO tokens with duplicate scopes", async () => { + const user = { + id: "oauth-user-8", + email: "test8@example.com", + }; + + const client = { + id: "oauth-client-8", + clientId: "svc_test8", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + const grant = { + id: "grant-8", + scopes: ["timelapse:read"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(grant as never); + + const authModule = await import("@/server/auth"); + const oboToken = authModule.generateOboJWT( + user.id, + user.email, + client.id, + ["timelapse:read", "timelapse:read"], + 900, + ); + + const res = createRes(); + const req = createReq({ + method: "POST", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/callback", + scope: ["timelapse:read"], + }, + headers: { + authorization: `Bearer ${oboToken}`, + }, + }); + + await oauthAuthorize( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + + expect(res.statusCode).toBe(401); + }); + + it("does not accept OBO tokens as user auth", async () => { + const user = { + id: "oauth-user-7", + email: "test7@example.com", + }; + + const client = { + id: "oauth-client-7", + clientId: "svc_test7", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + const grant = { + id: "grant-7", + scopes: ["timelapse:read"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(grant as never); + + const authModule = await import("@/server/auth"); + const oboToken = authModule.generateOboJWT( + user.id, + user.email, + client.id, + ["timelapse:read"], + 900, + ); + + const res = createRes(); + const req = createReq({ + method: "POST", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/callback", + scope: ["timelapse:read"], + }, + headers: { + authorization: `Bearer ${oboToken}`, + "content-type": "application/json", + }, + }); + + await oauthAuthorize( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + + expect(res.statusCode).toBe(401); + }); + + it("rejects overly long state values", async () => { + const user = { + id: "oauth-user-9", + email: "test9@example.com", + }; + + const client = { + id: "oauth-client-9", + clientId: "svc_test9", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + + const userToken = generateJWT(user.id, user.email); + + const res = createRes(); + const req = createReq({ + method: "POST", + body: { + client_id: client.clientId, + redirect_uri: "https://example.com/callback", + scope: ["timelapse:read"], + state: "x".repeat(300), + }, + headers: { + authorization: `Bearer ${userToken}`, + }, + }); + + await oauthAuthorize( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + + expect(res.statusCode).toBe(400); + }); + + it("rejects invalid scopes on token exchange", async () => { + const user = { + id: "oauth-user-10", + email: "test10@example.com", + }; + + const client = { + id: "oauth-client-10", + clientId: "svc_test10", + clientSecretHash: hashServiceSecret("secret"), + scopes: ["timelapse:read"], + redirectUris: ["https://example.com/callback"], + }; + + const grant = { + id: "grant-10", + scopes: ["timelapse:read"], + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.serviceGrant.findFirst.mockResolvedValue(grant as never); + + const authCode = generateOAuthCode( + user.id, + client.clientId, + ["timelapse:read", "invalid:scope"], + client.redirectUris[0], + 300, + ); + + const tokenRes = createRes(); + const tokenReq = createReq({ + method: "POST", + body: { + grant_type: "authorization_code", + code: authCode, + redirect_uri: client.redirectUris[0], + }, + headers: { + authorization: `Basic ${Buffer.from(`${client.clientId}:secret`).toString("base64")}`, + "content-type": "application/json", + }, + }); + + await oauthToken( + tokenReq as unknown as NextApiRequest, + tokenRes as unknown as NextApiResponse, + ); + + expect(tokenRes.statusCode).toBe(400); + }); +}); diff --git a/apps/web/src/__tests__/restApi.test.ts b/apps/web/src/__tests__/restApi.test.ts new file mode 100644 index 00000000..a39ee3fd --- /dev/null +++ b/apps/web/src/__tests__/restApi.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { setupEnvMock } from "./mocks/env"; +import { setupDatabaseMock, mockDatabase, resetMockDatabase } from "./mocks/database"; + +setupDatabaseMock(); +setupEnvMock(); + +let restHandler: (req: NextApiRequest, res: NextApiResponse) => Promise; +let generateOboJWT: ( + userId: string, + email: string, + actorId: string, + scopes: string[], + ttl: number, +) => string; + +beforeEach(async () => { + resetMockDatabase(); + restHandler = await import("@/pages/api/rest/[...trpc]").then( + (mod) => mod.default, + ); + + const authModule = await import("@/server/auth"); + generateOboJWT = authModule.generateOboJWT; +}); + +afterEach(() => { + vi.clearAllMocks(); + resetMockDatabase(); +}); + +describe("rest api", () => { + it("allows access to public endpoints without auth", async () => { + mockDatabase.user.aggregate.mockResolvedValue({ + _count: { lastHeartbeat: 7 }, + } as never); + + const { req, res } = createMocks({ + method: "GET", + url: "/api/rest/global/activeUsers", + query: { trpc: ["global", "activeUsers"] }, + }); + + await restHandler( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + expect(res._getStatusCode()).toBe(200); + expect(res._getJSONData()).toEqual({ + ok: true, + data: { count: 7 }, + }); + }); + + it("enforces authentication for protected endpoints", async () => { + const { req, res } = createMocks({ + method: "GET", + url: "/api/rest/user/getDevices", + query: { trpc: ["user", "getDevices"] }, + }); + + await restHandler( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + expect(res._getStatusCode()).toBe(401); + }); + + it("supports OBO authentication with correct scopes", async () => { + const user = { + id: "rest-user-1", + email: "rest-test@example.com", + }; + const client = { + id: "rest-client-1", + clientId: "svc_rest_test_1", + }; + + mockDatabase.user.findFirst.mockResolvedValue(user as never); + mockDatabase.serviceClient.findFirst.mockResolvedValue(client as never); + mockDatabase.knownDevice.findMany.mockResolvedValue([]); + + const oboToken = generateOboJWT( + user.id, + user.email, + client.id, + ["user:read"], + 900, + ); + + const { req, res } = createMocks({ + method: "GET", + url: "/api/rest/user/getDevices", + query: { trpc: ["user", "getDevices"] }, + headers: { authorization: `Bearer ${oboToken}` }, + }); + + await restHandler( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + expect(res._getStatusCode()).toBe(200); + expect(res._getJSONData()).toEqual({ + ok: true, + data: { devices: [] }, + }); + }); + + it("returns consistent success response format", async () => { + mockDatabase.user.aggregate.mockResolvedValue({ + _count: { lastHeartbeat: 42 }, + } as never); + + const { req, res } = createMocks({ + method: "GET", + url: "/api/rest/global/activeUsers", + query: { trpc: ["global", "activeUsers"] }, + }); + + await restHandler( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + ); + expect(res._getStatusCode()).toBe(200); + const body = res._getJSONData(); + expect(body.ok).toBe(true); + expect(body).toHaveProperty("data"); + expect(body.data).toHaveProperty("count", 42); + }); +}); diff --git a/apps/web/src/__tests__/user.test.ts b/apps/web/src/__tests__/user.test.ts index 7a2f8641..3addda8c 100644 --- a/apps/web/src/__tests__/user.test.ts +++ b/apps/web/src/__tests__/user.test.ts @@ -51,10 +51,14 @@ function polyfillIteratorHelpers(): void { } } -const importRouter = async () => (await import("@/server/routers/api/user")).default; +const importUserRouter = async () => (await import("@/server/routers/api/user")).default; +const importAdminRouter = async () => (await import("@/server/routers/api/admin")).default; const createCaller = async (ctx: Context) => - (await importRouter()).createCaller(ctx); + (await importUserRouter()).createCaller(ctx); + +const createAdminCaller = async (ctx: Context) => + (await importAdminRouter()).createCaller(ctx); describe("user router", () => { beforeAll(() => { @@ -436,4 +440,243 @@ describe("user router", () => { }); }); }); + + describe("setBanStatus", () => { + it("should deny normal users from banning", async () => { + const user = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + const target = testFactory.user({ id: "user-2", permissionLevel: "USER" }); + + const caller = await createAdminCaller(createMockContext(user)); + const result = await caller.setBanStatus({ + id: target.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow admin to ban a user", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + const bannedTarget = { + ...target, + isBanned: true, + devices: [], + }; + const banRecord = { + id: "ban-1", + createdAt: new Date(), + action: "BAN", + reason: "Test ban", + reasonInternal: "", + targetId: target.id, + performedById: admin.id, + performedBy: admin, + }; + + mockDatabase.user.findFirst.mockResolvedValue({ ...target, devices: [] }); + mockDatabase.user.update.mockResolvedValue(bannedTarget); + mockDatabase.banRecord.create.mockResolvedValue(banRecord); + mockDatabase.$transaction.mockResolvedValue([bannedTarget, banRecord]); + + const caller = await createAdminCaller(createMockContext(admin)); + const result = await caller.setBanStatus({ + id: target.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.user.private.ban).not.toBeNull(); + } + }); + + it("should prevent admin from banning another admin", async () => { + const admin1 = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const admin2 = testFactory.user({ id: "admin-2", permissionLevel: "ADMIN" }); + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin2, devices: [] }); + + const caller = await createAdminCaller(createMockContext(admin1)); + const result = await caller.setBanStatus({ + id: admin2.id, + isBanned: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow ROOT to ban an admin", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const bannedAdmin = { + ...admin, + isBanned: true, + devices: [], + }; + const banRecord = { + id: "ban-1", + createdAt: new Date(), + action: "BAN", + reason: "Test ban", + reasonInternal: "", + targetId: admin.id, + performedById: root.id, + performedBy: root, + }; + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin, devices: [] }); + mockDatabase.user.update.mockResolvedValue(bannedAdmin); + mockDatabase.banRecord.create.mockResolvedValue(banRecord); + mockDatabase.$transaction.mockResolvedValue([bannedAdmin, banRecord]); + + const caller = await createAdminCaller(createMockContext(root)); + const result = await caller.setBanStatus({ + id: admin.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(true); + }); + + it("should prevent user from banning themselves", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin, devices: [] }); + + const caller = await createAdminCaller(createMockContext(admin)); + const result = await caller.setBanStatus({ + id: admin.id, + isBanned: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + }); + + describe("list", () => { + it("should deny normal users from listing users", async () => { + const user = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + const caller = await createAdminCaller(createMockContext(user)); + const result = await caller.list({ limit: 10 }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow admin to list users", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const users = [ + { ...testFactory.user({ id: "user-1" }), devices: [] }, + { ...testFactory.user({ id: "user-2" }), devices: [] }, + ]; + + mockDatabase.user.findMany.mockResolvedValue(users); + + const caller = await createAdminCaller(createMockContext(admin)); + const result = await caller.list({ limit: 10 }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.users).toHaveLength(2); + } + }); + + it("should filter banned users when onlyBanned is true", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const bannedUser = { ...testFactory.user({ id: "user-1", isBanned: true }), devices: [] }; + + mockDatabase.user.findMany.mockResolvedValue([bannedUser]); + + const caller = await createAdminCaller(createMockContext(admin)); + const result = await caller.list({ limit: 10, onlyBanned: true }); + + expect(result.ok).toBe(true); + expect(mockDatabase.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isBanned: true }, + }) + ); + }); + }); + + describe("deleteUser", () => { + it("should deny admin from deleting users", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + const caller = await createAdminCaller(createMockContext(admin)); + const result = await caller.deleteUser({ id: target.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow ROOT to delete a user", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + mockDatabase.user.findFirst.mockResolvedValue(target); + mockDatabase.timelapse.findMany.mockResolvedValue([]); + mockDatabase.comment.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.knownDevice.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.uploadToken.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.draftTimelapse.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.user.delete.mockResolvedValue(target); + + const caller = await createAdminCaller(createMockContext(root)); + const result = await caller.deleteUser({ id: target.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.user.delete).toHaveBeenCalledWith({ + where: { id: target.id }, + }); + }); + + it("should prevent ROOT from deleting themselves", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + + mockDatabase.user.findFirst.mockResolvedValue(root); + + const caller = await createAdminCaller(createMockContext(root)); + const result = await caller.deleteUser({ id: root.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should prevent deleting ROOT users", async () => { + const root1 = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const root2 = testFactory.user({ id: "root-2", permissionLevel: "ROOT" }); + + mockDatabase.user.findFirst.mockResolvedValue(root2); + + const caller = await createAdminCaller(createMockContext(root1)); + const result = await caller.deleteUser({ id: root2.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + }); }); diff --git a/apps/web/src/client/api.ts b/apps/web/src/client/api.ts index 016ea99b..5493c4a3 100644 --- a/apps/web/src/client/api.ts +++ b/apps/web/src/client/api.ts @@ -1,7 +1,8 @@ // This file is used so that we can avoid *any* kind of import from @/server. +export type * from "@/server/routers/api/admin"; export type * from "@/server/routers/api/snapshot"; export type * from "@/server/routers/api/timelapse"; -export type * from "@/server/routers/api/tracing"; export type * from "@/server/routers/api/user"; export type * from "@/server/routers/api/comment"; export type * from "@/server/routers/api/hackatime"; +export type * from "@/server/routers/api/developer"; diff --git a/apps/web/src/client/components/ui/Button.tsx b/apps/web/src/client/components/ui/Button.tsx index 80ae370a..6ef7d159 100644 --- a/apps/web/src/client/components/ui/Button.tsx +++ b/apps/web/src/client/components/ui/Button.tsx @@ -10,12 +10,13 @@ export type ButtonKind = "regular" | "destructive"; -export function Button({ children, kind, disabled, onClick, href, className, icon }: PropsWithChildren< +export function Button({ children, kind, disabled, onClick, href, className, icon, isSquare }: PropsWithChildren< { kind?: ButtonKind, disabled?: boolean, className?: string, - icon?: IconGlyph + icon?: IconGlyph, + isSquare?: boolean } & ( { href?: undefined, onClick: () => void } | { href: string, onClick?: undefined } @@ -33,8 +34,9 @@ export function Button({ children, kind, disabled, onClick, href, className, ico + + + ); + })} + + )} + + + ); +} diff --git a/apps/web/src/client/components/ui/layout/SettingsView.tsx b/apps/web/src/client/components/ui/layout/SettingsView.tsx index 2d9cb187..af6fd6cc 100644 --- a/apps/web/src/client/components/ui/layout/SettingsView.tsx +++ b/apps/web/src/client/components/ui/layout/SettingsView.tsx @@ -8,10 +8,11 @@ import { trpc } from "@/client/trpc"; import { deviceStorage, LocalDevice } from "@/client/deviceStorage"; import { useAuth } from "@/client/hooks/useAuth"; -import { WindowedModal } from "../WindowedModal"; -import { Button } from "../Button"; -import { PasskeyModal } from "../PasskeyModal"; -import { ErrorModal } from "../ErrorModal"; +import { WindowedModal } from "@/client/components/ui/WindowedModal"; +import { Button } from "@/client/components/ui/Button"; +import { PasskeyModal } from "@/client/components/ui/PasskeyModal"; +import { ErrorModal } from "@/client/components/ui/ErrorModal"; +import { OAuthGrantsView } from "@/client/components/ui/layout/OAuthGrantsView"; export function SettingsView({ isOpen, setIsOpen }: { isOpen: boolean, @@ -26,6 +27,7 @@ export function SettingsView({ isOpen, setIsOpen }: { const [deviceToRemove, setDeviceToRemove] = useState(null); const [removeDeviceModalOpen, setRemoveDeviceModalOpen] = useState(false); const [timelapsesToRemove, setTimelapsesToRemove] = useState([]); + const [connectedServicesOpen, setConnectedServicesOpen] = useState(false); const [error, setError] = useState(null); @@ -123,7 +125,7 @@ export function SettingsView({ isOpen, setIsOpen }: { } function hasPasskeyForDevice(id: string) { - return localDevices.some(x => x.id == id); + return localDevices.some(x => x.id === id); } return (
@@ -142,14 +144,15 @@ export function SettingsView({ isOpen, setIsOpen }: {
-
setPasskeyVisible(!passkeyVisible)} > { getCurrentDevicePasskey() || "000000" } -
+
@@ -215,6 +218,22 @@ export function SettingsView({ isOpen, setIsOpen }: {
+ + + + + +
+ + ))} + + + + ); +} diff --git a/apps/web/src/pages/admin/index.tsx b/apps/web/src/pages/admin/index.tsx new file mode 100644 index 00000000..882f93d5 --- /dev/null +++ b/apps/web/src/pages/admin/index.tsx @@ -0,0 +1,609 @@ +import { useState } from "react"; +import Icon from "@hackclub/icons"; +import NextLink from "next/link"; + +import type { User, BanRecord } from "@/server/routers/api/user"; +import { TimeAgo } from "@/client/components/TimeAgo"; + +import { trpc } from "@/client/trpc"; +import { useAuth } from "@/client/hooks/useAuth"; +import { useAsyncEffect } from "@/client/hooks/useAsyncEffect"; + +import RootLayout from "@/client/components/RootLayout"; +import { ProfilePicture } from "@/client/components/ProfilePicture"; + +import { Button } from "@/client/components/ui/Button"; +import { Skeleton } from "@/client/components/ui/Skeleton"; +import { Switch } from "@/client/components/ui/Switch"; +import { TextInput } from "@/client/components/ui/TextInput"; +import { ErrorModal } from "@/client/components/ui/ErrorModal"; +import { WindowedModal } from "@/client/components/ui/WindowedModal"; +import { TextareaInput } from "@/client/components/ui/TextareaInput"; +import { SelectInput } from "@/client/components/ui/SelectInput"; + +export default function AdminPage() { + const { currentUser, isLoading: authLoading } = useAuth(true); + + const [users, setUsers] = useState(null); + const [nextCursor, setNextCursor] = useState(undefined); + const [error, setError] = useState(null); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const [showBannedOnly, setShowBannedOnly] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + + const [banModalOpen, setBanModalOpen] = useState(false); + const [banTargetUser, setBanTargetUser] = useState(null); + const [banReason, setBanReason] = useState(""); + const [banReasonInternal, setBanReasonInternal] = useState(""); + const [isBanning, setIsBanning] = useState(false); + + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deleteTargetUser, setDeleteTargetUser] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const [historyModalOpen, setHistoryModalOpen] = useState(false); + const [historyTargetUser, setHistoryTargetUser] = useState(null); + const [banHistory, setBanHistory] = useState(null); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + const [promoteModalOpen, setPromoteModalOpen] = useState(false); + const [promoteTargetUser, setPromoteTargetUser] = useState(null); + const [selectedPermission, setSelectedPermission] = useState<"USER" | "ADMIN">("USER"); + const [isPromoting, setIsPromoting] = useState(false); + + const isAdmin = currentUser && (currentUser.private.permissionLevel === "ADMIN" || currentUser.private.permissionLevel === "ROOT"); + const isRoot = currentUser?.private.permissionLevel === "ROOT"; + + async function loadUsers(cursor?: string, onlyBanned?: boolean) { + try { + const res = await trpc.admin.list.query({ + limit: 20, + cursor, + onlyBanned + }); + + if (!res.ok) { + setError(res.error); + return; + } + + if (cursor) { + setUsers(prev => [...(prev ?? []), ...res.data.users]); + } + else { + setUsers(res.data.users); + } + + setNextCursor(res.data.nextCursor); + } + catch (err) { + console.error("(admin/index.tsx) Error loading users:", err); + setError("Failed to load users"); + } + } + + useAsyncEffect(async () => { + if (!currentUser || !isAdmin) + return; + + await loadUsers(undefined, showBannedOnly); + }, [currentUser, showBannedOnly]); + + async function handleSearch() { + if (!searchQuery.trim()) + return; + + setIsSearching(true); + setSearchResult(null); + + try { + const res = await trpc.user.query.query( + searchQuery.startsWith("@") + ? { handle: searchQuery.substring(1).trim() } + : { id: searchQuery.trim() } + ); + + if (!res.ok) { + setError(res.error); + return; + } + + if (res.data.user && "private" in res.data.user) { + setSearchResult(res.data.user as User); + } + else { + setError("User not found or you don't have permission to view them."); + } + } + catch (err) { + console.error("(admin/index.tsx) Error searching user:", err); + setError("Failed to search for user"); + } + finally { + setIsSearching(false); + } + } + + async function handleLoadMore() { + if (!nextCursor || isLoadingMore) + return; + + setIsLoadingMore(true); + await loadUsers(nextCursor, showBannedOnly); + setIsLoadingMore(false); + } + + function openBanModal(user: User) { + setBanTargetUser(user); + setBanReason(""); + setBanReasonInternal(""); + setBanModalOpen(true); + } + + async function handleBanUser(ban: boolean) { + if (!banTargetUser) + return; + + setIsBanning(true); + + try { + const res = await trpc.admin.setBanStatus.mutate({ + id: banTargetUser.id, + isBanned: ban, + reason: ban ? banReason : undefined, + reasonInternal: ban ? banReasonInternal : undefined + }); + + if (!res.ok) { + setError(res.error); + return; + } + + setUsers(prev => prev?.map(u => + u.id === banTargetUser.id ? res.data.user : u + ) ?? null); + + if (searchResult?.id === banTargetUser.id) { + setSearchResult(res.data.user); + } + + setBanModalOpen(false); + } + catch (err) { + console.error("(admin/index.tsx) Error banning user:", err); + setError("Failed to update ban status"); + } + finally { + setIsBanning(false); + } + } + + function openDeleteModal(user: User) { + setDeleteTargetUser(user); + setDeleteModalOpen(true); + } + + async function openHistoryModal(user: User) { + setHistoryTargetUser(user); + setBanHistory(null); + setHistoryModalOpen(true); + setIsLoadingHistory(true); + + try { + const res = await trpc.admin.getBanHistory.query({ id: user.id }); + + if (!res.ok) { + setError(res.error); + setHistoryModalOpen(false); + return; + } + + setBanHistory(res.data.records); + } + catch (err) { + console.error("(admin/index.tsx) Error loading ban history:", err); + setError("Failed to load ban history"); + setHistoryModalOpen(false); + } + finally { + setIsLoadingHistory(false); + } + } + + async function handleDeleteUser() { + if (!deleteTargetUser) + return; + + setIsDeleting(true); + + try { + const res = await trpc.admin.deleteUser.mutate({ + id: deleteTargetUser.id + }); + + if (!res.ok) { + setError(res.error); + return; + } + + setUsers(prev => prev?.filter(u => u.id !== deleteTargetUser.id) ?? null); + + if (searchResult?.id === deleteTargetUser.id) { + setSearchResult(null); + } + + setDeleteModalOpen(false); + } + catch (err) { + console.error("(admin/index.tsx) Error deleting user:", err); + setError("Failed to delete user"); + } + finally { + setIsDeleting(false); + } + } + + function openPromoteModal(user: User) { + setPromoteTargetUser(user); + setSelectedPermission(user.private.permissionLevel === "ADMIN" ? "USER" : "ADMIN"); + setPromoteModalOpen(true); + } + + async function handlePromote() { + if (!promoteTargetUser) + return; + + setIsPromoting(true); + + try { + const res = await trpc.admin.setPermissionLevel.mutate({ + id: promoteTargetUser.id, + permissionLevel: selectedPermission + }); + + if (!res.ok) { + setError(res.error); + return; + } + + setUsers(prev => prev?.map(u => u.id === promoteTargetUser.id ? res.data.user : u) ?? null); + if (searchResult?.id === promoteTargetUser.id) + setSearchResult(res.data.user); + + setPromoteModalOpen(false); + } + catch (err) { + console.error("(admin/index.tsx) Error updating permission:", err); + setError("Failed to update permission level"); + } + finally { + setIsPromoting(false); + } + } + + if (authLoading) { + return ( + +
+ + +
+
+ ); + } + + if (!isAdmin) { + return ( + +
+ +

Access Denied

+

You don't have permission to access the admin dashboard.

+
+
+ ); + } + + function UserRow({ user }: { user: User }) { + const isBanned = user.private.ban !== null; + const userIsRoot = user.private.permissionLevel === "ROOT"; + const canModerate = isRoot || user.private.permissionLevel === "USER"; + + return ( + + +
+
+ {user.displayName} + @{user.handle} + {user.private.permissionLevel !== "USER" && ( + + {user.private.permissionLevel} + + )} + {isBanned && ( + + BANNED + + )} +
+
{user.id}
+ {user.private.ban && (user.private.ban.reason || user.private.ban.reasonInternal) && ( +
+ {user.private.ban.reason &&
Public: {user.private.ban.reason}
} + {user.private.ban.reasonInternal &&
Internal: {user.private.ban.reasonInternal}
} +
+ )} +
+
e.preventDefault()}> + + {isRoot && !userIsRoot && ( + + )} + {canModerate && !userIsRoot && ( + <> + + {isRoot && ( + + )} + + )} +
+
+ ); + } + + return ( + +
+
+ +

Admin Dashboard

+
+ +
+
+ +

Search User

+
+
+ + +
+ {searchResult && ( +
+ +
+ )} +
+ +
+
+

All Users

+ +
+ + {!users ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : users.length === 0 ? ( +
+ {showBannedOnly ? "No banned users found." : "No users found."} +
+ ) : ( +
+ {users.map(user => ( + + ))} +
+ )} + + {nextCursor && ( +
+ +
+ )} +
+
+ + +
+

+ {banTargetUser?.private.ban + ? `Are you sure you want to unban ${banTargetUser?.displayName}?` + : `Are you sure you want to ban ${banTargetUser?.displayName}?`} +

+ {!banTargetUser?.private.ban && ( + <> + + + + )} +
+ + +
+
+
+ + +
+

+ Are you sure you want to permanently delete {deleteTargetUser?.displayName}? + This will remove all their timelapses, comments, and data. This action cannot be undone. +

+
+ + +
+
+
+ + +
+ {isLoadingHistory ? ( +
+ + +
+ ) : banHistory && banHistory.length === 0 ? ( +

No ban history for this user.

+ ) : ( + banHistory?.map(record => ( +
+
+ + {record.action === "BAN" ? "Banned" : "Unbanned"} + + + + +
+
+ By: {record.performedBy.displayName} (@{record.performedBy.handle}) +
+ {record.reason && ( +
+ Public reason: {record.reason} +
+ )} + {record.reasonInternal && ( +
+ Internal reason: {record.reasonInternal} +
+ )} +
+ )) + )} +
+
+ +
+
+ + +
+ setSelectedPermission(v as "USER" | "ADMIN")} + > + + + +
+ + +
+
+
+ + {error && ( + !isOpen && setError(null)} + message={error} + /> + )} +
+ ); +} diff --git a/apps/web/src/pages/api/oauth/authorize.ts b/apps/web/src/pages/api/oauth/authorize.ts new file mode 100644 index 00000000..b4bbad00 --- /dev/null +++ b/apps/web/src/pages/api/oauth/authorize.ts @@ -0,0 +1,224 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { database } from "@/server/db"; +import { generateOAuthCode } from "@/server/auth"; +import { getRestAuthContext } from "@/server/auth"; +import { getAllOAuthScopes } from "@/shared/oauthScopes"; + +const InitSchema = z.object({ + client_id: z.string(), + redirect_uri: z.string().optional(), + scope: z.array(z.string()).optional(), + state: z.string().max(256).optional() +}); + +const ConsentSchema = z.object({ + client_id: z.string(), + redirect_uri: z.string().optional(), + scope: z.array(z.string()).optional(), + state: z.string().max(256).optional(), + consent: z.boolean() +}); + +const AUTH_CODE_TTL_SECONDS = 300; + +function normalizeScopes(input: string[] | undefined): string[] { + if (!input) + return []; + + return input.map(scope => scope.trim()).filter(Boolean); +} + +function getInvalidScopes(scopes: string[]) { + const allowed = new Set(getAllOAuthScopes()); + return scopes.filter(scope => !allowed.has(scope)); +} + +function buildRedirectUrl(redirectUri: string | null, fragment: Record) { + if (!redirectUri) + return null; + + const url = new URL(redirectUri); + const params = new URLSearchParams(url.search); + + for (const [key, value] of Object.entries(fragment)) { + if (value !== undefined) + params.set(key, value); + } + + url.search = params.toString(); + return url.toString(); +} + +function getRequestedScopes(requested: string[], allowed: string[]) { + if (requested.length === 0) + return allowed; + + return requested.filter(scope => allowed.includes(scope)); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const authContext = await getRestAuthContext(req); + if (!authContext.user || authContext.actor) + return res.status(401).json({ ok: false, message: "Authentication required." }); + + if (req.method === "POST") { + const parsed = InitSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ ok: false, message: "Invalid authorization request." }); + + const scopes = normalizeScopes(parsed.data.scope); + const invalidScopes = getInvalidScopes(scopes); + if (invalidScopes.length > 0) + return res.status(400).json({ ok: false, message: `Unknown scopes: ${invalidScopes.join(", ")}` }); + + const client = await database.serviceClient.findFirst({ + where: { clientId: parsed.data.client_id, revokedAt: null } + }); + + if (!client) + return res.status(404).json({ ok: false, message: "Unknown client." }); + + const redirectUri = parsed.data.redirect_uri ?? null; + if (!redirectUri) + return res.status(400).json({ ok: false, message: "Redirect URI required." }); + + if (client.redirectUris.length === 0 || !client.redirectUris.includes(redirectUri)) + return res.status(400).json({ ok: false, message: "Invalid redirect URI." }); + + const existingGrant = await database.serviceGrant.findFirst({ + where: { + serviceClientId: client.id, + userId: authContext.user.id, + revokedAt: null + } + }); + + if (existingGrant) { + const existingScopes = existingGrant.scopes + .map((scope) => scope.trim()) + .filter(Boolean); + + if (existingScopes.length === 0) + return res.status(400).json({ ok: false, message: "Invalid stored grant scopes." }); + + if (existingScopes.length !== new Set(existingScopes).size) + return res.status(400).json({ ok: false, message: "Invalid stored grant scopes." }); + + const code = generateOAuthCode( + authContext.user.id, + client.clientId, + existingScopes, + redirectUri, + AUTH_CODE_TTL_SECONDS + ); + + const redirectUrl = buildRedirectUrl(redirectUri, { + code, + state: parsed.data.state + }); + + return res.status(200).json({ + ok: true, + data: { redirectUrl, authorizationCode: code, grantId: existingGrant.id } + }); + } + + return res.status(200).json({ + ok: true, + data: { + client: { + id: client.id, + name: client.name, + clientId: client.clientId, + scopes: client.scopes, + redirectUris: client.redirectUris, + trustLevel: client.trustLevel + } + } + }); + } + + if (req.method === "PUT") { + const parsed = ConsentSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ ok: false, message: "Invalid authorization request." }); + + const scopes = normalizeScopes(parsed.data.scope); + const invalidScopes = getInvalidScopes(scopes); + if (invalidScopes.length > 0) + return res.status(400).json({ ok: false, message: `Unknown scopes: ${invalidScopes.join(", ")}` }); + + const client = await database.serviceClient.findFirst({ + where: { clientId: parsed.data.client_id, revokedAt: null } + }); + + if (!client) + return res.status(404).json({ ok: false, message: "Unknown client." }); + + const redirectUri = parsed.data.redirect_uri ?? null; + if (!redirectUri) + return res.status(400).json({ ok: false, message: "Redirect URI required." }); + + if (client.redirectUris.length === 0 || !client.redirectUris.includes(redirectUri)) + return res.status(400).json({ ok: false, message: "Invalid redirect URI." }); + + if (!parsed.data.consent) { + const denyRedirect = buildRedirectUrl(redirectUri, { + error: "access_denied", + state: parsed.data.state + }); + + return res.status(200).json({ ok: true, data: { redirectUrl: denyRedirect } }); + } + + const allowedScopes = client.scopes; + const requestedScopes = getRequestedScopes(scopes, allowedScopes); + + if (requestedScopes.length === 0) + return res.status(400).json({ ok: false, message: "Requested scopes are not allowed." }); + + const normalizedScopes = requestedScopes.map((scope) => scope.trim()).filter(Boolean); + if (normalizedScopes.length === 0) + return res.status(400).json({ ok: false, message: "Requested scopes are not allowed." }); + + if (normalizedScopes.length !== new Set(normalizedScopes).size) + return res.status(400).json({ ok: false, message: "Duplicate scopes are not allowed." }); + + const grant = await database.serviceGrant.upsert({ + where: { + serviceClientId_userId: { + serviceClientId: client.id, + userId: authContext.user.id + } + }, + update: { + scopes: normalizedScopes, + revokedAt: null + }, + create: { + serviceClientId: client.id, + userId: authContext.user.id, + scopes: normalizedScopes + } + }); + + const code = generateOAuthCode( + authContext.user.id, + client.clientId, + normalizedScopes, + redirectUri, + AUTH_CODE_TTL_SECONDS + ); + + const redirectUrl = buildRedirectUrl(redirectUri, { + code, + state: parsed.data.state + }); + + return res.status(200).json({ ok: true, data: { redirectUrl, grantId: grant.id, authorizationCode: code } }); + } + + return res.status(405).json({ ok: false, message: "Method not allowed." }); +} diff --git a/apps/web/src/pages/api/oauth/token.ts b/apps/web/src/pages/api/oauth/token.ts new file mode 100644 index 00000000..57828fe0 --- /dev/null +++ b/apps/web/src/pages/api/oauth/token.ts @@ -0,0 +1,347 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { + generateOboJWT, + OBO_AUDIENCE, + OBO_ISSUER, + verifyOAuthCode, + verifyServiceSecret, +} from "@/server/auth"; +import { database } from "@/server/db"; +import { logNextRequest } from "@/server/serverCommon"; +import { getAllOAuthScopes } from "@/shared/oauthScopes"; + +const AuthCodeSchema = z.object({ + grant_type: z.literal("authorization_code"), + code: z.string(), + redirect_uri: z.string().optional(), + client_id: z.string().optional(), + client_secret: z.string().optional(), +}); + +const ClientCredentialsSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); + +const TOKEN_TTL_SECONDS = 900; + +function parseBasicAuth(authorization: string | undefined) { + if (!authorization) + return null; + + const match = authorization.match(/^Basic\s+(.*)$/i); + if (!match) + return null; + + const decoded = Buffer.from(match[1], "base64").toString("utf-8"); + const [clientId, clientSecret] = decoded.split(":"); + + if (!clientId || !clientSecret) + return null; + + return { clientId, clientSecret }; +} + +function hasAllScopes(allowed: string[], requested: string[]) { + if (requested.length === 0) + return true; + + const allowedSet = new Set(allowed); + return requested.every((scope) => allowedSet.has(scope)); +} + +function parseClientCredentials(req: NextApiRequest) { + const basic = parseBasicAuth(req.headers.authorization); + if (basic) + return basic; + + const result = ClientCredentialsSchema.safeParse(req.body); + if (!result.success) + return null; + + return { + clientId: result.data.client_id, + clientSecret: result.data.client_secret, + }; +} + +function sanitizeScopes(scopes: string[]) { + return scopes + .map((scope) => scope.trim()) + .filter(Boolean); +} + +function getInvalidScopes(scopes: string[]) { + const allowed = new Set(getAllOAuthScopes()); + return scopes.filter((scope) => !allowed.has(scope)); +} + +export const config = { + api: { + bodyParser: false, + } +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + logNextRequest("oauth.token", req); + + if (req.method !== "POST") + return res.status(405).json({ + error: "invalid_request", + error_description: "Method not allowed.", + }); + + let rawBody = ""; + if (req.body) { + if (typeof req.body === "string") { + rawBody = req.body; + } else if (Buffer.isBuffer(req.body)) { + rawBody = req.body.toString("utf-8"); + } else { + rawBody = JSON.stringify(req.body); + } + } + else { + // Read the stream manually since bodyParser is off + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + + rawBody = Buffer.concat(chunks).toString("utf-8"); + } + + const contentType = req.headers["content-type"] || ""; + if (contentType.includes("application/x-www-form-urlencoded")) { + try { + const params = new URLSearchParams(rawBody); + const parsedBody: Record = {}; + + for (const [key, value] of params.entries()) { + parsedBody[key] = value; + } + + req.body = parsedBody; + } + catch (e) { + console.error("Failed to parse x-www-form-urlencoded body", e); + req.body = {}; + } + } + else if (contentType.includes("application/json")) { + try { + req.body = JSON.parse(rawBody); + } + catch (e) { + console.error("Failed to parse JSON body", e); + req.body = {}; + } + } + else if (rawBody.trim()) { + try { + req.body = JSON.parse(rawBody); + } + catch { + req.body = {}; + } + } + else { + req.body = {}; + } + + const requestBody = AuthCodeSchema.safeParse(req.body); + if (!requestBody.success) { + return res + .status(400) + .json({ + error: "invalid_request", + error_description: "Invalid authorization code exchange payload.", + }); + } + + const credentials = parseClientCredentials(req); + if (!credentials) { + return res + .status(401) + .json({ + error: "invalid_client", + error_description: "Missing client credentials.", + }); + } + const serviceClient = await database.serviceClient.findFirst({ + where: { clientId: credentials.clientId, revokedAt: null }, + }); + + if ( + !serviceClient || + !( + await verifyServiceSecret( + credentials.clientSecret, + serviceClient.clientSecretHash, + ) + ) + ) { + return res + .status(401) + .json({ + error: "invalid_client", + error_description: "Invalid client credentials.", + }); + } + + const authCode = verifyOAuthCode(requestBody.data.code); + if (!authCode) { + return res + .status(400) + .json({ + error: "invalid_grant", + error_description: "Authorization code is invalid or expired.", + }); + } + + if (authCode.clientId !== serviceClient.clientId) { + return res + .status(400) + .json({ + error: "invalid_grant", + error_description: "Authorization code does not match client.", + }); + } + + const expectedRedirect = requestBody.data.redirect_uri ?? null; + if (expectedRedirect && expectedRedirect !== authCode.redirectUri) { + return res + .status(400) + .json({ + error: "invalid_grant", + error_description: "Authorization code redirect URI mismatch.", + }); + } + + if (!serviceClient.redirectUris.includes(authCode.redirectUri)) { + return res + .status(400) + .json({ + error: "invalid_grant", + error_description: "Authorization code redirect URI mismatch.", + }); + } + + const requestedScopes = sanitizeScopes(authCode.scopes); + const invalidScopes = getInvalidScopes(requestedScopes); + if (invalidScopes.length > 0) { + return res + .status(400) + .json({ + error: "invalid_scope", + error_description: `Unknown scopes: ${invalidScopes.join(", ")}`, + }); + } + + if (!hasAllScopes(serviceClient.scopes, requestedScopes)) { + return res + .status(403) + .json({ + error: "invalid_scope", + error_description: "Requested scope is not allowed.", + }); + } + + const subjectUser = await database.user.findFirst({ + where: { id: authCode.userId }, + }); + + if (!subjectUser) { + return res + .status(400) + .json({ + error: "invalid_request", + error_description: "Subject user not found.", + }); + } + + const grant = await database.serviceGrant.findFirst({ + where: { + serviceClientId: serviceClient.id, + userId: subjectUser.id, + revokedAt: null, + }, + }); + + if (!grant) { + return res + .status(403) + .json({ + error: "access_denied", + error_description: "User has not granted access.", + }); + } + + const grantScopes = grant.scopes; + const finalScopes = sanitizeScopes( + requestedScopes.length > 0 + ? requestedScopes.filter((scope) => grantScopes.includes(scope)) + : grantScopes, + ); + + if (finalScopes.length === 0) { + return res + .status(403) + .json({ + error: "access_denied", + error_description: "No allowed scopes for this user.", + }); + } + + if (finalScopes.length !== new Set(finalScopes).size) { + return res + .status(400) + .json({ + error: "invalid_scope", + error_description: "Duplicate scopes are not allowed.", + }); + } + + const oboToken = generateOboJWT( + subjectUser.id, + subjectUser.email, + serviceClient.id, + finalScopes, + TOKEN_TTL_SECONDS, + ); + + await database.serviceClient.update({ + where: { id: serviceClient.id }, + data: { lastUsedAt: new Date() }, + }); + + await database.serviceGrant.update({ + where: { id: grant.id }, + data: { lastUsedAt: new Date() }, + }); + + await database.serviceTokenAudit.create({ + data: { + serviceClientId: serviceClient.id, + userId: subjectUser.id, + scope: finalScopes.join(" "), + ip: req.headers["x-forwarded-for"]?.toString() ?? null, + userAgent: req.headers["user-agent"] ?? null, + }, + }); + + return res.status(200).json({ + access_token: oboToken, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: TOKEN_TTL_SECONDS, + scope: finalScopes.join(" "), + audience: OBO_AUDIENCE, + issuer: OBO_ISSUER, + }); +} diff --git a/apps/web/src/pages/api/rest/[...trpc].ts b/apps/web/src/pages/api/rest/[...trpc].ts new file mode 100644 index 00000000..a0e07ad6 --- /dev/null +++ b/apps/web/src/pages/api/rest/[...trpc].ts @@ -0,0 +1,23 @@ +import { createOpenApiNextHandler } from "trpc-to-openapi"; + +import { appRouter } from "@/server/routers/_app"; +import { getRestAuthContext } from "@/server/auth"; + +export default createOpenApiNextHandler({ + router: appRouter, + responseMeta: undefined, + + async createContext({ req, res }) { + const authContext = await getRestAuthContext(req); + return { + req, + res, + user: authContext.user, + scopes: authContext.scopes + }; + }, + + onError({ error }) { + console.error("(REST API)", error); + }, +}); diff --git a/apps/web/src/pages/api/rest/docs.ts b/apps/web/src/pages/api/rest/docs.ts new file mode 100644 index 00000000..172aec20 --- /dev/null +++ b/apps/web/src/pages/api/rest/docs.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const SWAGGER_HTML = /*html*/` + + + + + + Lapse API Docs + + + + +
+ + + + +`.trim(); + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") + return res.status(405).send("Method not allowed"); + + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.status(200).send(SWAGGER_HTML); +} diff --git a/apps/web/src/pages/api/rest/openapi.ts b/apps/web/src/pages/api/rest/openapi.ts new file mode 100644 index 00000000..2d26e04e --- /dev/null +++ b/apps/web/src/pages/api/rest/openapi.ts @@ -0,0 +1,10 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { buildRestOpenApiSpec } from "@/server/restOpenapi"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") + return res.status(405).json({ error: "invalid_request", error_description: "Method not allowed." }); + + return res.status(200).json(buildRestOpenApiSpec()); +} diff --git a/apps/web/src/pages/banned.tsx b/apps/web/src/pages/banned.tsx new file mode 100644 index 00000000..e97a1262 --- /dev/null +++ b/apps/web/src/pages/banned.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import Icon from "@hackclub/icons"; + +import RootLayout from "@/client/components/RootLayout"; +import { Button } from "@/client/components/ui/Button"; +import { Skeleton } from "@/client/components/ui/Skeleton"; +import { useAuthContext } from "@/client/context/AuthContext"; + +export default function BannedPage() { + const router = useRouter(); + const { signOut, isBanned, isLoading, banReason } = useAuthContext(); + const [shouldRedirect, setShouldRedirect] = useState(false); + + useEffect(() => { + if (!isLoading && !isBanned) { + setShouldRedirect(true); + router.replace("/"); + } + }, [isLoading, isBanned, router]); + + if (isLoading || shouldRedirect) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+ +

Account Banned

+

+ Your account has been banned from Lapse. +

+ {banReason && ( +
+

Reason:

+

{banReason}

+
+ )} +

+ If you believe this is a mistake, please contact an administrator. +

+ +
+
+
+ ); +} diff --git a/apps/web/src/pages/developer/apps/index.tsx b/apps/web/src/pages/developer/apps/index.tsx new file mode 100644 index 00000000..61450525 --- /dev/null +++ b/apps/web/src/pages/developer/apps/index.tsx @@ -0,0 +1,544 @@ +import { useEffect, useMemo, useState } from "react"; +import Icon from "@hackclub/icons"; + +import { OAuthApp } from "@/client/api"; +import { trpc } from "@/client/trpc"; +import { useAuth } from "@/client/hooks/useAuth"; +import { Button } from "@/client/components/ui/Button"; +import { WindowedModal } from "@/client/components/ui/WindowedModal"; +import RootLayout from "@/client/components/RootLayout"; + +import { OAUTH_SCOPE_GROUPS } from "@/shared/oauthScopes"; + +const scopeEntries = Object.entries(OAUTH_SCOPE_GROUPS).flatMap( + ([group, scopes]) => + Object.entries(scopes).map(([key, description]) => ({ + group, + key, + description, + })), +); + +function buildAuthorizeTestUrl(app: OAuthApp) { + const redirectUri = app.redirectUris[0]; + if (!redirectUri) + return null; + + const params = new URLSearchParams({ + client_id: app.clientId, + redirect_uri: redirectUri, + }); + + if (app.scopes.length > 0) { + params.set("scope", app.scopes.join(" ")); + } + + return `/oauth/authorize?${params.toString()}`; +} + +export default function DeveloperApps() { + const auth = useAuth(true); + const [apps, setApps] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [formState, setFormState] = useState({ + name: "", + description: "", + homepageUrl: "", + iconUrl: "", + redirectUris: "", + scopes: scopeEntries.map((scope) => scope.key), + }); + + const [secretResult, setSecretResult] = useState<{ clientId: string; clientSecret: string } | null>(null); + + const [appModalOpen, setAppModalOpen] = useState(false); + const [appModalMode, setAppModalMode] = useState<"create" | "edit">("create"); + const [appModalId, setAppModalId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [rotateSecretId, setRotateSecretId] = useState(null); + const [saveNotice, setSaveNotice] = useState(null); + + useEffect(() => { + if (!auth.currentUser) + return; + + (async () => { + setIsLoading(true); + setError(null); + + try { + const res = await trpc.developer.getAllOwnedApps.query({}); + + if (!res.ok) { + setError(`Unable to load apps: ${res.message}`); + return; + } + + setApps(res.data.apps); + } + catch (err) { + console.error("(developer/apps) failed to load", err); + setError("Unable to load apps."); + } + finally { + setIsLoading(false); + } + })(); + }, [auth.currentUser]); + + const modalRedirectUris = useMemo( + () => formState.redirectUris + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), + [formState.redirectUris] + ); + + async function createApp() { + setError(null); + setSecretResult(null); + + try { + const res = await trpc.developer.createApp.mutate({ + name: formState.name, + description: formState.description, + homepageUrl: formState.homepageUrl, + iconUrl: formState.iconUrl, + redirectUris: modalRedirectUris, + scopes: formState.scopes + }); + + if (!res.ok) { + setError(`Unable to create app: ${res.message}`); + return; + } + + setApps([res.data.app, ...apps]); + setSecretResult({ + clientId: res.data.app.clientId, + clientSecret: res.data.clientSecret, + }); + + setFormState({ + name: "", + description: "", + homepageUrl: "", + iconUrl: "", + redirectUris: "", + scopes: formState.scopes, + }); + + setAppModalOpen(false); + } + catch (err) { + console.error("(developer/apps) failed to create", err); + setError("Unable to create app."); + } + } + + async function deleteApp(appId: string) { + setError(null); + + try { + const res = await trpc.developer.revokeApp.mutate({ id: appId }); + if (!res.ok) { + setError(`Unable to delete app: ${res.message}`); + return; + } + + setApps(apps.filter((app) => app.id !== appId)); + setDeleteConfirmId(null); + + if (appModalId === appId) { + setAppModalOpen(false); + setAppModalMode("create"); + setAppModalId(null); + } + } + catch (err) { + console.error("(developer/apps) failed to delete", err); + setError("Unable to delete app."); + } + } + + function confirmDeleteApp(appId: string) { + setDeleteConfirmId(appId); + } + + async function rotateSecret(appId: string) { + setError(null); + + try { + const res = await trpc.developer.rotateAppSecret.mutate({ id: appId }); + + if (!res.ok) { + setError(`Unable to rotate secret: ${res.message}`); + return; + } + + const app = apps.find(x => x.id === appId); + if (!app) + return; + + setSecretResult({ + clientId: app.clientId, + clientSecret: res.data.clientSecret, + }); + + setRotateSecretId(null); + } + catch (err) { + console.error("(developer/apps) failed to rotate secret", err); + setError("Unable to rotate secret."); + } + } + + function confirmRotateSecret(appId: string) { + setRotateSecretId(appId); + } + + function beginEdit(app: OAuthApp) { + setAppModalMode("edit"); + setAppModalId(app.id); + setFormState({ + name: app.name, + description: app.description, + homepageUrl: app.homepageUrl, + iconUrl: app.iconUrl, + redirectUris: app.redirectUris.join("\n"), + scopes: app.scopes, + }); + + setAppModalOpen(true); + } + + function cancelEdit() { + setAppModalOpen(false); + setAppModalMode("create"); + setAppModalId(null); + } + + async function saveEdit() { + if (!appModalId) + return; + + setError(null); + setSaveNotice(null); + + try { + const res = await trpc.developer.updateApp.mutate({ + id: appModalId, + name: formState.name, + description: formState.description, + homepageUrl: formState.homepageUrl, + iconUrl: formState.iconUrl, + redirectUris: modalRedirectUris, + scopes: formState.scopes + }); + + if (!res.ok) { + setError(`Unable to update app: ${res.message}`); + return; + } + + setApps(apps.map(x => x.id === appModalId ? res.data.app : x)); + setSaveNotice("App updated. Existing authorizations remain active."); + setAppModalOpen(false); + setAppModalMode("create"); + setAppModalId(null); + } + catch (err) { + console.error("(developer/apps) failed to update", err); + setError("Unable to update app."); + } + } + + return ( + +
+
+
+ + +
+

Developer Apps

+

+ Register an OAuth app for Lapse. Untrusted apps show a warning + during consent. +

+
+
+ + +
+ + {error &&
{error}
} + {saveNotice &&
{saveNotice}
} + + {secretResult && ( +
+

This is your client secret for your new app - it won't be shown again!

+

+ Client ID:{" "} + {secretResult.clientId} +

+

+ Client Secret:{" "} + {secretResult.clientSecret} +

+
+ )} + +
+ {isLoading &&

Loading apps...

} + + {!isLoading && apps.length === 0 && ( +

No apps yet.

+ )} + + {!isLoading && apps.length > 0 && ( +
+ {apps.map((app) => ( +
+
+ {app.name} + Client ID: {app.clientId} + Trust level: {app.trustLevel} +
+ +
+ + + + + + +
+
+ ))} +
+ )} +
+ + +
+ + setFormState({ ...formState, name: event.target.value }) + } + placeholder="App name" + className="rounded-xl border border-slate bg-dark px-4 py-2" + /> + + + setFormState({ ...formState, homepageUrl: event.target.value }) + } + placeholder="Homepage URL" + className="rounded-xl border border-slate bg-dark px-4 py-2" + /> + +