1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-06-30 06:30:11 +02:00

feat: add user management

This commit is contained in:
Elias Schneider 2022-12-05 15:53:24 +01:00
parent 31b3f6cb2f
commit 7a3967fd6f
25 changed files with 751 additions and 47 deletions

View File

@ -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 {
@ -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) {

View File

@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
@ -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(
{

View File

@ -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;
}

View File

@ -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);

View File

@ -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 });
}
}

View File

@ -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)
) {}

View File

@ -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",
@ -23,8 +16,7 @@ export class UserDTO {
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
@MinLength(8)
password: string;
@Expose()

View File

@ -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";
@ -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));
}
@ -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));
}
@ -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));
}
}

View File

@ -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";
@ -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({

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -0,0 +1,126 @@
import {
Accordion,
Button,
Group,
PasswordInput,
Stack,
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 User from "../../types/user.type";
import toast from "../../utils/toast.util";
const showUpdateUserModal = (
modals: ModalsContextProps,
user: User,
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update {user.username}</Title>,
children: <Body user={user} modals={modals} getUsers={getUsers} />,
});
};
const Body = ({
user,
modals,
getUsers,
}: {
modals: ModalsContextProps;
user: User;
getUsers: () => void;
}) => {
const accountForm = useForm({
initialValues: {
username: user?.username,
email: user?.email,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
})
),
});
const passwordForm = useForm({
initialValues: {
password: "",
},
validate: yupResolver(
yup.object().shape({
password: yup.string().min(8),
})
),
});
return (
<Stack>
<form
id="accountForm"
onSubmit={accountForm.onSubmit(async (values) => {
userService
.update(user.id, {
email: values.email,
username: values.username,
})
.then(() => {
getUsers();
modals.closeAll();
})
.catch(toast.axiosError);
})}
>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput
type="email"
label="Email"
{...accountForm.getInputProps("email")}
/>
</Stack>
</form>
<Accordion>
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
<Accordion.Control>Passwort ändern</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={passwordForm.onSubmit(async (values) => {
userService
.update(user.id, {
password: values.password,
})
.then(() => toast.success("Password changed successfully"))
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Button variant="light" type="submit">
Save new password
</Button>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group position="right">
<Button type="submit" form="accountForm">
Save
</Button>
</Group>
</Stack>
);
};
export default showUpdateUserModal;

View File

@ -35,7 +35,7 @@ const SignInForm = () => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
.catch(toast.axiosError);
};
return (

View File

@ -20,7 +20,7 @@ const SignUpForm = () => {
const validationSchema = yup.object().shape({
email: yup.string().email().required(),
username: yup.string().required(),
username: yup.string().min(3).required(),
password: yup.string().min(8).required(),
});
@ -37,13 +37,13 @@ const SignUpForm = () => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
.catch(toast.axiosError);
};
const signUp = (email: string, username: string, password: string) => {
authService
.signUp(email, username, password)
.then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message));
.catch(toast.axiosError);
};
return (

View File

@ -1,6 +1,6 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link";
import { TbDoorExit, TbLink, TbSettings } from "react-icons/tb";
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
@ -22,10 +22,13 @@ const ActionAvatar = () => {
>
My shares
</Menu.Item>
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
My account
</Menu.Item>
{user!.isAdmin && (
<Menu.Item
component={Link}
href="/admin/config"
href="/admin"
icon={<TbSettings size={14} />}
>
Administration

View File

@ -13,23 +13,6 @@ const FileList = ({
shareId: string;
isLoading: boolean;
}) => {
const skeletonRows = [...Array(5)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={30} width={30} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={25} width={25} />
</td>
</tr>
));
const rows = files.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
@ -69,4 +52,21 @@ const FileList = ({
);
};
const skeletonRows = [...Array(5)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={30} width={30} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={25} width={25} />
</td>
</tr>
));
export default FileList;

View File

@ -0,0 +1,154 @@
import {
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import * as yup from "yup";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import userService from "../../services/user.service";
import toast from "../../utils/toast.util";
const Account = () => {
const user = useUser();
const modals = useModals();
const router = useRouter();
const accountForm = useForm({
initialValues: {
username: user?.username,
email: user?.email,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
})
),
});
const passwordForm = useForm({
initialValues: {
oldPassword: "",
password: "",
},
validate: yupResolver(
yup.object().shape({
oldPassword: yup.string().min(8),
password: yup.string().min(8),
})
),
});
if (!user) {
router.push("/");
return;
}
return (
<Container size="sm">
<Title order={3} mb="xs">
My account
</Title>
<Paper withBorder p="xl">
<Title order={5} mb="xs">
Account Info
</Title>
<form
onSubmit={accountForm.onSubmit((values) =>
userService
.updateCurrentUser({
username: values.username,
email: values.email,
})
.then(() => toast.success("User updated successfully"))
.catch(toast.axiosError)
)}
>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput
type="email"
label="Email"
{...accountForm.getInputProps("email")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Password
</Title>
<form
onSubmit={passwordForm.onSubmit((values) =>
authService
.updatePassword(values.oldPassword, values.password)
.then(() => {
toast.success("Password updated successfully");
passwordForm.reset();
})
.catch(toast.axiosError)
)}
>
<Stack>
<PasswordInput
label="Old password"
{...passwordForm.getInputProps("oldPassword")}
/>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Center mt={80}>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all your
active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Center>
</Container>
);
};
export default Account;

View File

@ -1,9 +1,12 @@
import { Space } from "@mantine/core";
import { Space, Title } from "@mantine/core";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
const AdminConfig = () => {
return (
<>
<Title mb={30} order={3}>
Configuration
</Title>
<AdminConfigTable />
<Space h="xl" />
</>

View File

@ -0,0 +1,62 @@
import { Col, Container, createStyles, Grid, Paper, Text } from "@mantine/core";
import Link from "next/link";
import { TbSettings, TbUsers } from "react-icons/tb";
const managementOptions = [
{
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
];
const useStyles = createStyles((theme) => ({
item: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
height: 90,
"&:hover": {
boxShadow: `${theme.shadows.sm} !important`,
transform: "scale(1.01)",
},
},
}));
const Admin = () => {
const { classes, theme } = useStyles();
return (
<Container size="xl">
<Paper withBorder radius="md" p={40}>
<Grid mt="md">
{managementOptions.map((item) => {
return (
<Col xs={6} key={item.route}>
<Paper
withBorder
component={Link}
href={item.route}
key={item.title}
className={classes.item}
>
<item.icon color={theme.colors.victoria[5]} size={35} />
<Text mt={7}>{item.title}</Text>
</Paper>
</Col>
);
})}
</Grid>
</Paper>
</Container>
);
};
export default Admin;

View File

@ -0,0 +1,73 @@
import { Button, Group, Space, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb";
import ManageUserTable from "../../components/admin/ManageUserTable";
import showCreateUserModal from "../../components/admin/showCreateUserModal";
import userService from "../../services/user.service";
import User from "../../types/user.type";
import toast from "../../utils/toast.util";
const Users = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const modals = useModals();
const getUsers = () => {
setIsLoading(true);
userService.list().then((users) => {
setUsers(users);
setIsLoading(false);
});
};
const deleteUser = (user: User) => {
modals.openConfirmModal({
title: `Delete ${user.username}?`,
children: (
<Text size="sm">
Do you really want to delete <b>{user.username}</b> and all his
shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
userService
.remove(user.id)
.then(() => setUsers(users.filter((v) => v.id != user.id)))
.catch(toast.axiosError);
},
});
};
useEffect(() => {
getUsers();
}, []);
return (
<>
<Group position="apart" align="baseline" mb={20}>
<Title mb={30} order={3}>
User management
</Title>
<Button
onClick={() => showCreateUserModal(modals, getUsers)}
leftIcon={<TbPlus size={20} />}
>
Create
</Button>
</Group>
<ManageUserTable
users={users}
getUsers={getUsers}
deleteUser={deleteUser}
isLoading={isLoading}
/>
<Space h="xl" />
</>
);
};
export default Users;

View File

@ -44,9 +44,14 @@ const refreshAccessToken = async () => {
}
};
const updatePassword = async (oldPassword: string, password: string) => {
await api.patch("/auth/password", { oldPassword, password });
};
export default {
signIn,
signUp,
signOut,
refreshAccessToken,
updatePassword
};

View File

@ -1,7 +1,36 @@
import { CurrentUser } from "../types/user.type";
import {
CreateUser,
CurrentUser,
UpdateCurrentUser,
UpdateUser,
} from "../types/user.type";
import api from "./api.service";
import authService from "./auth.service";
const list = async () => {
return (await api.get("/users")).data;
};
const create = async (user: CreateUser) => {
return (await api.post("/users", user)).data;
};
const update = async (id: string, user: UpdateUser) => {
return (await api.patch(`/users/${id}`, user)).data;
};
const remove = async (id: string) => {
await api.delete(`/users/${id}`);
};
const updateCurrentUser = async (user: UpdateCurrentUser) => {
return (await api.patch("/users/me", user)).data;
};
const removeCurrentUser = async () => {
await api.delete("/users/me");
};
const getCurrentUser = async (): Promise<CurrentUser | null> => {
try {
await authService.refreshAccessToken();
@ -12,5 +41,11 @@ const getCurrentUser = async (): Promise<CurrentUser | null> => {
};
export default {
list,
create,
update,
remove,
getCurrentUser,
updateCurrentUser,
removeCurrentUser,
};

View File

@ -1,9 +1,29 @@
export default interface User {
type User = {
id: string;
firstName?: string;
lastName?: string;
username: string;
email: string;
isAdmin: boolean;
}
};
export interface CurrentUser extends User {}
export type CreateUser = {
username: string;
email: string;
password: string,
isAdmin?: boolean;
};
export type UpdateUser = {
username?: string;
email?: string;
password?: string,
isAdmin?: boolean;
};
export type UpdateCurrentUser = {
username?: string;
email?: string;
};
export type CurrentUser = User & {};
export default User;

View File

@ -10,6 +10,9 @@ const error = (message: string) =>
message: message,
});
const axiosError = (axiosError: any) =>
error(axiosError?.response?.data?.message ?? "An unknown error occured");
const success = (message: string) =>
showNotification({
icon: <TbCheck />,
@ -22,5 +25,6 @@ const success = (message: string) =>
const toast = {
error,
success,
axiosError,
};
export default toast;