Auth redirects, language switch, basic profile

This commit is contained in:
2024-07-26 01:14:12 +02:00
parent 2091bdb4e3
commit 36a4659915
31 changed files with 5638 additions and 331 deletions
+21 -8
View File
@@ -6,8 +6,9 @@ const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
let instance: ApiImpl;
class ApiImpl {
//FIXME: PRIVATE when reauth token exists
public token?: string;
private token?: string;
private refreshToken?: string;
private self?: User;
constructor() {
if (instance) {
@@ -19,18 +20,26 @@ class ApiImpl {
}
public hasAuth = () => this.token !== undefined;
public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
public getAuthenticatedUser = () => this.self;
public getCurrentSession = () => [this.token, this.refreshToken];
//FIXME: Currently returns Auth token, switch to reauth token
public logIn = async (email: string, password: string): Promise<[User, string]> => {
public logIn = async (email: string, password: string): Promise<void> => {
const { user, token } = await (await this.post('login', { email, password })).json();
this.self = user;
this.isAdmin = user.isAdmin;
this.token = token;
return [user, token];
};
public logOut = async (): Promise<boolean> => {
this.token = undefined;
return await (await this.postAuth('logout')).json();
try {
return await (await this.postAuth('logout')).json();
} catch {
return false;
} finally {
this.self = undefined;
this.token = undefined;
}
};
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
@@ -41,6 +50,10 @@ class ApiImpl {
return await (await this.get(url)).json();
};
public user = async (id?: number): Promise<User> => {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
};
private post = async (
endpoint: string,
body: Record<string, unknown> | undefined = undefined,
@@ -4,13 +4,14 @@ import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import useGuestBookStore from '../../../store/store';
import handleError from '../../../utils/errors';
const Login: FC = () => {
const [error, setError] = useState();
interface Props {
handleClose: () => void;
}
const setUser = useGuestBookStore((state) => state.setUser);
const LoginForm: FC<Props> = ({ handleClose }) => {
const [error, setError] = useState();
const { t } = useTranslation();
const router = useRouter();
@@ -22,9 +23,9 @@ const Login: FC = () => {
},
onSubmit: async ({ value }) => {
try {
const [user, token] = await Api.logIn(value.email, value.password);
setUser(user, token);
await Api.logIn(value.email, value.password);
router.invalidate();
handleClose();
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
@@ -126,4 +127,4 @@ const Login: FC = () => {
);
};
export default Login;
export default LoginForm;
+16 -57
View File
@@ -1,22 +1,20 @@
import { AccountCircle } from '@mui/icons-material';
import { AccountCircle, Translate } from '@mui/icons-material';
import {
AppBar,
Avatar,
Box,
CircularProgress,
IconButton,
Menu,
MenuItem,
Link as MUILink,
Toolbar,
useScrollTrigger,
} from '@mui/material';
import { Link, useRouter, useRouterState } from '@tanstack/react-router';
import { Link, useRouterState } from '@tanstack/react-router';
import { cloneElement, FC, ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import useGuestBookStore from '../../store/store';
import Login from '../Forms/Login/Login';
import LanguageMenu from '../Menus/Language/LanguageMenu';
import UserMenu from '../Menus/User/UserMenu';
const ElevationScroll = ({ children }: { children: ReactElement }) => {
const trigger = useScrollTrigger({
@@ -30,21 +28,17 @@ const ElevationScroll = ({ children }: { children: ReactElement }) => {
};
const Header: FC = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const user = useGuestBookStore((state) => state.user);
const setUser = useGuestBookStore((state) => state.setUser);
const [anchorUserMenu, setAnchorUserMenu] = useState<null | HTMLElement>(null);
const [anchorLanguageMenu, setAnchorLanguageMenu] = useState<null | HTMLElement>(null);
const { t } = useTranslation();
const router = useRouter();
const isLoading = useRouterState({ select: (s) => s.status === 'pending' });
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const user = Api.getAuthenticatedUser();
const handleClose = () => {
setAnchorEl(null);
setAnchorLanguageMenu(null);
setAnchorUserMenu(null);
};
return (
@@ -58,55 +52,20 @@ const Header: FC = () => {
</MUILink>
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
</Box>
<IconButton size="large" onClick={(event) => setAnchorLanguageMenu(event.currentTarget)}>
<Translate sx={{ color: 'white' }} />
</IconButton>
{user ? (
<IconButton onClick={handleMenu} sx={{ p: 0 }}>
<IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`storage/${user.image}`} />
</IconButton>
) : (
<IconButton size="large" onClick={handleMenu} color="inherit">
<IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} color="inherit">
<AccountCircle />
</IconButton>
)}
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
{user ? (
[
<MenuItem key="profile" onClick={handleClose}>
{t('Profile')}
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
setUser();
router.invalidate();
}}
>
{t('Log out')}
</MenuItem>,
]
) : (
<Login />
)}
</Menu>
<LanguageMenu anchorEl={anchorLanguageMenu} handleClose={handleClose} />
<UserMenu anchorEl={anchorUserMenu} handleClose={handleClose} />
</Toolbar>
</AppBar>
<Toolbar />
@@ -0,0 +1,55 @@
import { Menu, MenuItem } from '@mui/material';
import { FC } from 'react';
import i18n from '../../../i18n';
interface Props {
anchorEl: HTMLElement | null;
handleClose: () => void;
}
const LanguageMenu: FC<Props> = ({ anchorEl, handleClose }) => {
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
<MenuItem
key="de"
selected={i18n.language === 'en'}
onClick={() => {
i18n.changeLanguage('en');
handleClose();
}}
>
English
</MenuItem>
<MenuItem
key="en"
selected={i18n.language === 'de'}
onClick={() => {
i18n.changeLanguage('de');
handleClose();
}}
>
Deutsch
</MenuItem>
</Menu>
);
};
export default LanguageMenu;
@@ -0,0 +1,69 @@
import { Menu, MenuItem } from '@mui/material';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { t } from 'i18next';
import { FC } from 'react';
import Api from '../../../api/Api';
import { ROUTES } from '../../../types/Routes';
import LoginForm from '../../Forms/Login/LoginForm';
interface Props {
anchorEl: HTMLElement | null;
handleClose: () => void;
}
const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
const navigate = useNavigate();
const router = useRouter();
const user = Api.getAuthenticatedUser();
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
},
}}
>
{user ? (
[
<MenuItem
key="profile"
onClick={() => {
navigate({ to: ROUTES.PROFILE });
handleClose();
}}
>
{t('Profile')}
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
router.invalidate();
handleClose();
}}
>
{t('Log out')}
</MenuItem>,
]
) : (
<LoginForm handleClose={handleClose} />
)}
</Menu>
);
};
export default UserMenu;
+31 -13
View File
@@ -1,7 +1,9 @@
import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material';
import { Link } from '@tanstack/react-router';
import { FC } from 'react';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
interface Props {
post: PostNonAuth | PostAuth;
@@ -12,21 +14,37 @@ const Post: FC<Props> = ({ post }) => {
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardHeader
avatar={
<MUILink component={Link} to="/" color="#FFF" variant="h6" underline="none">
'id' in post.user ? (
<MUILink
component={Link}
to="/profile/$id"
params={{ id: post.user.id }}
color="#FFF"
variant="h6"
underline="none"
>
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
</MUILink>
) : (
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
</MUILink>
)
}
title={post.user.username}
subheader={new Date(post.postedAt.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
timeZone: post.postedAt.timezone,
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
})}
title={
'id' in post.user ? (
post.user.id !== Api.getAuthenticatedUser()?.id ? (
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
{post.user.username}
</MUILink>
) : (
<MUILink component={Link} to="/profile">
{post.user.username}
</MUILink>
)
) : (
post.user.username
)
}
subheader={convertDate(post.postedAt)}
/>
<CardContent>
<Typography>{post.content}</Typography>
@@ -0,0 +1,34 @@
import { Avatar, Box, Grid, Typography } from '@mui/material';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../../types/User';
import convertDate from '../../utils/date';
interface Props {
user: User;
}
const Profile: FC<Props> = ({ user }) => {
const { t } = useTranslation();
return (
<Box>
<Grid container spacing={2}>
<Grid item sx={{ display: 'grid', gridTemplateColumns: 'fit-content(100%) 1fr', columnGap: 2, rowGap: 1 }}>
<Box sx={{ gridColumn: '1/3', display: 'flex', justifyContent: 'center' }}>
<Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: 100, height: 100 }} />
</Box>
<Typography fontWeight="bold">{t('Username')}:</Typography>
<Typography>{user.username}</Typography>
<Typography fontWeight="bold">{t('Member since')}:</Typography>
<Typography>{convertDate(user.memberSince)}</Typography>
<Typography fontWeight="bold">{t('Post count')}:</Typography>
<Typography>{user.postCount}</Typography>
</Grid>
<Grid item></Grid>
</Grid>
</Box>
);
};
export default Profile;
+40 -2
View File
@@ -12,6 +12,8 @@
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as ProfileIndexImport } from './routes/profile/index'
import { Route as ProfileIdImport } from './routes/profile/$id'
// Create/Update Routes
@@ -20,6 +22,16 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const ProfileIndexRoute = ProfileIndexImport.update({
path: '/profile/',
getParentRoute: () => rootRoute,
} as any)
const ProfileIdRoute = ProfileIdImport.update({
path: '/profile/$id',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -31,12 +43,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/profile/$id': {
id: '/profile/$id'
path: '/profile/$id'
fullPath: '/profile/$id'
preLoaderRoute: typeof ProfileIdImport
parentRoute: typeof rootRoute
}
'/profile/': {
id: '/profile/'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof ProfileIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren({ IndexRoute })
export const routeTree = rootRoute.addChildren({
IndexRoute,
ProfileIdRoute,
ProfileIndexRoute,
})
/* prettier-ignore-end */
@@ -46,11 +76,19 @@ export const routeTree = rootRoute.addChildren({ IndexRoute })
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/profile/$id",
"/profile/"
]
},
"/": {
"filePath": "index.tsx"
},
"/profile/$id": {
"filePath": "profile/$id.tsx"
},
"/profile/": {
"filePath": "profile/index.tsx"
}
}
}
+35 -8
View File
@@ -1,26 +1,53 @@
import { Toolbar } from '@mui/material';
import { QueryClient } from '@tanstack/react-query';
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useEffect } from 'react';
import Api from '../api/Api';
import Header from '../components/Header/Header';
import useGuestBookStore from '../store/store';
import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => {
//FIXME: REAUTH HERE
const token = useGuestBookStore((state) => state.token);
Api.token = token;
//TODO: REAUTH HERE
return (
<>
<Header />
<Toolbar />
<Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</>
);
};
//TODO: Make nice
const Error: ErrorRouteComponent = ({ error }) => {
const router = useRouter();
const queryErrorResetBoundary = useQueryErrorResetBoundary();
useEffect(() => {
// Reset the query error boundary
queryErrorResetBoundary.reset();
}, [queryErrorResetBoundary]);
if ('code' in error && error.code === ERRORS.UNAUTHORIZED) {
Api.logOut();
redirect({ to: ROUTES.INDEX });
}
return (
<div>
{error.message}
<button
onClick={() => {
router.invalidate();
}}
>
retry
</button>
</div>
);
};
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: Root,
errorComponent: Error,
});
+20 -17
View File
@@ -1,8 +1,10 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import Post from '../components/Post/Post';
import { ROUTES } from '../types/Routes';
const postsQueryOptions = (page?: number) =>
queryOptions({
@@ -10,28 +12,18 @@ const postsQueryOptions = (page?: number) =>
queryFn: () => Api.posts(page),
});
export const Route = createFileRoute('/')({
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
validateSearch: (search: Record<string, unknown>): { page?: number } => {
return {
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
};
},
component: Home,
});
function Home() {
const Home = () => {
const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
const { t } = useTranslation();
return (
<>
<Snackbar open={isFetching} message="Updating" />
<Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2}>
{postsQuery.data.map((post) => (
<Grid item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
<Post key={post.id} post={post} />
<Grid key={post.id} item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
<Post post={post} />
</Grid>
))}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
@@ -44,7 +36,7 @@ function Home() {
{...item}
component={Link}
to="/"
search={{ page: item.page ? item.page - 1 : undefined }}
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)}
/>
@@ -54,4 +46,15 @@ function Home() {
</Grid>
</>
);
}
};
export const Route = createFileRoute(ROUTES.INDEX)({
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
validateSearch: (search: Record<string, unknown>): { page?: number } => {
return {
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
};
},
component: Home,
});
+37
View File
@@ -0,0 +1,37 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
const ProfilePage = () => {
const { id } = Route.useParams();
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));
return (
<>
<Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} />
</>
);
};
export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
params: {
parse: ({ id }) => ({ id: parseInt(id) }),
stringify: ({ id }) => ({ id: id.toString() }),
},
loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)),
beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
},
component: ProfilePage,
});
+31
View File
@@ -0,0 +1,31 @@
import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
const ProfilePage = () => {
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions);
return (
<>
<Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} />
</>
);
};
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileQueryOptions),
beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
},
component: ProfilePage,
});
+4 -17
View File
@@ -1,27 +1,14 @@
import type {} from '@redux-devtools/extension';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/User';
interface GuestBookState {
user: User | undefined;
token: string | undefined;
setUser: (user?: User, token?: string) => void;
}
interface GuestBookState {}
const useGuestBookStore = create<GuestBookState>()(
devtools(
persist(
(set) => ({
user: undefined,
// FIXME: Currentlay auth token, switch this to be reauth token
token: undefined,
setUser: (user?: User, token?: string) => set(() => ({ user, token })),
}),
{
name: 'guestbook-storage',
}
)
persist(() => ({}), {
name: 'guestbook-storage',
})
)
);
+4
View File
@@ -0,0 +1,4 @@
export enum ROUTES {
INDEX = '/',
PROFILE = '/profile',
}
+20
View File
@@ -0,0 +1,20 @@
import { Timestamp } from '../types/Timestamp';
/**
* Convert date to nicer format
* @param date Timestamp in question
* @returns Formatted string
*/
const convertDate = (date: Timestamp): string => {
return new Date(date.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
timeZone: date.timezone,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
});
};
export default convertDate;
+7 -2
View File
@@ -1,5 +1,10 @@
import { TFunction } from 'i18next';
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
}
/**
* Return translated error
* @param error Error object
@@ -18,9 +23,9 @@ const handleError = (
if (error.code) {
switch (error.code) {
case 'NotFound':
case ERRORS.NOT_FOUND:
return t(error.code, { context: `${error.entity}:${context}` });
case 'Unauthorized':
case ERRORS.UNAUTHORIZED:
return t(error.code, { context });
default:
return t('Unknown', { context });