Skip to content

Commit

Permalink
feat: add user management
Browse files Browse the repository at this point in the history
  • Loading branch information
stonith404 committed Dec 5, 2022
1 parent 31b3f6c commit 7a3967f
Show file tree
Hide file tree
Showing 25 changed files with 751 additions and 47 deletions.
12 changes: 12 additions & 0 deletions backend/src/auth/auth.controller.ts
Expand Up @@ -3,14 +3,20 @@ import {
Controller,
ForbiddenException,
HttpCode,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service";
import { GetUser } from "./decorator/getUser.decorator";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { JwtGuard } from "./guard/jwt.guard";

@Controller("auth")
export class AuthController {
Expand All @@ -34,6 +40,12 @@ export class AuthController {
return this.authService.signIn(dto);
}

@Patch("password")
@UseGuards(JwtGuard)
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
await this.authService.updatePassword(user, dto.oldPassword, dto.password);
}

@Post("token")
@HttpCode(200)
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/auth/auth.service.ts
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
Expand Down Expand Up @@ -68,6 +69,18 @@ export class AuthService {
return { accessToken, refreshToken };
}

async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (argon.verify(user.password, oldPassword))
throw new ForbiddenException("Invalid password");

const hash = await argon.hash(newPassword);

this.prisma.user.update({
where: { id: user.id },
data: { password: hash },
});
}

