diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index f1d544a1..6de47a1a 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -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) { diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index f9da9aed..e6970697 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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( { diff --git a/backend/src/auth/dto/updatePassword.dto.ts b/backend/src/auth/dto/updatePassword.dto.ts new file mode 100644 index 00000000..483ed84c --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index c6bbf8a8..852eedf1 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,7 +6,7 @@ import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.useGlobalPipes(new ValidationPipe()); + app.useGlobalPipes(new ValidationPipe({whitelist: true})); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); app.set("trust proxy", true); diff --git a/backend/src/user/dto/createUser.dto.ts b/backend/src/user/dto/createUser.dto.ts new file mode 100644 index 00000000..785a1dbd --- /dev/null +++ b/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) { + return plainToClass(CreateUserDTO, partial, { excludeExtraneousValues: true }); + } +} diff --git a/backend/src/user/dto/updateOwnUser.dto.ts b/backend/src/user/dto/updateOwnUser.dto.ts index b3cfddbf..604d3804 100644 --- a/backend/src/user/dto/updateOwnUser.dto.ts +++ b/backend/src/user/dto/updateOwnUser.dto.ts @@ -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) ) {} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 512d1746..85d661a8 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/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", @@ -23,8 +16,7 @@ export class UserDTO { @IsEmail() email: string; - @IsNotEmpty() - @IsString() + @MinLength(8) password: string; @Expose() diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index d517f8b0..08ebf73f 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -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)); } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index fdb7ce59..02f382b1 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -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({ diff --git a/frontend/src/components/admin/ManageUserTable.tsx b/frontend/src/components/admin/ManageUserTable.tsx new file mode 100644 index 00000000..52e61ccf --- /dev/null +++ b/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 ( + + + + + + + + + + + + {isLoading + ? skeletonRows + : users.map((user) => ( + + + + + + + ))} + +
UsernameEmailAdmin
{user.username}{user.email}{user.isAdmin && } + + + showUpdateUserModal(modals, user, getUsers) + } + > + + + deleteUser(user)} + > + + + +
+
+ ); +}; + +const skeletonRows = [...Array(10)].map((v, i) => ( + + + + + + + + + + + + + + +)); + +export default ManageUserTable; diff --git a/frontend/src/components/admin/showCreateUserModal.tsx b/frontend/src/components/admin/showCreateUserModal.tsx new file mode 100644 index 00000000..6a7d4a2c --- /dev/null +++ b/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: Create user, + children: , + }); +}; + +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 ( + +
{ + console.log(values) + userService + .create(values) + .then(() => { + getUsers(); + modals.closeAll(); + }) + .catch(toast.axiosError); + })} + > + + + + + + + + + + + + +
+
+ ); +}; + +export default showCreateUserModal; diff --git a/frontend/src/components/admin/showUpdateConfigVariableModal.tsx b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx index f74e1744..561cf184 100644 --- a/frontend/src/components/admin/showUpdateConfigVariableModal.tsx +++ b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx @@ -84,7 +84,7 @@ const Body = ({ getConfigVariables(); modals.closeAll(); }) - .catch((e) => toast.error(e.response.data.message)); + .catch(toast.axiosError); }} > Save diff --git a/frontend/src/components/admin/showUpdateUserModal.tsx b/frontend/src/components/admin/showUpdateUserModal.tsx new file mode 100644 index 00000000..57b50d66 --- /dev/null +++ b/frontend/src/components/admin/showUpdateUserModal.tsx @@ -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: Update {user.username}, + children: , + }); +}; + +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 ( + +
{ + userService + .update(user.id, { + email: values.email, + username: values.username, + }) + .then(() => { + getUsers(); + modals.closeAll(); + }) + .catch(toast.axiosError); + })} + > + + + + +
+ + + Passwort ändern + +
{ + userService + .update(user.id, { + password: values.password, + }) + .then(() => toast.success("Password changed successfully")) + .catch(toast.axiosError); + })} + > + + + + +
+
+
+
+ + + +
+ ); +}; + +export default showUpdateUserModal; diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index e0b77618..2f48f296 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -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 ( diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index 2e4a5e84..a8cc6733 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -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 ( diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/navBar/ActionAvatar.tsx index 0fc4df96..37eca730 100644 --- a/frontend/src/components/navBar/ActionAvatar.tsx +++ b/frontend/src/components/navBar/ActionAvatar.tsx @@ -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 + }> + My account + {user!.isAdmin && ( } > Administration diff --git a/frontend/src/components/share/FileList.tsx b/frontend/src/components/share/FileList.tsx index 8ffeef0c..daa03abc 100644 --- a/frontend/src/components/share/FileList.tsx +++ b/frontend/src/components/share/FileList.tsx @@ -13,23 +13,6 @@ const FileList = ({ shareId: string; isLoading: boolean; }) => { - const skeletonRows = [...Array(5)].map((c, i) => ( - - - - - - - - - - - - - - - )); - const rows = files.map((file) => ( {file.name} @@ -69,4 +52,21 @@ const FileList = ({ ); }; +const skeletonRows = [...Array(5)].map((c, i) => ( + + + + + + + + + + + + + + +)); + export default FileList; diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx new file mode 100644 index 00000000..85e8927b --- /dev/null +++ b/frontend/src/pages/account/index.tsx @@ -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 ( + + + My account + + + + Account Info + +
+ userService + .updateCurrentUser({ + username: values.username, + email: values.email, + }) + .then(() => toast.success("User updated successfully")) + .catch(toast.axiosError) + )} + > + + + + + + + +
+
+ + + Password + +
+ authService + .updatePassword(values.oldPassword, values.password) + .then(() => { + toast.success("Password updated successfully"); + passwordForm.reset(); + }) + .catch(toast.axiosError) + )} + > + + + + + + + +
+
+
+ +
+
+ ); +}; + +export default Account; diff --git a/frontend/src/pages/admin/config.tsx b/frontend/src/pages/admin/config.tsx index 6e336120..37e2b0b8 100644 --- a/frontend/src/pages/admin/config.tsx +++ b/frontend/src/pages/admin/config.tsx @@ -1,9 +1,12 @@ -import { Space } from "@mantine/core"; +import { Space, Title } from "@mantine/core"; import AdminConfigTable from "../../components/admin/AdminConfigTable"; const AdminConfig = () => { return ( <> + + Configuration + diff --git a/frontend/src/pages/admin/index.tsx b/frontend/src/pages/admin/index.tsx new file mode 100644 index 00000000..6140f8d9 --- /dev/null +++ b/frontend/src/pages/admin/index.tsx @@ -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 ( + + + + {managementOptions.map((item) => { + return ( + + + + {item.title} + + + ); + })} + + + + ); +}; + +export default Admin; diff --git a/frontend/src/pages/admin/users.tsx b/frontend/src/pages/admin/users.tsx new file mode 100644 index 00000000..2ab3a9a0 --- /dev/null +++ b/frontend/src/pages/admin/users.tsx @@ -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([]); + 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: ( + + Do you really want to delete {user.username} and all his + shares? + + ), + 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 ( + <> + + + User management + + + + + + + + ); +}; + +export default Users; diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 19671a94..9f4cba30 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -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 }; diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index 6c90e1f6..43060ff5 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -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 => { try { await authService.refreshAccessToken(); @@ -12,5 +41,11 @@ const getCurrentUser = async (): Promise => { }; export default { + list, + create, + update, + remove, getCurrentUser, + updateCurrentUser, + removeCurrentUser, }; diff --git a/frontend/src/types/user.type.ts b/frontend/src/types/user.type.ts index f80447ab..a4879a8e 100644 --- a/frontend/src/types/user.type.ts +++ b/frontend/src/types/user.type.ts @@ -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; diff --git a/frontend/src/utils/toast.util.tsx b/frontend/src/utils/toast.util.tsx index 3e1f56e5..069c3b31 100644 --- a/frontend/src/utils/toast.util.tsx +++ b/frontend/src/utils/toast.util.tsx @@ -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: , @@ -22,5 +25,6 @@ const success = (message: string) => const toast = { error, success, + axiosError, }; export default toast;