Skip to content

Commit

Permalink
Fix: potentially sessions (#1055)
Browse files Browse the repository at this point in the history
  • Loading branch information
emielvanseveren authored Jun 28, 2024
1 parent fc13ae1 commit 4100618
Show file tree
Hide file tree
Showing 45 changed files with 195 additions and 164 deletions.
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions packages/web-main/src/components/Navbar/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,15 @@ const Name = styled.div`
`;

export const UserDropdown = () => {
const { session } = useAuth();
const { logOut } = useAuth();
const navigate = useNavigate();

const { data, isPending } = useQuery(userMeQueryOptions());

const hasMultipleDomains = isPending === false && data && data.domains && data.domains.length > 1 ? true : false;

// TODO: this should be a fallback component, to stil try to logout.
if (session === null) return <div>could not get session</div>;
if (!data) return <div>could not get user information</div>;

const { name, email } = session;
const { name, email } = data.user;
return (
<Dropdown placement="top">
<Dropdown.Trigger asChild>
Expand Down
6 changes: 2 additions & 4 deletions packages/web-main/src/components/Navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ const domainLinks: NavbarLink[] = [
{
label: 'Players',
linkProps: {
to: '/players' as any,
to: '/players',
},
icon: <PlayersIcon />,
requiredPermissions: [PERMISSIONS.ReadPlayers],
},
{
label: 'Users',
linkProps: {
to: '/users' as any,
to: '/users',
},
icon: <UsersIcon />,
requiredPermissions: [PERMISSIONS.ReadUsers],
Expand Down Expand Up @@ -112,8 +112,6 @@ export interface NavbarLink {
export const renderLink = ({ linkProps, icon, label, requiredPermissions }: NavbarLink) => (
<PermissionsGuard key={`guard-${linkProps.to}`} requiredPermissions={requiredPermissions || []}>
<div key={`wrapper-${linkProps.to}`}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment*/}
{/* @ts-ignore reusable link */}
<Link to={linkProps.to} key={`link-${linkProps.to}`}>
<span key={`inner-${linkProps.to}`}>
{cloneElement(icon, { size: 20, key: `icon-${linkProps.to}` })}
Expand Down
4 changes: 2 additions & 2 deletions packages/web-main/src/components/PermissionsGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PERMISSIONS } from '@takaro/apiclient';
import { PermissionsGuard as Guard } from '@takaro/lib-components';
import { RequiredPermissions } from '@takaro/lib-components';
import { useAuth } from 'hooks/useAuth';
import { useSession } from 'hooks/useSession';
import { FC, PropsWithChildren, ReactElement, useMemo } from 'react';

interface PermissionsGuardProps {
Expand All @@ -14,7 +14,7 @@ export const PermissionsGuard: FC<PropsWithChildren<PermissionsGuardProps>> = ({
children,
fallback,
}) => {
const { session } = useAuth();
const { session } = useSession();

const userPermissions = useMemo(() => {
if (!session) {
Expand Down
92 changes: 33 additions & 59 deletions packages/web-main/src/hooks/useAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useQueryClient } from '@tanstack/react-query';
import { UserOutputWithRolesDTO } from '@takaro/apiclient';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext } from 'react';
import { useOry } from './useOry';
import * as Sentry from '@sentry/react';
import { DateTime } from 'luxon';
import { getApiClient } from 'util/getApiClient';
import { redirect } from '@tanstack/react-router';

const SESSION_EXPIRES_AFTER_MINUTES = 5;

Expand All @@ -16,8 +15,7 @@ interface ExpirableSession {

export interface IAuthContext {
logOut: () => Promise<void>;
isAuthenticated: boolean;
session: UserOutputWithRolesDTO;
getSession: () => Promise<UserOutputWithRolesDTO>;
login: (user: UserOutputWithRolesDTO) => void;
}

Expand All @@ -35,95 +33,71 @@ function getLocalSession() {
return expirableSession.session;
}

function setLocalSession(session: UserOutputWithRolesDTO | null) {
if (session) {
const expirableSession: ExpirableSession = {
session,
expiresAt: DateTime.now().plus({ minutes: SESSION_EXPIRES_AFTER_MINUTES }).toISO(),
};
localStorage.setItem('session', JSON.stringify(expirableSession));
} else {
localStorage.removeItem('session');
}
}

export const AuthContext = createContext<IAuthContext | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const { oryClient } = useOry();
const [user, setUser] = useState<UserOutputWithRolesDTO | null>(getLocalSession());

const isAuthenticated = !!user;

const logOut = useCallback(async () => {
const logoutFlowRes = await oryClient.createBrowserLogoutFlow();
setStoredSession(null);
setUser(null);
setLocalSession(null);
queryClient.clear();
window.location.href = logoutFlowRes.data.logout_url;
// Extra clean up is done in /logout-return
return Promise.resolve();
}, [oryClient, queryClient]);

const login = useCallback((session: UserOutputWithRolesDTO) => {
setStoredSession(session);
setLocalSession(session);
Sentry.setUser({ id: session.id, email: session.email, username: session.name });
setUser(session);
}, []);

const refreshSession = useCallback(async () => {
try {
const response = await getApiClient().user.userControllerMe({
headers: {
'Cache-Control': 'no-cache',
},
});
const newSession = response.data.data.user;
login(newSession);
} catch (error) {
setStoredSession(null);
setUser(null);
queryClient.clear();
redirect({ to: '/login' });
}
}, [login, queryClient]);

const getSession = useCallback(async (): Promise<UserOutputWithRolesDTO | null> => {
const getSession = async function (): Promise<UserOutputWithRolesDTO> {
const session = getLocalSession();

if (session) {
return session;
}

try {
await refreshSession();
return JSON.parse(localStorage.getItem('session')!).session;
const newSession = (
await getApiClient().user.userControllerMe({
headers: {
'Cache-Control': 'no-cache',
},
})
).data.data.user;
setLocalSession(session);
return newSession;
} catch (error) {
// logout if session is invalid
localStorage.removeItem('session');
return null;
}
}, [refreshSession]);

useEffect(() => {
async function fetchSession() {
const storedSession = await getSession();
if (!storedSession) {
refreshSession();
} else {
setUser(storedSession);
}
}
fetchSession();
}, [getSession, refreshSession]);

function setStoredSession(session: UserOutputWithRolesDTO | null) {
if (session) {
const expirableSession: ExpirableSession = {
session,
expiresAt: DateTime.now().plus({ minutes: SESSION_EXPIRES_AFTER_MINUTES }).toISO(),
};
localStorage.setItem('session', JSON.stringify(expirableSession));
} else {
localStorage.removeItem('session');
setLocalSession(null);
queryClient.clear();
window.location.href = '/login';
throw 'should not have no session and not be redirected to login';
}
}
};

return (
<AuthContext.Provider
value={{
session: user as UserOutputWithRolesDTO,
login,
logOut,
isAuthenticated,
getSession,
}}
>
{children}
Expand Down
4 changes: 2 additions & 2 deletions packages/web-main/src/hooks/useHasPermission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { useMemo } from 'react';
import { RequiredPermissions } from '@takaro/lib-components';
import { hasPermissionHelper } from '@takaro/lib-components/src/components/other/PermissionsGuard';
import { PERMISSIONS, UserOutputWithRolesDTO } from '@takaro/apiclient';
import { useAuth } from 'hooks/useAuth';
import { useSession } from './useSession';

export const useHasPermission = (requiredPermissions: RequiredPermissions) => {
const { session } = useAuth();
const { session } = useSession();

const userPermissions = useMemo(() => {
if (!session) {
Expand Down
16 changes: 16 additions & 0 deletions packages/web-main/src/hooks/useSession.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { UserOutputWithRolesDTO } from '@takaro/apiclient';
import { createContext, useContext } from 'react';

export interface ISessionContext {
session: UserOutputWithRolesDTO;
}

export const SessionContext = createContext<ISessionContext>({} as any);

export function useSession() {
const context = useContext(SessionContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
1 change: 0 additions & 1 deletion packages/web-main/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const options = {
Sentry.setTag('traceId', traceId);
}
}

Sentry.captureException(error);
return true;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/web-main/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { queryClient } from './queryClient';
import { routeTree } from './routeTree.gen';
import { createRouter } from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { IAuthContext } from 'hooks/useAuth';
import { DefaultErrorComponent } from 'components/ErrorComponent';
import { IAuthContext } from 'hooks/useAuth';
import { UserOutputWithRolesDTO } from '@takaro/apiclient';

export interface RouterContext {
queryClient: QueryClient;
Expand All @@ -14,10 +15,9 @@ export const router = createRouter({
routeTree,
context: {
auth: {
isAuthenticated: false,
logOut: async () => {},
session: undefined!,
login: () => {},
getSession: async () => ({} as Promise<UserOutputWithRolesDTO>),
},
queryClient: queryClient,
},
Expand Down
22 changes: 19 additions & 3 deletions packages/web-main/src/routes/_auth.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router';
import { SessionContext } from 'hooks/useSession';

export const Route = createFileRoute('/_auth')({
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated === false) {
beforeLoad: async ({ context }) => {
const session = await context.auth.getSession();
if (!session) {
const redirectPath = location.pathname === '/login' ? '/' : location.pathname;
throw redirect({ to: '/login', search: { redirect: redirectPath } });
}
},
component: () => <Outlet />,

loader: async ({ context }) => {
return await context.auth.getSession();
},
component: Component,
});

function Component() {
const session = Route.useLoaderData();

return (
<SessionContext.Provider value={{ session }}>
<Outlet />
</SessionContext.Provider>
);
}
3 changes: 2 additions & 1 deletion packages/web-main/src/routes/_auth/_global/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { PlayersOnlineStatsQueryOptions, ActivityStatsQueryOptions } from 'queri

export const Route = createFileRoute('/_auth/_global/dashboard')({
beforeLoad: async ({ context }) => {
if (!hasPermission(context.auth.session, ['READ_EVENTS'])) {
const session = await context.auth.getSession();
if (!hasPermission(session, ['READ_EVENTS'])) {
throw redirect({ to: '/forbidden' });
}
},
Expand Down
3 changes: 2 additions & 1 deletion packages/web-main/src/routes/_auth/_global/events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ type EventSearch = z.infer<typeof eventSearchSchema>;
export const Route = createFileRoute('/_auth/_global/events')({
validateSearch: eventSearchSchema,
beforeLoad: async ({ context }) => {
if (!hasPermission(context.auth.session, ['READ_EVENTS', 'READ_GAMESERVERS', 'READ_PLAYERS', 'READ_USERS'])) {
const session = await context.auth.getSession();
if (!hasPermission(session, ['READ_EVENTS', 'READ_GAMESERVERS', 'READ_PLAYERS', 'READ_USERS'])) {
throw redirect({ to: '/forbidden' });
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export interface IFormInputs {
}

export const Route = createFileRoute('/_auth/_global/gameservers/create/import')({
beforeLoad: ({ context }) => {
if (!hasPermission(context.auth.session, ['MANAGE_GAMESERVERS'])) {
beforeLoad: async ({ context }) => {
const session = await context.auth.getSession();
if (!hasPermission(session, ['MANAGE_GAMESERVERS'])) {
throw redirect({ to: '/forbidden' });
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { GameServerCreateDTOTypeEnum } from '@takaro/apiclient';
import { hasPermission } from 'hooks/useHasPermission';

export const Route = createFileRoute('/_auth/_global/gameservers/create/')({
beforeLoad: ({ context }) => {
if (!hasPermission(context.auth.session, ['MANAGE_GAMESERVERS'])) {
beforeLoad: async ({ context }) => {
const session = await context.auth.getSession();
if (!hasPermission(session, ['MANAGE_GAMESERVERS'])) {
throw redirect({ to: '/forbidden' });
}
},
Expand Down
5 changes: 3 additions & 2 deletions packages/web-main/src/routes/_auth/_global/gameservers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { Fragment } from 'react';
import { PERMISSIONS } from '@takaro/apiclient';

export const Route = createFileRoute('/_auth/_global/gameservers')({
beforeLoad: ({ context }) => {
if (!hasPermission(context.auth.session, ['READ_GAMESERVERS'])) {
beforeLoad: async ({ context }) => {
const session = await context.auth.getSession();
if (!hasPermission(session, ['READ_GAMESERVERS'])) {
throw redirect({ to: '/forbidden' });
}
},
Expand Down
Loading

0 comments on commit 4100618

Please sign in to comment.