Skip to content

Commit

Permalink
Merge pull request #188 from makeopensource/148-admin-user
Browse files Browse the repository at this point in the history
admin user
  • Loading branch information
jessehartloff authored Nov 12, 2024
2 parents e3b467b + da9d955 commit eccf8ad
Show file tree
Hide file tree
Showing 14 changed files with 200 additions and 7 deletions.
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 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)
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).json({ 'error': 'Unauthorized' })
}

const isAdmin = await UserService.isAdmin(userId)
if (!isAdmin!.isAdmin!) return res.status(403).json({ "error": '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
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
}

0 comments on commit eccf8ad

Please sign in to comment.