This commit is contained in:
2024-07-30 21:20:51 +02:00
parent b1061e67ac
commit 0fbbfdc997
30 changed files with 394 additions and 62 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,10 +23,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GuestBook</title> <title>GuestBook</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-Cag5GO1b.js"></script> <script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-SI5snNZz.js"></script>
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C9_qfvjK.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C9_qfvjK.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BnAUJOoN.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BnAUJOoN.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-BqkrhB-y.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DpDh5IPY.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DKxXQGKw.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DKxXQGKw.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-Be01V9yD.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-Be01V9yD.js">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css"> <link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
+12 -1
View File
@@ -20,6 +20,9 @@
"NotAllowed_postUpdate": "Keine Berechtigung", "NotAllowed_postUpdate": "Keine Berechtigung",
"NotFound_post:postUpdate": "Post nicht gefunden", "NotFound_post:postUpdate": "Post nicht gefunden",
"NotAllowed_delete|PermissionsUser": "Keine Berechtigung",
"NotFound_user:delete|PermissionsUser": "Benutzer nicht gefunden",
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon", "Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
"username": "Benutzername", "username": "Benutzername",
@@ -101,5 +104,13 @@
"Password confirm": "Passwort bestätigen", "Password confirm": "Passwort bestätigen",
"Password match": "Passwörter stimmen nicht überein", "Password match": "Passwörter stimmen nicht überein",
"Change password": "Passwort ändern" "Change password": "Passwort ändern",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Benutzer verwalten",
"Admin": "Administration",
"Make Admin": "Administratorrecht erteilen",
"Demote Admin": "Administratorrecht entziehen"
} }
+12 -1
View File
@@ -20,6 +20,9 @@
"NotAllowed_postUpdate": "Not allowed", "NotAllowed_postUpdate": "Not allowed",
"NotFound_post:postUpdate": "Post not found", "NotFound_post:postUpdate": "Post not found",
"NotAllowed_delete|PermissionsUser": "Not allowed",
"NotFound_user:deleteUserdelete|PermissionsUser": "User not found",
"Duplicate_user:register": "A user with this username or email already exists", "Duplicate_user:register": "A user with this username or email already exists",
"username": "username", "username": "username",
@@ -102,5 +105,13 @@
"Password confirm": "Confirm password", "Password confirm": "Confirm password",
"Password match": "Password do not match", "Password match": "Password do not match",
"Change password": "Change password" "Change password": "Change password",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Manage users",
"Admin": "Administration",
"Make Admin": "Make Admin",
"Demote Admin": "Demote Admin"
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "react", "name": "react",
"private": true, "private": true,
"version": "1.1.1", "version": "3.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+12 -1
View File
@@ -20,6 +20,9 @@
"NotAllowed_postUpdate": "Keine Berechtigung", "NotAllowed_postUpdate": "Keine Berechtigung",
"NotFound_post:postUpdate": "Post nicht gefunden", "NotFound_post:postUpdate": "Post nicht gefunden",
"NotAllowed_delete|PermissionsUser": "Keine Berechtigung",
"NotFound_user:delete|PermissionsUser": "Benutzer nicht gefunden",
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon", "Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
"username": "Benutzername", "username": "Benutzername",
@@ -101,5 +104,13 @@
"Password confirm": "Passwort bestätigen", "Password confirm": "Passwort bestätigen",
"Password match": "Passwörter stimmen nicht überein", "Password match": "Passwörter stimmen nicht überein",
"Change password": "Passwort ändern" "Change password": "Passwort ändern",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Benutzer verwalten",
"Admin": "Administration",
"Make Admin": "Administratorrecht erteilen",
"Demote Admin": "Administratorrecht entziehen"
} }
+12 -1
View File
@@ -20,6 +20,9 @@
"NotAllowed_postUpdate": "Not allowed", "NotAllowed_postUpdate": "Not allowed",
"NotFound_post:postUpdate": "Post not found", "NotFound_post:postUpdate": "Post not found",
"NotAllowed_delete|PermissionsUser": "Not allowed",
"NotFound_user:deleteUserdelete|PermissionsUser": "User not found",
"Duplicate_user:register": "A user with this username or email already exists", "Duplicate_user:register": "A user with this username or email already exists",
"username": "username", "username": "username",
@@ -102,5 +105,13 @@
"Password confirm": "Confirm password", "Password confirm": "Confirm password",
"Password match": "Password do not match", "Password match": "Password do not match",
"Change password": "Change password" "Change password": "Change password",
"Confirm user delete title": "Diesen User löschen?",
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
"Manage users": "Manage users",
"Admin": "Administration",
"Make Admin": "Make Admin",
"Demote Admin": "Demote Admin"
} }
+51 -6
View File
@@ -1,8 +1,17 @@
import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'; import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react';
import { ERRORS } from '../components/Error/Errors';
import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst'; import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst';
import useGuestBookStore from '../store/store'; import useGuestBookStore from '../store/store';
import { PostAuth, PostCreate, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post'; import { PostAuth, PostCreate, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
import { User, UserCreate, UserImageUpdate, UserUpdate } from '../types/User'; import {
User,
UserCreate,
UserDelete,
UserImageUpdate,
UserList,
UserPermissionsUpdate,
UserUpdate,
} from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/'; const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@@ -19,11 +28,14 @@ interface ApiContext {
updatePost?: (data: PostUpdate, id: number) => Promise<PostAuth>; updatePost?: (data: PostUpdate, id: number) => Promise<PostAuth>;
deletePost?: (id: number) => Promise<PostDelete>; deletePost?: (id: number) => Promise<PostDelete>;
users?: (page?: number) => Promise<UserList>;
user?: (id?: number) => Promise<User>; user?: (id?: number) => Promise<User>;
createUser?: (data: UserCreate) => Promise<User>; createUser?: (data: UserCreate) => Promise<User>;
confirmUser?: (code: string) => Promise<User>; confirmUser?: (code: string) => Promise<User>;
updateUser?: (data: UserUpdate, id?: number) => Promise<User>; updateUser?: (data: UserUpdate, id?: number) => Promise<User>;
updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>; updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>;
updateUserPermissions?: (data: UserPermissionsUpdate, id: number) => Promise<User>;
deleteUser?: (id: number) => Promise<UserDelete>;
userPosts?: (id?: number) => Promise<PostListAuth>; userPosts?: (id?: number) => Promise<PostListAuth>;
} }
@@ -47,11 +59,14 @@ export const useApi = () => {
updatePost, updatePost,
deletePost, deletePost,
users,
user, user,
createUser, createUser,
confirmUser, confirmUser,
updateUser, updateUser,
updateUserImage, updateUserImage,
updateUserPermissions,
deleteUser,
userPosts, userPosts,
} = useContext(ApiContext); } = useContext(ApiContext);
@@ -63,11 +78,14 @@ export const useApi = () => {
newPost && newPost &&
updatePost && updatePost &&
deletePost && deletePost &&
users &&
user && user &&
createUser && createUser &&
confirmUser && confirmUser &&
updateUser && updateUser &&
updateUserImage && updateUserImage &&
updateUserPermissions &&
deleteUser &&
userPosts userPosts
) { ) {
return { return {
@@ -82,11 +100,14 @@ export const useApi = () => {
updatePost, updatePost,
deletePost, deletePost,
users,
user, user,
createUser, createUser,
confirmUser, confirmUser,
updateUser, updateUser,
updateUserImage, updateUserImage,
updateUserPermissions,
deleteUser,
userPosts, userPosts,
}; };
@@ -157,6 +178,12 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
return await (await reAuth(() => _delete(`posts/${id}?l=${POST_LIMIT}`))).json(); return await (await reAuth(() => _delete(`posts/${id}?l=${POST_LIMIT}`))).json();
}; };
const users = async (page?: number): Promise<UserList> => {
const url = `users?p=${page ?? 0}&l=${POST_LIMIT}`;
return await (await reAuth(() => getAuth(url))).json();
};
const user = async (id?: number): Promise<User> => { const user = async (id?: number): Promise<User> => {
return await (await reAuth(() => getAuth(`users/${id ?? authenticatedUser?.id}`))).json(); return await (await reAuth(() => getAuth(`users/${id ?? authenticatedUser?.id}`))).json();
}; };
@@ -187,10 +214,21 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
return _user; return _user;
}; };
const updateUserPermissions = async (data: UserPermissionsUpdate, id: number): Promise<User> => {
const _user = await (
await reAuth(() => patchAuth(`users/${id}/permissions`, data as Record<string, unknown>))
).json();
return _user;
};
const userPosts = async (id?: number): Promise<PostListAuth> => { const userPosts = async (id?: number): Promise<PostListAuth> => {
return await (await reAuth(() => getAuth(`users/${id}/posts?l=${PROFILE_POST_LIMIT}&s=desc`))).json(); return await (await reAuth(() => getAuth(`users/${id}/posts?l=${PROFILE_POST_LIMIT}&s=desc`))).json();
}; };
const deleteUser = async (id: number): Promise<UserDelete> => {
return await (await reAuth(() => _delete(`users/${id}?l=${POST_LIMIT}`))).json();
};
/* IMPL */ /* IMPL */
const post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => { const post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@@ -284,9 +322,13 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
const ret = await callback(); const ret = await callback();
return ret; return ret;
} catch { //eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log('[REAUTH] failed once', error);
if (error.code !== ERRORS.UNAUTHORIZED) throw error;
try { try {
console.log('[REAUTH] fail, refreshing'); console.log('[REAUTH] failed due to authentication, try refreshing session');
// REAUTH // REAUTH
await refresh(); await refresh();
// DO AGAIN // DO AGAIN
@@ -294,13 +336,13 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
const ret = await callback(); const ret = await callback();
return ret; return ret;
} catch (error) { } catch (_error) {
console.log('[REAUTH] terminating session', error); console.log('[REAUTH] terminating session', _error);
setAuthenticatedUser(undefined); setAuthenticatedUser(undefined);
setHasAuth(false); setHasAuth(false);
setCurrentSession([undefined, undefined]); setCurrentSession([undefined, undefined]);
token.current = undefined; token.current = undefined;
throw error; throw _error;
} }
} }
}; };
@@ -332,11 +374,14 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
updatePost, updatePost,
deletePost, deletePost,
users,
user, user,
createUser, createUser,
confirmUser, confirmUser,
updateUser, updateUser,
updateUserImage, updateUserImage,
updateUserPermissions,
deleteUser,
userPosts, userPosts,
}} }}
@@ -35,9 +35,7 @@ const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
const Api = useApi(); const Api = useApi();
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => { mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => Api.updatePost(data, id),
return Api.updatePost(data, id);
},
}); });
const form = useForm<PostUpdate>({ const form = useForm<PostUpdate>({
@@ -35,9 +35,7 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
const Api = useApi(); const Api = useApi();
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: ({ data }: { data: UserCreate }) => { mutationFn: ({ data }: { data: UserCreate }) => Api.createUser(data),
return Api.createUser(data);
},
}); });
const form = useForm<UserCreate & { passwordConfirm: string }>({ const form = useForm<UserCreate & { passwordConfirm: string }>({
@@ -34,9 +34,7 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
const Api = useApi(); const Api = useApi();
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => { mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => Api.updateUser(data, id),
return Api.updateUser(data, id);
},
}); });
const form = useForm<UserUpdate>({ const form = useForm<UserUpdate>({
@@ -44,9 +44,7 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
const Api = useApi(); const Api = useApi();
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => { mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => Api.updateUserImage(data, id),
return Api.updateUserImage(data, id);
},
}); });
const form = useForm<UserImageUpdate>({ const form = useForm<UserImageUpdate>({
@@ -34,9 +34,7 @@ const UserPasswordDialog: FC<Props> = ({ user, open, onClose }) => {
const Api = useApi(); const Api = useApi();
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => { mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => Api.updateUser(data, id),
return Api.updateUser(data, id);
},
}); });
const form = useForm<UserUpdate & { passwordConfirm: string }>({ const form = useForm<UserUpdate & { passwordConfirm: string }>({
@@ -1,6 +1,6 @@
import { ErrorOutline } from '@mui/icons-material'; import { ErrorOutline } from '@mui/icons-material';
import { Grid, Link as MUILink, Typography } from '@mui/material'; import { Grid, Link as MUILink, Typography } from '@mui/material';
import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { useQueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorRouteComponent as TSErrorRouteComponent, useNavigate, useRouter } from '@tanstack/react-router'; import { ErrorRouteComponent as TSErrorRouteComponent, useNavigate, useRouter } from '@tanstack/react-router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -13,10 +13,11 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
const router = useRouter(); const router = useRouter();
const navigate = useNavigate(); const navigate = useNavigate();
const queryErrorResetBoundary = useQueryErrorResetBoundary(); const queryErrorResetBoundary = useQueryErrorResetBoundary();
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
// Reset the query error boundary // Reset the query error boundary
console.log(queryErrorResetBoundary.isReset()); console.log(queryErrorResetBoundary);
queryErrorResetBoundary.reset(); queryErrorResetBoundary.reset();
}, [queryErrorResetBoundary]); }, [queryErrorResetBoundary]);
@@ -34,10 +35,9 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}> <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<MUILink <MUILink
variant="h6" variant="h6"
underline="hover"
sx={{ cursor: 'pointer' }} sx={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
console.log('CLICK AS WELL'); queryClient.clear();
router.invalidate(); router.invalidate();
navigate({ to: ROUTES.INDEX }); navigate({ to: ROUTES.INDEX });
}} }}
@@ -19,9 +19,7 @@ const PostForm: FC = () => {
const Api = useApi(); const Api = useApi();
const newMutation = useMutation({ const newMutation = useMutation({
mutationFn: ({ data }: { data: PostCreate }) => { mutationFn: ({ data }: { data: PostCreate }) => Api.newPost(data),
return Api.newPost(data);
},
}); });
const form = useForm<PostCreate>({ const form = useForm<PostCreate>({
@@ -1,4 +1,4 @@
import { Box, Link, Menu, MenuItem, Typography } from '@mui/material'; import { Box, Divider, Link, Menu, MenuItem, Typography } from '@mui/material';
import { useMatch, useNavigate, useRouter } from '@tanstack/react-router'; import { useMatch, useNavigate, useRouter } from '@tanstack/react-router';
import { t } from 'i18next'; import { t } from 'i18next';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
@@ -69,6 +69,14 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
> >
{t('Log out')} {t('Log out')}
</MenuItem>, </MenuItem>,
Api.authenticatedUser.isAdmin && [
<Divider>
<Typography variant="caption">{t('Admin')}</Typography>
</Divider>,
<MenuItem key="users" onClick={() => navigate({ to: ROUTES.USERS })}>
{t('Manage users')}
</MenuItem>,
],
] ]
) : register ? ( ) : register ? (
<RegisterDialog open={register} onClose={() => setRegister(false)} /> <RegisterDialog open={register} onClose={() => setRegister(false)} />
@@ -81,7 +89,6 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
<Link <Link
sx={{ cursor: 'pointer' }} sx={{ cursor: 'pointer' }}
variant="body1" variant="body1"
underline="hover"
color="secondary.main" color="secondary.main"
onClick={() => { onClick={() => {
setRegister(true); setRegister(true);
+1 -3
View File
@@ -44,9 +44,7 @@ const Post: FC<Props> = ({ page = 0, post, disableActions }) => {
const Api = useApi(); const Api = useApi();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => { mutationFn: (id: number) => Api.deletePost(id),
return Api.deletePost(id);
},
}); });
return ( return (
@@ -41,11 +41,17 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
<CardContent> <CardContent>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}> <Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
<IconButton onClick={() => setImageOpen(true)}> {canEdit ? (
<IconButton onClick={() => setImageOpen(true)}>
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} />
</Avatar>
</IconButton>
) : (
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}> <Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} /> <Person sx={{ width: '60px', height: '60px' }} />
</Avatar> </Avatar>
</IconButton> )}
</Grid> </Grid>
<Grid item sx={{ display: 'flex', alignItems: 'center' }}> <Grid item sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}>
+10
View File
@@ -0,0 +1,10 @@
import { queryOptions } from '@tanstack/react-query';
import { useApi } from '../api/Api';
import { ERRORS } from '../components/Error/Errors';
export const usersQueryOptions = (Api: ReturnType<typeof useApi>, page?: number) =>
queryOptions({
queryKey: ['users', { page: page ?? 0 }],
queryFn: async () => await Api.users(page),
retry: (count, error) => ('code' in error && error.code !== ERRORS.UNAUTHORIZED ? count < 3 : false),
});
+19 -1
View File
@@ -12,6 +12,7 @@
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
import { Route as UsersIndexImport } from './routes/users/index'
import { Route as ProfileIndexImport } from './routes/profile/index' import { Route as ProfileIndexImport } from './routes/profile/index'
import { Route as ConfirmIndexImport } from './routes/confirm/index' import { Route as ConfirmIndexImport } from './routes/confirm/index'
import { Route as ProfileIdImport } from './routes/profile/$id' import { Route as ProfileIdImport } from './routes/profile/$id'
@@ -23,6 +24,11 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const UsersIndexRoute = UsersIndexImport.update({
path: '/users/',
getParentRoute: () => rootRoute,
} as any)
const ProfileIndexRoute = ProfileIndexImport.update({ const ProfileIndexRoute = ProfileIndexImport.update({
path: '/profile/', path: '/profile/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -70,6 +76,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProfileIndexImport preLoaderRoute: typeof ProfileIndexImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/users/': {
id: '/users/'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof UsersIndexImport
parentRoute: typeof rootRoute
}
} }
} }
@@ -80,6 +93,7 @@ export const routeTree = rootRoute.addChildren({
ProfileIdRoute, ProfileIdRoute,
ConfirmIndexRoute, ConfirmIndexRoute,
ProfileIndexRoute, ProfileIndexRoute,
UsersIndexRoute,
}) })
/* prettier-ignore-end */ /* prettier-ignore-end */
@@ -93,7 +107,8 @@ export const routeTree = rootRoute.addChildren({
"/", "/",
"/profile/$id", "/profile/$id",
"/confirm/", "/confirm/",
"/profile/" "/profile/",
"/users/"
] ]
}, },
"/": { "/": {
@@ -107,6 +122,9 @@ export const routeTree = rootRoute.addChildren({
}, },
"/profile/": { "/profile/": {
"filePath": "profile/index.tsx" "filePath": "profile/index.tsx"
},
"/users/": {
"filePath": "users/index.tsx"
} }
} }
} }
+4
View File
@@ -3,6 +3,8 @@ import { createRouter, ErrorRouteComponent, RouterProvider, useRouter } from '@t
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useApi } from './api/Api'; import { useApi } from './api/Api';
import ErrorRouterComponent from './components/Error/ErrorRouterComponent';
import NotFoundComponent from './components/NotFound/NotFoundComponent';
import { routeTree } from './routeTree.gen'; import { routeTree } from './routeTree.gen';
//TODO: REAUTH HERE //TODO: REAUTH HERE
@@ -51,6 +53,8 @@ const router = createRouter({
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
basepath: process.env.NODE_ENV === 'development' ? 'phpCourse/exam/dist' : '/phpCourse/exam', basepath: process.env.NODE_ENV === 'development' ? 'phpCourse/exam/dist' : '/phpCourse/exam',
//defaultErrorComponent: Error, //defaultErrorComponent: Error,
defaultNotFoundComponent: NotFoundComponent,
defaultErrorComponent: ErrorRouterComponent,
}); });
// Register the router instance for type safety // Register the router instance for type safety
-4
View File
@@ -3,8 +3,6 @@ import { QueryClient } from '@tanstack/react-query';
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools'; import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useApi } from '../api/Api'; import { useApi } from '../api/Api';
import ErrorRouterComponent from '../components/Error/ErrorRouterComponent';
import NotFoundComponent from '../components/NotFound/NotFoundComponent';
const Root = () => { const Root = () => {
return ( return (
@@ -17,6 +15,4 @@ const Root = () => {
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
component: Root, component: Root,
notFoundComponent: NotFoundComponent,
errorComponent: ErrorRouterComponent,
}); });
+1 -3
View File
@@ -15,9 +15,7 @@ const Home = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const confirmMutation = useMutation({ const confirmMutation = useMutation({
mutationFn: ({ code: _code }: { code: string }) => { mutationFn: ({ code: _code }: { code: string }) => Api.confirmUser(_code),
return Api.confirmUser(_code);
},
}); });
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -30,7 +30,7 @@ const Home = () => {
useEffect(() => { useEffect(() => {
if ((page ?? 0) >= postsQuery.pages) { if ((page ?? 0) >= postsQuery.pages) {
navigate({ to: '/', search: { page: postsQuery.pages - 1 } }); navigate({ to: ROUTES.INDEX, search: { page: postsQuery.pages - 1 } });
} }
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps }, [page]); //eslint-disable-line react-hooks/exhaustive-deps
+203
View File
@@ -0,0 +1,203 @@
import {
Alert,
Button,
Card,
CardActions,
CardContent,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Grid,
Link as MUILink,
Pagination,
PaginationItem,
Snackbar,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useApi } from '../../api/Api';
import ErrorComponent from '../../components/Error/ErrorComponent';
import { ERRORS } from '../../components/Error/Errors';
import HeaderLayout from '../../components/Layouts/HeaderLayout';
import { usersQueryOptions } from '../../queries/usersQuery';
import { ROUTES } from '../../types/Routes';
import { UserPermissionsUpdate } from '../../types/User';
const Users = () => {
const [deleteOpen, setDeleteOpen] = useState(false);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [_error, setError] = useState<any>();
const Api = useApi();
const { page } = Route.useSearch();
const { data: usersQuery, isFetching, failureReason, error } = useSuspenseQuery(usersQueryOptions(Api, page));
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const theme = useTheme();
const oneSibling = useMediaQuery(theme.breakpoints.not('xs'), { noSsr: true });
if (failureReason && 'code' in failureReason && failureReason.code === ERRORS.UNAUTHORIZED) {
throw failureReason;
}
if (error && 'code' in error && error.code === ERRORS.UNAUTHORIZED) {
throw error;
}
const deleteMutation = useMutation({
mutationFn: (id: number) => Api.deleteUser(id),
});
const permissionMutation = useMutation({
mutationFn: ({ data, id }: { data: UserPermissionsUpdate; id: number }) => Api.updateUserPermissions(data, id),
});
useEffect(() => {
if ((page ?? 0) >= usersQuery.pages) {
navigate({ to: ROUTES.USERS, search: { page: usersQuery.pages - 1 } });
}
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
return (
<HeaderLayout>
<Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2} sx={{ marginTop: 0 }}>
{usersQuery.data.map((user) => (
<Grid item xs={12} key={user.id}>
<Card>
<CardContent>
{user.id !== Api.authenticatedUser?.id ? (
<MUILink component={Link} to="/profile/$id" params={{ id: user.id }}>
{user.username}
</MUILink>
) : (
<Typography>{user.username}</Typography>
)}
<Typography>{user.email}</Typography>
</CardContent>
{user.id !== Api.authenticatedUser?.id && (
<>
<CardActions>
<Button size="small" color="error" onClick={() => setDeleteOpen(true)}>
{t('Delete')}
</Button>
<Button
size="small"
onClick={() => {
permissionMutation.mutate(
{ data: { isAdmin: !user.isAdmin }, id: user.id },
{
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['users'],
});
},
onError: setError,
}
);
}}
>
{t(user.isAdmin ? 'Demote Admin' : 'Make Admin')}
</Button>
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>{t('Confirm user delete title')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('Confirm user delete body', { name: user.username })}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)} autoFocus variant="contained">
{t('No')}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => {
deleteMutation.mutate(user.id, {
onSuccess: async (data) => {
await queryClient.invalidateQueries({
queryKey: ['users'],
});
if ((page ?? 0) >= data.pages)
navigate({ to: ROUTES.PROFILE, search: { page: data.pages - 1 } });
},
onError: setError,
});
setDeleteOpen(false);
}}
>
{t('Yes')}
</Button>
</DialogActions>
</Dialog>
</CardActions>
<Snackbar
open={deleteMutation.isError || permissionMutation.isError}
autoHideDuration={2000}
onClose={() => {
deleteMutation.reset();
permissionMutation.reset();
}}
TransitionProps={{
onExited: () => setError(undefined),
}}
>
<Alert severity="error" variant="filled" sx={{ width: '100%' }}>
{error && <ErrorComponent error={_error} context="delete|PermissionsUser" color="white" />}
</Alert>
</Snackbar>
<Snackbar open={deleteMutation.isPending} message={t('Deleting')} />
<Snackbar open={permissionMutation.isPending} message={t('Updating')} />
</>
)}
</Card>
</Grid>
))}
<Grid item xs={12}>
<Divider variant="middle" />
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={(page ?? 0) + 1}
count={usersQuery.pages}
siblingCount={oneSibling ? 1 : 0}
color="primary"
renderItem={(item) => (
<PaginationItem
{...item}
component={Link}
to="/"
search={{ page: (item.page ?? 0) > 0 ? (item.page ?? 1) - 1 : undefined }}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={(e) => item.onClick(e as any)}
/>
)}
/>
</Grid>
</Grid>
</HeaderLayout>
);
};
export const Route = createFileRoute(`${ROUTES.USERS}/`)({
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ context: { queryClient, Api }, deps: { page } }) =>
queryClient.ensureQueryData(usersQueryOptions(Api, page)),
validateSearch: (search: Record<string, unknown>): { page?: number } => {
return {
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
};
},
beforeLoad: ({ context: { Api } }) => {
if ((!Api.hasAuth && !Api.currentSession[0]) || !Api.authenticatedUser?.isAdmin)
throw redirect({ to: ROUTES.INDEX });
},
component: Users,
});
+1
View File
@@ -2,4 +2,5 @@ export enum ROUTES {
INDEX = '/', INDEX = '/',
PROFILE = '/profile', PROFILE = '/profile',
CONFIRM = '/confirm', CONFIRM = '/confirm',
USERS = '/users',
} }
+14
View File
@@ -17,6 +17,10 @@ export interface UserUpdate {
password?: string; password?: string;
} }
export interface UserPermissionsUpdate {
isAdmin?: boolean;
}
export interface UserCreate { export interface UserCreate {
username: string; username: string;
email: string; email: string;
@@ -32,3 +36,13 @@ export interface UserImageUpdate {
image?: File; image?: File;
predefined?: string; predefined?: string;
} }
export interface UserList {
pages: number;
data: User[];
}
export interface UserDelete {
pages: number;
user: User;
}