mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-02-19 01:55:48 -05:00
feat: add user management
This commit is contained in:
parent
31b3f6cb2f
commit
7a3967fd6f
25 changed files with 751 additions and 47 deletions
|
@ -3,14 +3,20 @@ import {
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { GetUser } from "./decorator/getUser.decorator";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
||||||
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
|
import { JwtGuard } from "./guard/jwt.guard";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
@ -34,6 +40,12 @@ export class AuthController {
|
||||||
return this.authService.signIn(dto);
|
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")
|
@Post("token")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
@ -68,6 +69,18 @@ export class AuthService {
|
||||||
return { accessToken, refreshToken };
|
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) {
|
async createAccessToken(user: User) {
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{
|
{
|
||||||
|
|
8
backend/src/auth/dto/updatePassword.dto.ts
Normal file
8
backend/src/auth/dto/updatePassword.dto.ts
Normal 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;
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import { AppModule } from "./app.module";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
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.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
14
backend/src/user/dto/createUser.dto.ts
Normal file
14
backend/src/user/dto/createUser.dto.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,5 +2,5 @@ import { OmitType, PartialType } from "@nestjs/mapped-types";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class UpdateOwnUserDTO extends PartialType(
|
export class UpdateOwnUserDTO extends PartialType(
|
||||||
OmitType(UserDTO, ["isAdmin"] as const)
|
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||||
) {}
|
) {}
|
||||||
|
|
|
@ -1,17 +1,10 @@
|
||||||
import { Expose, plainToClass } from "class-transformer";
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
import {
|
import { IsEmail, Length, Matches, MinLength } from "class-validator";
|
||||||
IsEmail,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsString,
|
|
||||||
Length,
|
|
||||||
Matches,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class UserDTO {
|
export class UserDTO {
|
||||||
@Expose()
|
@Expose()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
|
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
|
||||||
message: "Username can only contain letters, numbers, dots and underscores",
|
message: "Username can only contain letters, numbers, dots and underscores",
|
||||||
|
@ -23,8 +16,7 @@ export class UserDTO {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@MinLength(8)
|
||||||
@IsString()
|
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
|
|
|
@ -12,6 +12,8 @@ import { User } from "@prisma/client";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.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 { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
import { UserDTO } from "./dto/user.dto";
|
||||||
import { UserSevice } from "./user.service";
|
import { UserSevice } from "./user.service";
|
||||||
|
@ -29,7 +31,10 @@ export class UserController {
|
||||||
|
|
||||||
@Patch("me")
|
@Patch("me")
|
||||||
@UseGuards(JwtGuard)
|
@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));
|
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +53,7 @@ export class UserController {
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async create(@Body() user: UserDTO) {
|
async create(@Body() user: CreateUserDTO) {
|
||||||
return new UserDTO().from(await this.userService.create(user));
|
return new UserDTO().from(await this.userService.create(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +65,7 @@ export class UserController {
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async delete(@Param() id: string) {
|
async delete(@Param("id") id: string) {
|
||||||
return new UserDTO().from(await this.userService.delete(id));
|
return new UserDTO().from(await this.userService.delete(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
import { UserDTO } from "./dto/user.dto";
|
||||||
|
|
||||||
|
@ -17,7 +18,7 @@ export class UserSevice {
|
||||||
return await this.prisma.user.findUnique({ where: { id } });
|
return await this.prisma.user.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: UserDTO) {
|
async create(dto: CreateUserDTO) {
|
||||||
const hash = await argon.hash(dto.password);
|
const hash = await argon.hash(dto.password);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.user.create({
|
return await this.prisma.user.create({
|
||||||
|
|
86
frontend/src/components/admin/ManageUserTable.tsx
Normal file
86
frontend/src/components/admin/ManageUserTable.tsx
Normal 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;
|
88
frontend/src/components/admin/showCreateUserModal.tsx
Normal file
88
frontend/src/components/admin/showCreateUserModal.tsx
Normal 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;
|
|
@ -84,7 +84,7 @@ const Body = ({
|
||||||
getConfigVariables();
|
getConfigVariables();
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
})
|
})
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
.catch(toast.axiosError);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|
126
frontend/src/components/admin/showUpdateUserModal.tsx
Normal file
126
frontend/src/components/admin/showUpdateUserModal.tsx
Normal 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;
|
|
@ -35,7 +35,7 @@ const SignInForm = () => {
|
||||||
authService
|
authService
|
||||||
.signIn(email, password)
|
.signIn(email, password)
|
||||||
.then(() => window.location.replace("/"))
|
.then(() => window.location.replace("/"))
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -20,7 +20,7 @@ const SignUpForm = () => {
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email().required(),
|
||||||
username: yup.string().required(),
|
username: yup.string().min(3).required(),
|
||||||
password: yup.string().min(8).required(),
|
password: yup.string().min(8).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -37,13 +37,13 @@ const SignUpForm = () => {
|
||||||
authService
|
authService
|
||||||
.signIn(email, password)
|
.signIn(email, password)
|
||||||
.then(() => window.location.replace("/"))
|
.then(() => window.location.replace("/"))
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
const signUp = (email: string, username: string, password: string) => {
|
const signUp = (email: string, username: string, password: string) => {
|
||||||
authService
|
authService
|
||||||
.signUp(email, username, password)
|
.signUp(email, username, password)
|
||||||
.then(() => signIn(email, password))
|
.then(() => signIn(email, password))
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
||||||
import Link from "next/link";
|
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 useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
|
|
||||||
|
@ -22,10 +22,13 @@ const ActionAvatar = () => {
|
||||||
>
|
>
|
||||||
My shares
|
My shares
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||||
|
My account
|
||||||
|
</Menu.Item>
|
||||||
{user!.isAdmin && (
|
{user!.isAdmin && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
href="/admin/config"
|
href="/admin"
|
||||||
icon={<TbSettings size={14} />}
|
icon={<TbSettings size={14} />}
|
||||||
>
|
>
|
||||||
Administration
|
Administration
|
||||||
|
|
|
@ -13,23 +13,6 @@ const FileList = ({
|
||||||
shareId: string;
|
shareId: string;
|
||||||
isLoading: boolean;
|
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) => (
|
const rows = files.map((file) => (
|
||||||
<tr key={file.name}>
|
<tr key={file.name}>
|
||||||
<td>{file.name}</td>
|
<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;
|
export default FileList;
|
||||||
|
|
154
frontend/src/pages/account/index.tsx
Normal file
154
frontend/src/pages/account/index.tsx
Normal 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;
|
|
@ -1,9 +1,12 @@
|
||||||
import { Space } from "@mantine/core";
|
import { Space, Title } from "@mantine/core";
|
||||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||||
|
|
||||||
const AdminConfig = () => {
|
const AdminConfig = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Title mb={30} order={3}>
|
||||||
|
Configuration
|
||||||
|
</Title>
|
||||||
<AdminConfigTable />
|
<AdminConfigTable />
|
||||||
<Space h="xl" />
|
<Space h="xl" />
|
||||||
</>
|
</>
|
||||||
|
|
62
frontend/src/pages/admin/index.tsx
Normal file
62
frontend/src/pages/admin/index.tsx
Normal 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;
|
73
frontend/src/pages/admin/users.tsx
Normal file
73
frontend/src/pages/admin/users.tsx
Normal 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;
|
|
@ -44,9 +44,14 @@ const refreshAccessToken = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePassword = async (oldPassword: string, password: string) => {
|
||||||
|
await api.patch("/auth/password", { oldPassword, password });
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
updatePassword
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 api from "./api.service";
|
||||||
import authService from "./auth.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> => {
|
const getCurrentUser = async (): Promise<CurrentUser | null> => {
|
||||||
try {
|
try {
|
||||||
await authService.refreshAccessToken();
|
await authService.refreshAccessToken();
|
||||||
|
@ -12,5 +41,11 @@ const getCurrentUser = async (): Promise<CurrentUser | null> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
list,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
|
updateCurrentUser,
|
||||||
|
removeCurrentUser,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,29 @@
|
||||||
export default interface User {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName?: string;
|
username: string;
|
||||||
lastName?: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
isAdmin: boolean;
|
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;
|
||||||
|
|
|
@ -10,6 +10,9 @@ const error = (message: string) =>
|
||||||
message: message,
|
message: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const axiosError = (axiosError: any) =>
|
||||||
|
error(axiosError?.response?.data?.message ?? "An unknown error occured");
|
||||||
|
|
||||||
const success = (message: string) =>
|
const success = (message: string) =>
|
||||||
showNotification({
|
showNotification({
|
||||||
icon: <TbCheck />,
|
icon: <TbCheck />,
|
||||||
|
@ -22,5 +25,6 @@ const success = (message: string) =>
|
||||||
const toast = {
|
const toast = {
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
|
axiosError,
|
||||||
};
|
};
|
||||||
export default toast;
|
export default toast;
|
||||||
|
|
Loading…
Add table
Reference in a new issue