diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 2ca3727..39b1368 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -6,7 +6,7 @@ export type UserType = { password: string; username: string; role: number; - avatarPath: string; + avatarPath: string | null; } | null; export type TokenProps = { diff --git a/frontend/utils/TokenProvider.tsx b/frontend/utils/TokenProvider.tsx index 7892007..100b5f9 100644 --- a/frontend/utils/TokenProvider.tsx +++ b/frontend/utils/TokenProvider.tsx @@ -3,7 +3,11 @@ import { createContext, useState, useEffect, useContext } from 'react'; import { TokenContextType, TokenType } from '@/types/user'; import { TokenProps } from '@/types/user'; import { useRouter, usePathname } from '@/src/navigation'; -import { roles } from '@/config/selection'; +import { + isSignedIn as tokenIsSinedIn, + isAdmin as tokenIsAdmin, + checkSignInPage as tokenCheckSignInPage, +} from './token'; import { ToastContext } from './ToastProvider'; const LOCAL_STORAGE_KEY = 'testplat-auth-token'; @@ -40,39 +44,12 @@ const TokenProvider = ({ toastMessages, locale, children }: TokenProps) => { user: null, }); - const tokenExists = () => { - if (token && token.user && token.user.username) { - return true; - } else { - return false; - } - }; - - const isTokenValid = () => { - if (Date.now() < token.expires_at) { - return true; - } else { - return false; - } - }; - const isSignedIn = () => { - if (tokenExists() && isTokenValid()) { - return true; - } else { - return false; - } + return tokenIsSinedIn(token); }; const isAdmin = () => { - if (tokenExists() && isTokenValid()) { - const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator'); - if (token.user.role === adminRoleIndex) { - return true; - } - } else { - return false; - } + return tokenIsAdmin(token); }; const tokenContext = { @@ -98,32 +75,7 @@ const TokenProvider = ({ toastMessages, locale, children }: TokenProps) => { }, []); useEffect(() => { - // pravate paths are '/account', '/admin', '/projects/*' - const isPrivatePath = (pathname: string) => { - return ( - /^\/account(\/)?$/.test(pathname) || /^\/admin(\/)?$/.test(pathname) || /^\/projects(\/.*)?$/.test(pathname) - ); - }; - - const checkSignInPage = () => { - if (!hasRestoreFinished) { - return; - } - if (isPrivatePath(pathname) && !isSignedIn()) { - if (tokenExists()) { - toastContext.showToast(toastMessages.needSignedIn, 'error'); - router.push(`/account/signin`, { locale: locale }); - return; - } - if (isTokenValid()) { - toastContext.showToast(toastMessages.sessionExpired, 'error'); - router.push(`/account/signin`, { locale: locale }); - return; - } - } - }; - - checkSignInPage(); + tokenCheckSignInPage(token, pathname); }, [pathname, hasRestoreFinished]); return {children}; diff --git a/frontend/utils/token.test.ts b/frontend/utils/token.test.ts new file mode 100644 index 0000000..324b0a0 --- /dev/null +++ b/frontend/utils/token.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; +import { isPrivatePath, checkSignInPage, isAdmin } from './token'; + +describe('check account path', () => { + test('"account/" is private', () => { + expect(isPrivatePath('/account')).toBe(true); + }); + test('"account/signin" is public', () => { + expect(isPrivatePath('/account/signin')).toBe(false); + }); + test('"account/signup" is public', () => { + expect(isPrivatePath('/account/signup')).toBe(false); + }); +}); + +describe('check admin path', () => { + test('"admin/" is private', () => { + expect(isPrivatePath('/admin')).toBe(true); + }); + test('"admin/something" is private', () => { + expect(isPrivatePath('/admin/signin')).toBe(true); + }); +}); + +describe('check projects path', () => { + test('"projects/" is private', () => { + expect(isPrivatePath('/projects')).toBe(true); + }); + test('"projects/1" is private', () => { + expect(isPrivatePath('/projects/1')).toBe(true); + }); +}); + +const validAdminToken = { + access_token: 'loremipsumdolorsitametconsecteturadipiscingelit', + expires_at: Infinity, + user: { + id: 1, + email: 'admin@example.com', + password: 'hashedpassword', + username: 'admin', + role: 0, + avatarPath: null, + createdAt: '2024-06-01T00:18:03.859Z', + updatedAt: '2024-06-01T00:18:03.859Z', + }, +}; + +const validUserToken = { + access_token: 'loremipsumdolorsitametconsecteturadipiscingelit', + expires_at: Infinity, + user: { + id: 2, + email: 'user@example.com', + password: 'hashedpassword', + username: 'user', + role: 1, + avatarPath: null, + createdAt: '2024-06-01T00:18:03.859Z', + updatedAt: '2024-06-01T00:18:03.859Z', + }, +}; + +const expiredUserToken = { + access_token: 'loremipsumdolorsitametconsecteturadipiscingelit', + expires_at: 1717200000000, + user: { + id: 2, + email: 'user@example.com', + password: 'hashedpassword', + username: 'user', + role: 1, + avatarPath: null, + createdAt: '2024-06-01T00:18:03.859Z', + updatedAt: '2024-06-01T00:18:03.859Z', + }, +}; + +describe('check user is admin or not', () => { + test('user with admin role is admin', () => { + expect(isAdmin(validAdminToken)).toBe(true); + }); + + test('user with user role is not admin', () => { + expect(isAdmin(validUserToken)).toBe(false); + }); +}); + +describe('check sign in', () => { + test('Access to public paths by not signed user will be allowed', () => { + const token = { + access_token: '', + expires_at: 0, + user: null, + }; + + const privatePath = '/'; + expect(checkSignInPage(token, privatePath)).toStrictEqual({ + ok: true, + reason: '', + redirectPath: '', + }); + }); + + test('Access to private paths by not signed user in will be redirected', () => { + const token = { + access_token: '', + expires_at: 0, + user: null, + }; + + const privatePath = '/account'; + expect(checkSignInPage(token, privatePath)).toStrictEqual({ + ok: false, + reason: 'notoken', + redirectPath: '/account/signin', + }); + }); + + test('Access to private paths by signed in user will be allowed', () => { + const privatePath = '/account'; + expect(checkSignInPage(validAdminToken, privatePath)).toStrictEqual({ + ok: true, + reason: '', + redirectPath: '', + }); + }); + + test('Access to private paths by user whose token expired will be redirected', () => { + const privatePath = '/account'; + expect(checkSignInPage(expiredUserToken, privatePath)).toStrictEqual({ + ok: false, + reason: 'expired', + redirectPath: '/account/signin', + }); + }); +}); diff --git a/frontend/utils/token.ts b/frontend/utils/token.ts new file mode 100644 index 0000000..5edab7a --- /dev/null +++ b/frontend/utils/token.ts @@ -0,0 +1,69 @@ +import { TokenType } from '@/types/user'; +import { roles } from '@/config/selection'; + +function tokenExists(token: TokenType) { + if (token && token.user && token.user.username) { + return true; + } else { + return false; + } +} + +function isTokenValid(token: TokenType) { + if (Date.now() < token.expires_at) { + return true; + } else { + return false; + } +} + +function isSignedIn(token: TokenType): boolean { + if (tokenExists(token) && isTokenValid(token)) { + return true; + } else { + return false; + } +} + +function isAdmin(token: TokenType) { + if (tokenExists(token) && isTokenValid(token)) { + const adminRoleIndex = roles.findIndex((entry) => entry.uid === 'administrator'); + if (token.user.role === adminRoleIndex) { + return true; + } + } + + return false; +} + +// pravate paths are '/account', '/admin', '/projects/*' +const isPrivatePath = (pathname: string) => { + return /^\/account(\/)?$/.test(pathname) || /^\/admin(\/.*)?$/.test(pathname) || /^\/projects(\/.*)?$/.test(pathname); +}; + +function checkSignInPage(token: TokenType, pathname: string) { + let ret = { + ok: true, + reason: '', + redirectPath: '', + }; + + if (isPrivatePath(pathname) && !isSignedIn(token)) { + if (!tokenExists(token)) { + ret.ok = false; + ret.reason = 'notoken'; + ret.redirectPath = '/account/signin'; + return ret; + } + if (!isTokenValid(token)) { + ret.ok = false; + ret.reason = 'expired'; + ret.redirectPath = '/account/signin'; + return ret; + } + } + + return ret; +} + +export { isSignedIn, isAdmin, isPrivatePath, checkSignInPage }; diff --git a/vitest.config.ts b/vitest.config.ts index 50a0ce7..6143f1e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,14 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; export default defineConfig({ test: { coverage: { exclude: ['**/node_modules/**', 'docs/**', '**/.next/**'], - provider: "v8", + provider: 'v8', }, }, + resolve: { + alias: [{ find: '@', replacement: resolve(__dirname, './frontend') }], + }, });