Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin user #188

Merged
merged 5 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions devU-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions devU-api/src/authorization/authorization.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)

Expand Down
47 changes: 45 additions & 2 deletions devU-api/src/entities/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 newAdminUserId = req.body.newAdminUserId
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a more appropriate variable name

if (!newAdminUserId) {
return res.status(404).send('Not found')
}
await UserService.softDeleteAdmin(newAdminUserId)
res.status(204).send('User is no longer admin')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 204 shouldn't have a body

} 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)
Expand All @@ -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))
}
}
}
Expand Down Expand Up @@ -87,4 +130,4 @@ export async function _delete(req: Request, res: Response, next: NextFunction) {
}
}

export default { get, detail, post, put, _delete, getByCourse }
export default { get, detail, post, put, _delete, getByCourse, deleteAdmin, createNewAdmin, listAdmins }
16 changes: 16 additions & 0 deletions devU-api/src/entities/user/user.middlware.ts
Original file line number Diff line number Diff line change
@@ -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).send('Unauthorized')
}

const isAdmin = await UserService.isAdmin(userId)
if (!isAdmin!.isAdmin!) return res.status(403).send('unauthorized')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to be consistent with capitalization on "Unauthorized"


next()
}
3 changes: 3 additions & 0 deletions devU-api/src/entities/user/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
64 changes: 64 additions & 0 deletions devU-api/src/entities/user/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions devU-api/src/entities/user/user.serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export function serialize(user: UserModel): User {
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
preferredName: user.preferredName,
isAdmin: user.isAdmin,
}
}
44 changes: 42 additions & 2 deletions devU-api/src/entities/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thinking!

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() })
}
Expand All @@ -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 }
}
Expand All @@ -64,6 +100,10 @@ export default {
update,
_delete,
list,
isAdmin,
createAdmin,
softDeleteAdmin,
listAdmin,
ensure,
listByCourse,
}
2 changes: 1 addition & 1 deletion devU-api/src/entities/webhooks/webhooks.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Expand Down
14 changes: 14 additions & 0 deletions devU-api/src/migration/1731053786646-user-admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class UserAdmin1731053786646 implements MigrationInterface {
name = 'UserAdmin1731053786646'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "is_admin" boolean NOT NULL DEFAULT false`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "is_admin"`);
}

}
2 changes: 1 addition & 1 deletion devU-client/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions devU-client/src/redux/initialState/user.initialState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const defaultState: UserState = {
createdAt: '',
updatedAt: '',
preferredName: '',
isAdmin: false,
}

export default defaultState
1 change: 1 addition & 0 deletions devU-shared/src/types/user.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export type User = {
createdAt?: string
updatedAt?: string
preferredName?: string
isAdmin: boolean
}