diff --git a/devU-api/package.json b/devU-api/package.json index 1471b189..27951f8e 100644 --- a/devU-api/package.json +++ b/devU-api/package.json @@ -5,6 +5,7 @@ "scripts": { "start": "npm run migrate && ts-node-dev src/index.ts", "migrate": "npm run typeorm -- migration:run -d src/database.ts", + "create-migrate": "npx typeorm-ts-node-commonjs migration:generate -d src/database.ts", "update-shared": "cd ../devU-shared && npm run build-local && cd ../devU-api && npm i", "typeorm": "typeorm-ts-node-commonjs", "test": "jest --passWithNoTests", diff --git a/devU-api/src/authentication/login.developer/login.developer.controller.ts b/devU-api/src/authentication/login.developer/login.developer.controller.ts index 8593f10b..ede9f56e 100644 --- a/devU-api/src/authentication/login.developer/login.developer.controller.ts +++ b/devU-api/src/authentication/login.developer/login.developer.controller.ts @@ -10,7 +10,7 @@ export async function callback(req: Request, res: Response, next: NextFunction) try { const { email = '', externalId = '' } = req.body - const { user } = await UserService.ensure({ email, externalId }) + const { user } = await UserService.ensure({ email, externalId, isAdmin: false }) const refreshToken = AuthService.createRefreshToken(user) res.cookie('refreshToken', refreshToken, refreshCookieOptions) diff --git a/devU-api/src/authorization/authorization.middleware.ts b/devU-api/src/authorization/authorization.middleware.ts index 567554a7..4fdf73bd 100644 --- a/devU-api/src/authorization/authorization.middleware.ts +++ b/devU-api/src/authorization/authorization.middleware.ts @@ -5,6 +5,7 @@ import UserCourseService from '../entities/userCourse/userCourse.service' import RoleService from '../entities/role/role.service' import { serialize } from '../entities/role/role.serializer' import { Role } from '../../devu-shared-modules' +import UserService from '../entities/user/user.service' /** * Are you authorized to access this endpoint? @@ -23,6 +24,14 @@ export function isAuthorized(permission: string, permissionIfSelf?: string) { return res.status(404).json(NotFound) } + // check if admin + const user = await UserService.isAdmin(userId!) + if (user && user.isAdmin!) { + // no role checks needed + // user is admin ! + return next() + } + // Pull userCourse const userCourse = await UserCourseService.retrieveByCourseAndUser(courseId, userId) diff --git a/devU-api/src/entities/user/user.controller.ts b/devU-api/src/entities/user/user.controller.ts index b36a2eaa..6b5cefce 100644 --- a/devU-api/src/entities/user/user.controller.ts +++ b/devU-api/src/entities/user/user.controller.ts @@ -31,6 +31,7 @@ export async function detail(req: Request, res: Response, next: NextFunction) { next(err) } } + //USE THIS export async function getByCourse(req: Request, res: Response, next: NextFunction) { try { @@ -48,6 +49,48 @@ export async function getByCourse(req: Request, res: Response, next: NextFunctio } } +// create an admin, only an admin can create a new admin +export async function createNewAdmin(req: Request, res: Response, next: NextFunction) { + try { + let newAdminUserId = req.body.newAdminUserId + if (!newAdminUserId) { + return res.status(404).send('Not found') + } + + await UserService.createAdmin(newAdminUserId!) + res.status(201).send('Created new admin') + } catch (e) { + next(e) + } +} + + +// delete an admin, only an admin can delete an admin +export async function deleteAdmin(req: Request, res: Response, next: NextFunction) { + try { + let deleteAdminUserId = req.body.newAdminUserId + if (!deleteAdminUserId) { + return res.status(404).send('Not found') + } + await UserService.softDeleteAdmin(deleteAdminUserId) + res.status(204) + } catch (e) { + next(e) + } +} + +// list admins +export async function listAdmins(req: Request, res: Response, next: NextFunction) { + try { + let users = await UserService.listAdmin() + const response = users.map(serialize) + res.status(200).json(response) + } catch (e) { + next(e) + } +} + + export async function post(req: Request, res: Response, next: NextFunction) { try { const user = await UserService.create(req.body) @@ -56,7 +99,7 @@ export async function post(req: Request, res: Response, next: NextFunction) { res.status(201).json(response) } catch (err) { if (err instanceof Error) { - res.status(400).json(new GenericResponse(err.message)) + res.status(400).json(new GenericResponse(err.message)) } } } @@ -87,4 +130,4 @@ export async function _delete(req: Request, res: Response, next: NextFunction) { } } -export default { get, detail, post, put, _delete, getByCourse } \ No newline at end of file +export default { get, detail, post, put, _delete, getByCourse, deleteAdmin, createNewAdmin, listAdmins } \ No newline at end of file diff --git a/devU-api/src/entities/user/user.middlware.ts b/devU-api/src/entities/user/user.middlware.ts new file mode 100644 index 00000000..5e046a5f --- /dev/null +++ b/devU-api/src/entities/user/user.middlware.ts @@ -0,0 +1,16 @@ +import { NextFunction, Request, Response } from 'express' +import UserService from './user.service' + +// is admin middleware, use this when marking an endpoint as only accessible by admin +// different from userCourse permissions. this is attached to a user instead of a course level permission +export async function isAdmin(req: Request, res: Response, next: NextFunction) { + const userId = req.currentUser?.userId + if (!userId) { + return res.status(403).json({ 'error': 'Unauthorized' }) + } + + const isAdmin = await UserService.isAdmin(userId) + if (!isAdmin!.isAdmin!) return res.status(403).json({ "error": 'Unauthorized' }) + + next() +} diff --git a/devU-api/src/entities/user/user.model.ts b/devU-api/src/entities/user/user.model.ts index 59d0c04a..70a5ad07 100644 --- a/devU-api/src/entities/user/user.model.ts +++ b/devU-api/src/entities/user/user.model.ts @@ -40,4 +40,7 @@ export default class UserModel { @Column({ name: 'preferred_name', length: 128, nullable: true }) preferredName: string + + @Column({ name: 'is_admin', default: false }) + isAdmin: boolean } diff --git a/devU-api/src/entities/user/user.router.ts b/devU-api/src/entities/user/user.router.ts index d2557d4b..0bb0be4a 100644 --- a/devU-api/src/entities/user/user.router.ts +++ b/devU-api/src/entities/user/user.router.ts @@ -5,6 +5,7 @@ import { asInt } from '../../middleware/validator/generic.validator' import { isAuthorized } from '../../authorization/authorization.middleware' import UserController from './user.controller' +import { isAdmin } from './user.middlware' const Router = express.Router() @@ -70,6 +71,69 @@ Router.get('/:id', asInt(), UserController.detail) Router.get('/course/:id', /* isAuthorized('courseViewAll'), */ asInt(), UserController.getByCourse) // TODO: Removed authorization for now, fix later +const adminRouter = express.Router() + +Router.use('/admin', isAdmin, adminRouter) + +/** + * @swagger + * /users/admin/: + * get: + * summary: list admin users + * tags: + * - Users + * responses: + * '200': + * description: OK + */ +adminRouter.get('/list', UserController.listAdmins) + +/** + * @swagger + * /users/admin/: + * post: + * summary: Make a user admin + * tags: + * - Users + * responses: + * '200': + * description: OK + * requestBody: + * application/json: + * schema: + * type: object + * required: + * - userId + * properties: + * newAdminUserId: + * description: "User id to make admin" + * type: number + */ +adminRouter.post('/', UserController.createNewAdmin) + +/** + * @swagger + * /users/admin/: + * delete: + * summary: delete a user admin + * tags: + * - Users + * responses: + * '200': + * description: OK + * requestBody: + * application/json: + * schema: + * type: object + * required: + * - userId + * properties: + * newAdminUserId: + * description: "User id to make admin" + * type: number + */ +adminRouter.delete('/', UserController.deleteAdmin) + /** * @swagger * /users: diff --git a/devU-api/src/entities/user/user.serializer.ts b/devU-api/src/entities/user/user.serializer.ts index 4109f31c..68dad1b0 100644 --- a/devU-api/src/entities/user/user.serializer.ts +++ b/devU-api/src/entities/user/user.serializer.ts @@ -10,5 +10,6 @@ export function serialize(user: UserModel): User { createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), preferredName: user.preferredName, + isAdmin: user.isAdmin, } } diff --git a/devU-api/src/entities/user/user.service.ts b/devU-api/src/entities/user/user.service.ts index b25bb9a7..b0e62ec4 100644 --- a/devU-api/src/entities/user/user.service.ts +++ b/devU-api/src/entities/user/user.service.ts @@ -10,6 +10,13 @@ import UserCourseService from '../userCourse/userCourse.service' const connect = () => dataSource.getRepository(UserModel) export async function create(user: User) { + // check if the first account + const users = await connect().count({ take: 1 }) + if (users == 0) { + // make first created account admin + user.isAdmin = true + } + return await connect().save(user) } @@ -29,6 +36,35 @@ export async function retrieve(id: number) { return await connect().findOneBy({ id, deletedAt: IsNull() }) } +export async function isAdmin(id: number) { + return await connect().findOne({ + where: { id, deletedAt: IsNull() }, + select: ['isAdmin'], + }) +} + +export async function createAdmin(id: number) { + return await connect().update(id, { isAdmin: true }) +} + +// soft deletes an admin +export async function softDeleteAdmin(id: number) { + let res = await connect().count({ take: 2, where: { isAdmin: true } }) + // check if this deletes the last admin + // there must always be at least 1 admin + if (res == 1) { + throw Error('Unable to delete, only a single admin remains') + } + + return await connect().update(id, { isAdmin: false }) +} + +// list all admins +export async function listAdmin() { + return await connect().findBy({ isAdmin: true, deletedAt: IsNull() }) +} + + export async function retrieveByEmail(email: string) { return await connect().findOneBy({ email: email, deletedAt: IsNull() }) } @@ -46,13 +82,13 @@ export async function listByCourse(courseId: number, userRole?: string) { } export async function ensure(userInfo: User) { - const { externalId, email } = userInfo + const { externalId } = userInfo const user = await connect().findOneBy({ externalId }) if (user) return { user, isNewUser: false } - const newUser = await create({ email, externalId }) + const newUser = await create(userInfo) return { user: newUser, isNewUser: true } } @@ -64,6 +100,10 @@ export default { update, _delete, list, + isAdmin, + createAdmin, + softDeleteAdmin, + listAdmin, ensure, listByCourse, } diff --git a/devU-api/src/entities/webhooks/webhooks.middleware.ts b/devU-api/src/entities/webhooks/webhooks.middleware.ts index 97bf9fa5..6c40389f 100644 --- a/devU-api/src/entities/webhooks/webhooks.middleware.ts +++ b/devU-api/src/entities/webhooks/webhooks.middleware.ts @@ -59,7 +59,7 @@ export function responseInterceptor(req: Request, res: Response, next: NextFunct console.log('Sent webhook successfully') }, ).catch(err => { - console.error('Error sending webhook', err) + console.warn('Error sending webhook', err) }) } } diff --git a/devU-api/src/migration/1731053786646-user-admin.ts b/devU-api/src/migration/1731053786646-user-admin.ts new file mode 100644 index 00000000..07505e51 --- /dev/null +++ b/devU-api/src/migration/1731053786646-user-admin.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserAdmin1731053786646 implements MigrationInterface { + name = 'UserAdmin1731053786646' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "is_admin" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_admin"`); + } + +} diff --git a/devU-client/package.json b/devU-client/package.json index df476526..743abc69 100644 --- a/devU-client/package.json +++ b/devU-client/package.json @@ -1,6 +1,6 @@ { "scripts": { - "update-shared": "npm update devu-shared-modules", + "update-shared": "cd ../devU-shared && npm run build-local && cd ../devU-client && npm i", "local": "cross-env NODE_ENV=local webpack-dev-server --mode development", "start": "cross-env NODE_ENV=development webpack-dev-server -d --open --mode development", "prod": "cross-env NODE_ENV=production webpack-dev-server -d --open --mode development", diff --git a/devU-client/src/redux/initialState/user.initialState.ts b/devU-client/src/redux/initialState/user.initialState.ts index 17d26f44..4284c0d8 100644 --- a/devU-client/src/redux/initialState/user.initialState.ts +++ b/devU-client/src/redux/initialState/user.initialState.ts @@ -8,6 +8,7 @@ const defaultState: UserState = { createdAt: '', updatedAt: '', preferredName: '', + isAdmin: false, } export default defaultState diff --git a/devU-shared/src/types/user.types.ts b/devU-shared/src/types/user.types.ts index b19cf9fa..8421b1d1 100644 --- a/devU-shared/src/types/user.types.ts +++ b/devU-shared/src/types/user.types.ts @@ -5,4 +5,5 @@ export type User = { createdAt?: string updatedAt?: string preferredName?: string + isAdmin: boolean }