diff --git a/.github/workflows/web-actions.yml b/.github/workflows/web-actions.yml index 60e22fb6a..9ca044937 100644 --- a/.github/workflows/web-actions.yml +++ b/.github/workflows/web-actions.yml @@ -134,4 +134,7 @@ jobs: kubectl set env deployment/cc-web-deploy CHAT_PROVIDER=openai kubectl set env deployment/cc-web-deploy OPENAI_API_KEY=${{secrets.OPENAI_API_KEY}} kubectl set env deployment/cc-web-deploy HUGGINGFACE_API_KEY=${{secrets.HUGGINGFACE_API_KEY}} + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_EMAIL=${{secrets.DEFAULT_ADMIN_EMAIL}} + kubectl set env deployment/cc-web-deploy DEFAULT_ADMIN_PASSWORD=${{secrets.DEFAULT_ADMIN_PASSWORD}} + kubectl create -f k8s/cc-create-admin.yml -n default kubectl rollout restart deployment cc-web-deploy -n default diff --git a/app/package.json b/app/package.json index 1c4cabdfb..52604d4b0 100644 --- a/app/package.json +++ b/app/package.json @@ -26,7 +26,8 @@ "cy:run": "cypress run", "cy:test": "start-server-and-test start http://localhost:3000/en/auth/login cy:run", "prettier": "npx prettier . --write", - "email": "email dev --dir src/lib/emails" + "email": "email dev --dir src/lib/emails", + "create-admin": "tsx scripts/create-admin.ts" }, "dependencies": { "@chakra-ui/icons": "^2.1.0", @@ -91,6 +92,7 @@ "sequelize": "^6.37.1", "sequelize-cli": "^6.6.2", "tailwindcss": "3.4.1", + "tsx": "^4.7.0", "typescript": "5.3.3", "uuid": "^9.0.1", "wellknown": "^0.5.0", @@ -108,8 +110,7 @@ "prettier": "3.2.5", "sequelize-auto": "^0.8.8", "start-server-and-test": "^2.0.3", - "storybook": "^7.6.17", - "tsx": "^4.7.0" + "storybook": "^7.6.17" }, "engines": { "node": ">=20.5.0" diff --git a/app/scripts/create-admin.ts b/app/scripts/create-admin.ts new file mode 100644 index 000000000..e63090772 --- /dev/null +++ b/app/scripts/create-admin.ts @@ -0,0 +1,53 @@ +import { db } from "@/models"; +import env from "@next/env"; +import { randomUUID } from "node:crypto"; +import { logger } from "@/services/logger"; +import bcrypt from "bcrypt"; +import { Roles } from "@/lib/auth"; + +async function createAdmin() { + const projectDir = process.cwd(); + env.loadEnvConfig(projectDir); + + if (!db.initialized) { + await db.initialize(); + } + + if (!process.env.DEFAULT_ADMIN_EMAIL || !process.env.DEFAULT_ADMIN_PASSWORD) { + logger.error( + "create-admin.ts: Missing default admin credentials DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD in env!", + ); + await db.sequelize?.close(); + return; + } + + const email = process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(); + const user = await db.models.User.findOne({ where: { email } }); + + if (user) { + logger.info("Admin user already exists. Exiting."); + await db.sequelize?.close(); + return; + } + + const passwordHash = await bcrypt.hash( + process.env.DEFAULT_ADMIN_PASSWORD, + 12, + ); + const newUser = await db.models.User.create({ + userId: randomUUID(), + name: "Admin", + email: email, + passwordHash, + role: Roles.Admin, + }); + + logger.info( + "Created admin user with email %s and ID %s", + newUser.email, + newUser.userId, + ); + await db.sequelize?.close(); +} + +createAdmin(); diff --git a/app/src/app/api/v0/auth/role/route.ts b/app/src/app/api/v0/auth/role/route.ts new file mode 100644 index 000000000..97fb865ac --- /dev/null +++ b/app/src/app/api/v0/auth/role/route.ts @@ -0,0 +1,31 @@ +import { Roles } from "@/lib/auth"; +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const changeRoleRequest = z.object({ + email: z.string().email(), + role: z.nativeEnum(Roles), +}); + +export const POST = apiHandler(async (req, { session }) => { + if (session?.user.role !== Roles.Admin) { + throw new createHttpError.Forbidden("Can only be used by admin accounts"); + } + const body = changeRoleRequest.parse(await req.json()); + const user = await db.models.User.findOne({ where: { email: body.email } }); + + if (!user) { + throw new createHttpError.NotFound("User not found"); + } + if (user.role === body.role) { + throw new createHttpError.BadRequest("User already has role " + body.role); + } + + user.role = body.role; + await user.save(); + + return NextResponse.json({ success: true }); +}); diff --git a/app/tests/api/admin.test.ts b/app/tests/api/admin.test.ts new file mode 100644 index 000000000..d8e47f731 --- /dev/null +++ b/app/tests/api/admin.test.ts @@ -0,0 +1,90 @@ +import { POST as changeRole } from "@/app/api/v0/auth/role/route"; +import { db } from "@/models"; +import assert from "node:assert"; +import { after, before, beforeEach, describe, it, mock } from "node:test"; +import { mockRequest, setupTests, testUserData, testUserID } from "../helpers"; +import { AppSession, Auth, Roles } from "@/lib/auth"; + +const mockSession: AppSession = { + user: { id: testUserID, role: "user" }, + expires: "1h", +}; +const mockAdminSession: AppSession = { + user: { id: testUserID, role: "admin" }, + expires: "1h", +}; + +describe("Admin API", () => { + let prevGetServerSession = Auth.getServerSession; + + before(async () => { + setupTests(); + await db.initialize(); + }); + + after(async () => { + Auth.getServerSession = prevGetServerSession; + if (db.sequelize) await db.sequelize.close(); + }); + + beforeEach(async () => { + Auth.getServerSession = mock.fn(() => Promise.resolve(mockSession)); + await db.models.User.upsert({ + userId: testUserID, + name: testUserData.name, + email: testUserData.email, + role: Roles.User, + }); + }); + + it("should change the user role when logged in as admin", async () => { + const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + + const user = await db.models.User.findOne({ + where: { email: testUserData.email }, + }); + assert.equal(user?.role, Roles.Admin); + }); + + it("should not change the user role when logged in as normal user", async () => { + const req = mockRequest({ email: testUserData.email, role: Roles.Admin }); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 403); + + const user = await db.models.User.findOne({ + where: { email: testUserData.email }, + }); + assert.equal(user?.role, Roles.User); + }); + + it("should return a 404 error when user does not exist", async () => { + const req = mockRequest({ + email: "not-existing@example.com", + role: Roles.Admin, + }); + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 404); + }); + + it("should validate the request", async () => { + Auth.getServerSession = mock.fn(() => Promise.resolve(mockAdminSession)); + + const req = mockRequest({ email: testUserData.email, role: "invalid" }); + const res = await changeRole(req, { params: {} }); + assert.equal(res.status, 400); + + const req2 = mockRequest({ email: "not-an-email", role: "Admin" }); + const res2 = await changeRole(req2, { params: {} }); + assert.equal(res2.status, 400); + + const req3 = mockRequest({}); + const res3 = await changeRole(req3, { params: {} }); + assert.equal(res3.status, 400); + }); +}); diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts index 5dafdc6a7..5bc1c0d54 100644 --- a/app/tests/helpers.ts +++ b/app/tests/helpers.ts @@ -17,7 +17,10 @@ export function createRequest(url: string, body?: any) { return request; } -export function mockRequest(body?: any, searchParams?: Record): NextRequest { +export function mockRequest( + body?: any, + searchParams?: Record, +): NextRequest { const request = new NextRequest(new URL(mockUrl)); request.json = mock.fn(() => Promise.resolve(body)); for (const param in searchParams) { @@ -72,6 +75,13 @@ export const testFileFormat = { }; export const testUserID = "beb9634a-b68c-4c1b-a20b-2ab0ced5e3c2"; +export const testUserData = { + id: testUserID, + name: "Test User", + email: "test@example.com", + image: null, + role: "user", +}; export function setupTests() { const projectDir = process.cwd(); @@ -82,13 +92,7 @@ export function setupTests() { const expires = new Date(); expires.setDate(expires.getDate() + 1); return { - user: { - id: testUserID, - name: "Test User", - email: "test@example.com", - image: null, - role: "user", - }, + user: testUserData, expires: expires.toISOString(), }; }); diff --git a/k8s/cc-create-admin.yml b/k8s/cc-create-admin.yml new file mode 100644 index 000000000..208719a27 --- /dev/null +++ b/k8s/cc-create-admin.yml @@ -0,0 +1,29 @@ +apiVersion: batch/v1 +kind: Job +metadata: + generateName: cc-create-admin- +spec: + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: OnFailure + containers: + - name: cc-migrate + image: ghcr.io/open-earth-foundation/citycatalyst:latest + imagePullPolicy: Always + env: + - name: NODE_ENV + value: development + - name: DATABASE_NAME + value: "citycatalyst" + - name: DATABASE_HOST + value: "cc-db" + - name: DATABASE_USER + value: "citycatalyst" + - name: DATABASE_PASSWORD + value: "development" + command: ["npm", "run", "create-admin"] + resources: + limits: + memory: "1024Mi" + cpu: "1000m"