diff --git a/.env.example b/.env.example index fb9ebf1d..788d6cb2 100644 --- a/.env.example +++ b/.env.example @@ -24,4 +24,4 @@ ACCESS_SECRET=7RK3RKuABKYGUQkeAt8nXTMBk2b5G4BJm4javB1DUiP= REFRESH_PK="ME4CAQAwEAYHKoZIzj0CAQYFK4EEACIENzA1AgEBBDAK2dZyfoC7rMBkznEN+oMU9bPUuwfPLZ6EoF53tb5/cWPFnop6TGhyyT0hKsYFMkc=" MINIATURE_MAX_HEIGHT_PX=300 MINIATURE_MAX_PROCESSING_TIME_S=5 -GOOGLE_APPLICATION_CREDENTIALS=./firebase_service_account_key.json +GOOGLE_APPLICATION_CREDENTIALS=./firebase_service_account_key.json \ No newline at end of file diff --git a/app/controllers/v1/permissions.ts b/app/controllers/v1/permissions.ts index 4deb035f..e7a6dcb0 100644 --- a/app/controllers/v1/permissions.ts +++ b/app/controllers/v1/permissions.ts @@ -46,6 +46,13 @@ const permissionChangeValidator = vine.compile( }), ); +const roleChangeValidator = vine.compile( + vine.object({ + userId: vine.number(), + roles: vine.array(vine.string()), + }), +); + const listPermissionsValidator = vine.compile( vine.object({ userId: vine.number(), @@ -57,6 +64,8 @@ export default class PermissionsController extends BaseController { router.post("/allow", [controller, "allow"]).as("allow"); router.post("/revoke", [controller, "revoke"]).as("revoke"); router.get("/list", [controller, "listUserPermissions"]).as("list"); + router.post("/roles/assign", [controller, "assignRoles"]).as("assignRoles"); + router.post("/roles/revoke", [controller, "revokeRoles"]).as("revokeRoles"); } async allow({ request, auth }: HttpContext) { @@ -162,4 +171,48 @@ export default class PermissionsController extends BaseController { permissions, }; } + + async assignRoles({ request, auth }: HttpContext) { + await this.requireSuperUser(auth); + + const { userId, roles } = await request.validateUsing(roleChangeValidator); + + const targetUser = await User.findOrFail(userId).addErrorContext( + () => `User with id ${userId} not found`, + ); + + const manager = Acl.model(targetUser); + + for (const role of roles) { + await manager + .assignRole(role) + .addErrorContext( + () => `Failed to assign role ${role} to user ${targetUser.id}`, + ); + } + + return { success: true }; + } + + async revokeRoles({ request, auth }: HttpContext) { + await this.requireSuperUser(auth); + + const { userId, roles } = await request.validateUsing(roleChangeValidator); + + const targetUser = await User.findOrFail(userId).addErrorContext( + () => `User with id ${userId} not found`, + ); + + const manager = Acl.model(targetUser); + + for (const role of roles) { + await manager + .revokeRole(role) + .addErrorContext( + () => `Failed to revoke role ${role} from user ${targetUser.id}`, + ); + } + + return { success: true }; + } } diff --git a/app/controllers/v1/users.ts b/app/controllers/v1/users.ts new file mode 100644 index 00000000..384876af --- /dev/null +++ b/app/controllers/v1/users.ts @@ -0,0 +1,141 @@ +import vine from "@vinejs/vine"; + +import type { HttpContext } from "@adonisjs/core/http"; +import router from "@adonisjs/core/services/router"; +import type { Constructor, LazyImport } from "@adonisjs/core/types/http"; +import db from "@adonisjs/lucid/services/db"; + +import User from "#app/models/user"; + +import BaseController from "../base_controller.js"; + +//should check if email is unique and more conditions on password +const createUserValidator = vine.compile( + vine.object({ + fullName: vine.string().optional(), + email: vine.string().email(), + password: vine.string().minLength(8), + }), +); + +const updateUserValidator = vine.compile( + vine.object({ + fullName: vine.string().optional(), + email: vine.string().email().optional(), + password: vine.string().minLength(8).optional(), + }), +); + +const paginationValidator = vine.compile( + vine.object({ + page: vine.number().min(1).optional(), + limit: vine.number().min(1).max(100).optional(), + }), +); + +const userIdParamValidator = vine.compile( + vine.object({ + id: vine.number(), + }), +); + +export default class UsersController extends BaseController { + $configureRoutes(controller: LazyImport>) { + router.get("/", [controller, "findAll"]).as("users.list"); + router.get("/:id", [controller, "findOne"]).as("users.show"); + router.delete("/:id", [controller, "delete"]).as("users.delete"); + router.patch("/:id", [controller, "update"]).as("users.update"); + router.post("/", [controller, "create"]).as("users.create"); + } + + async findAll({ request, auth }: HttpContext) { + await this.requireSuperUser(auth); + + const { page = 1, limit = 10 } = + await request.validateUsing(paginationValidator); + + const users = await User.query() + .select("id", "fullName", "email") + .paginate(page, limit); + + return { data: users }; + } + + async findOne({ request, auth }: HttpContext) { + const user = await request.validateUsing(userIdParamValidator); + + await this.requireSuperUser(auth); + + const targetUser = await User.query() + .select("id", "fullName", "email") + .where("id", user.id) + .firstOrFail() + .addErrorContext(() => `User with id ${user.id} not found`); + + return { data: targetUser }; + } + + async delete({ request, auth }: HttpContext) { + const user = await request.validateUsing(userIdParamValidator); + + await this.requireSuperUser(auth); + await db.transaction(async (trx) => { + const targetUser = await User.findOrFail(user.id, { + client: trx, + }).addErrorContext(() => `User with id ${user.id} not found`); + + await targetUser.delete(); + }); + + return { success: true }; + } + + async update({ request, auth }: HttpContext) { + const user = await request.validateUsing(userIdParamValidator); + + await this.requireSuperUser(auth); + + const payload = await request.validateUsing(updateUserValidator); + + const updatedUser = await db.transaction(async (trx) => { + const targetUser = await User.query({ client: trx }) + .where("id", user.id) + .firstOrFail() + .addErrorContext(() => `User with id ${user.id} not found`); + + targetUser.merge(payload); + await targetUser.save(); + + return targetUser; + }); + + return { + success: true, + data: { + id: updatedUser.id, + fullName: updatedUser.fullName, + email: updatedUser.email, + }, + }; + } + + async create({ request, auth }: HttpContext) { + await this.requireSuperUser(auth); + + const payload = await request.validateUsing(createUserValidator); + + const newUser = await db.transaction(async (trx) => { + const user = await User.create(payload, { client: trx }); + return user; + }); + + return { + success: true, + data: { + id: newUser.id, + fullName: newUser.fullName, + email: newUser.email, + }, + }; + } +} diff --git a/package-lock.json b/package-lock.json index 870cab37..a2816a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2724,7 +2724,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@japa/assert/-/assert-4.2.0.tgz", "integrity": "sha512-Krgrcee01BN1StlVwK5JQP6LL5t3DE3uFNbfFoDTfW7kQuHB0xh6yfaV0hrgcoiEjsqmm2OOsVWeju9aXK4vIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@poppinss/macroable": "^1.1.0", @@ -4024,7 +4024,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -4052,7 +4052,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -4962,7 +4962,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5392,7 +5392,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18"