async createAccessToken(user: User) {
return this.jwtService.sign(
{
Expand Down
8 changes: 8 additions & 0 deletions backend/src/auth/dto/updatePassword.dto.ts
@@ -0,0 +1,8 @@
import { PickType } from "@nestjs/mapped-types";
import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";

export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
oldPassword: string;
}
2 changes: 1 addition & 1 deletion backend/src/main.ts
Expand Up @@ -6,7 +6,7 @@ import { AppModule } from "./app.module";

async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.useGlobalPipes(new ValidationPipe({whitelist: true}));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

app.set("trust proxy", true);
Expand Down
14 changes: 14 additions & 0 deletions backend/src/user/dto/createUser.dto.ts
@@ -0,0 +1,14 @@
import { Expose, plainToClass } from "class-transformer";
import { Allow } from "class-validator";
import { UserDTO } from "./user.dto";

export class CreateUserDTO extends UserDTO{

@Expose()
@Allow()
isAdmin: boolean;

from(partial: Partial<CreateUserDTO>) {
return plainToClass(CreateUserDTO, partial, { excludeExtraneousValues: true });
}
}
2 changes: 1 addition & 1 deletion backend/src/user/dto/updateOwnUser.dto.ts
Expand Up @@ -2,5 +2,5 @@ import { OmitType, PartialType } from "@nestjs/mapped-types";
import { UserDTO } from "./user.dto";

export class UpdateOwnUserDTO extends PartialType(
OmitType(UserDTO, ["isAdmin"] as const)
OmitType(UserDTO, ["isAdmin", "password"] as const)
) {}
12 changes: 2 additions & 10 deletions backend/src/user/dto/user.dto.ts
@@ -1,17 +1,10 @@
import { Expose, plainToClass } from "class-transformer";
import {
IsEmail,
IsNotEmpty,
IsString,
Length,
Matches,
} from "class-validator";
import { IsEmail, Length, Matches, MinLength } from "class-validator";

export class UserDTO {
@Expose()
id: string;

@Expose()
@Expose()
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
message: "Username can only contain letters, numbers, dots and underscores",
Expand All @@ -23,8 +16,7 @@ export class UserDTO {
@IsEmail()
email: string;

@IsNotEmpty()
@IsString()
@MinLength(8)
password: string;

@Expose()
Expand Down
11 changes: 8 additions & 3 deletions backend/src/user/user.controller.ts
Expand Up @@ -12,6 +12,8 @@ import { User } from "@prisma/client";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";
import { UserSevice } from "./user.service";
Expand All @@ -29,7 +31,10 @@ export class UserController {

@Patch("me")
@UseGuards(JwtGuard)
async updateCurrentUser(@GetUser() user: User, @Body() data: UpdateUserDto) {
async updateCurrentUser(
@GetUser() user: User,
@Body() data: UpdateOwnUserDTO
) {
return new UserDTO().from(await this.userService.update(user.id, data));
}

Expand All @@ -48,7 +53,7 @@ export class UserController {

@Post()
@UseGuards(JwtGuard, AdministratorGuard)
async create(@Body() user: UserDTO) {
async create(@Body() user: CreateUserDTO) {
return new UserDTO().from(await this.userService.create(user));
}

Expand All @@ -60,7 +65,7 @@ export class UserController {

@Delete(":id")
@UseGuards(JwtGuard, AdministratorGuard)
async delete(@Param() id: string) {
async delete(@Param("id") id: string) {
return new UserDTO().from(await this.userService.delete(id));
}
}
3 changes: 2 additions & 1 deletion backend/src/user/user.service.ts
Expand Up @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";

Expand All @@ -17,7 +18,7 @@ export class UserSevice {
return await this.prisma.user.findUnique({ where: { id } });
}

async create(dto: UserDTO) {
async create(dto: CreateUserDTO) {
const hash = await argon.hash(dto.password);
try {
return await this.prisma.user.create({
Expand Down
86 changes: 86 additions & 0 deletions frontend/src/components/admin/ManageUserTable.tsx
@@ -0,0 +1,86 @@
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
import User from "../../types/user.type";
import showUpdateUserModal from "./showUpdateUserModal";

const ManageUserTable = ({
users,
getUsers,
deleteUser,
isLoading,
}: {
users: User[];
getUsers: () => void;
deleteUser: (user: User) => void;
isLoading: boolean;
}) => {
const modals = useModals();

return (
<Box sx={{ display: "block", overflowX: "auto", whiteSpace: "nowrap" }}>
<Table verticalSpacing="sm" highlightOnHover>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: users.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.email}</td>
<td>{user.isAdmin && <TbCheck />}</td>
<td>
<Group position="right">
<ActionIcon
variant="light"
color="primary"
size="sm"
onClick={() =>
showUpdateUserModal(modals, user, getUsers)
}
>
<TbEdit />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => deleteUser(user)}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
);
};

const skeletonRows = [...Array(10)].map((v, i) => (
<tr key={i}>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
</tr>
));

export default ManageUserTable;
88 changes: 88 additions & 0 deletions frontend/src/components/admin/showCreateUserModal.tsx
@@ -0,0 +1,88 @@
import {
Button,
Group,
Input,
PasswordInput,
Stack,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup";
import userService from "../../services/user.service";
import toast from "../../utils/toast.util";

const showCreateUserModal = (
modals: ModalsContextProps,
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Create user</Title>,
children: <Body modals={modals} getUsers={getUsers} />,
});
};

const Body = ({
modals,
getUsers,
}: {
modals: ModalsContextProps;
getUsers: () => void;
}) => {
const form = useForm({
initialValues: {
username: "",
email: "",
password: "",
isAdmin: false,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
password: yup.string().min(8),
})
),
});

return (
<Stack>
<form
onSubmit={form.onSubmit(async (values) => {
console.log(values)
userService
.create(values)
.then(() => {
getUsers();
modals.closeAll();
})
.catch(toast.axiosError);
})}
>
<Stack>
<TextInput label="Username" {...form.getInputProps("username")} />
<TextInput
type="email"
label="Email"
{...form.getInputProps("email")}
/>
<PasswordInput
label="New password"
{...form.getInputProps("password")}
/>


<Switch labelPosition="left" label="Admin privileges" {...form.getInputProps("isAdmin")} />

<Group position="right">
<Button type="submit">Create</Button>
</Group>
</Stack>
</form>
</Stack>
);
};

export default showCreateUserModal;
Expand Up @@ -84,7 +84,7 @@ const Body = ({
getConfigVariables();
modals.closeAll();
})
.catch((e) => toast.error(e.response.data.message));
.catch(toast.axiosError);
}}
>
Save
Expand Down

0 comments on commit 7a3967f

Please sign in to comment.