Skip to content

Commit

Permalink
implement role check, properly type nested roles, add route protectio…
Browse files Browse the repository at this point in the history
…n tests
  • Loading branch information
sissbruecker committed Sep 15, 2023
1 parent 6bb0d15 commit 3c7d8c0
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 25 deletions.
46 changes: 33 additions & 13 deletions packages/ts/react-auth/src/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,36 @@
* See <https://vaadin.com/commercial-license-and-service-terms> for the full
* license.
*/
import { useContext, type ReactNode } from 'react';
import { useContext } from 'react';
import type { RouteObject } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { type IndexRouteObject, Navigate, type NonIndexRouteObject, useLocation } from 'react-router-dom';
import { type AccessProps, AuthContext } from './useAuth.js';

type CustomMetadata = Record<string, any>;

type HandleWithAuth = Readonly<{ handle?: AccessProps & CustomMetadata }>;

type Override<T, E> = E & Omit<T, keyof E>;

type IndexRouteObjectWithAuth = Override<IndexRouteObject, HandleWithAuth>;
type NonIndexRouteObjectWithAuth = Override<
Override<NonIndexRouteObject, HandleWithAuth>,
{
children?: RouteObjectWithAuth[];
}
>;
export type RouteObjectWithAuth = IndexRouteObjectWithAuth | NonIndexRouteObjectWithAuth;

interface ProtectedRouteProps {
redirectPath: string;
route: ReactNode;
access: AccessProps;
element: JSX.Element;
}

function ProtectedRoute({ redirectPath, route }: ProtectedRouteProps): JSX.Element | null {
function ProtectedRoute({ redirectPath, access, element }: ProtectedRouteProps): JSX.Element | null {
const {
state: { initializing, user },
hasAccess,
} = useContext(AuthContext);

const location = useLocation();
Expand All @@ -28,11 +45,11 @@ function ProtectedRoute({ redirectPath, route }: ProtectedRouteProps): JSX.Eleme
return <div></div>;
}

if (!user) {
if (!hasAccess(access)) {
return <Navigate to={redirectPath} state={{ from: location }} replace />;
}

return route ? (route as JSX.Element) : <Outlet />;
return element;
}

const collectRoutes = <T,>(routes: T[]): T[] => {
Expand All @@ -46,10 +63,6 @@ const collectRoutes = <T,>(routes: T[]): T[] => {
return allRoutes;
};

export type RouteObjectWithAuth = RouteObject & {
handle?: AccessProps;
};

/**
* Adds protection to routes that require authentication.
* These routes should contain the {@link AccessProps.requiresLogin} and/or
Expand All @@ -67,10 +80,17 @@ export const protectRoutes = (
const allRoutes: RouteObjectWithAuth[] = collectRoutes(routes);

allRoutes.forEach((route) => {
const { handle } = route as AccessProps;
const { handle } = route;
const requiresAuth = handle?.requiresLogin || (handle?.rolesAllowed && handle.rolesAllowed.length > 0);

if (handle?.requiresLogin ?? handle?.rolesAllowed) {
route.element = <ProtectedRoute redirectPath={redirectPath} route={(route as RouteObject).element} />;
if (requiresAuth) {
route.element = (
<ProtectedRoute
redirectPath={redirectPath}
access={route.handle as AccessProps}
element={route.element as JSX.Element}
/>
);
}
});

Expand Down
20 changes: 9 additions & 11 deletions packages/ts/react-auth/src/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,8 @@ function reducer(state: AuthState, action: LoginActions | LogoutAction) {
* They can be added to the route type handler as properties.
*/
export type AccessProps = Readonly<{
handle?: {
requiresLogin?: boolean;
rolesAllowed?: readonly string[];
};
requiresLogin?: boolean;
rolesAllowed?: readonly string[];
}>;

/**
Expand All @@ -149,7 +147,7 @@ export type Authentication = Readonly<{
state: AuthState;
authenticate: AuthenticateThunk;
unauthenticate: UnauthenticateThunk;
hasAccess({ handle }: AccessProps): boolean;
hasAccess(accessProps: AccessProps): boolean;
}>;

/**
Expand All @@ -175,8 +173,8 @@ export function useAuth(getAuthenticatedUser?: AuthFunctionType): Authentication
state,
authenticate,
unauthenticate,
hasAccess({ handle }: AccessProps): boolean {
const requiresAuth = handle?.requiresLogin ?? handle?.rolesAllowed;
hasAccess(accessProps: AccessProps): boolean {
const requiresAuth = accessProps.requiresLogin ?? accessProps.rolesAllowed;
if (!requiresAuth) {
return true;
}
Expand All @@ -185,8 +183,8 @@ export function useAuth(getAuthenticatedUser?: AuthFunctionType): Authentication
return false;
}

if (handle?.rolesAllowed) {
return handle.rolesAllowed.some((allowedRole) => state.user?.roles?.includes(allowedRole));
if (accessProps.rolesAllowed) {
return accessProps.rolesAllowed.some((allowedRole) => state.user?.roles?.includes(allowedRole));
}

return true;
Expand All @@ -202,8 +200,8 @@ export const AuthContext = createContext<Authentication>({
state: initialState,
async authenticate() {},
unauthenticate() {},
hasAccess({ handle }: AccessProps): boolean {
return !handle?.requiresLogin && !handle?.rolesAllowed;
hasAccess(): boolean {
return true;
},
});

Expand Down
100 changes: 99 additions & 1 deletion packages/ts/react-auth/test/useAuth.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,61 @@
import { expect } from '@esm-bundle/chai';
import { render, waitFor } from '@testing-library/react';
import { AuthContext, useAuth } from '../src';
import { RouterProvider, createMemoryRouter } from 'react-router-dom';
import { AuthContext, type AuthUser, protectRoutes, type RouteObjectWithAuth, useAuth } from '../src';

function TestView({ route }: { route: string }) {
return <div>{`route: ${route}`}</div>;
}

const testRoutes: RouteObjectWithAuth[] = [
{
path: '/login',
element: <TestView route="/login" />,
},
{
path: '/public',
element: <TestView route="/public" />,
},
{
path: '/protected/login',
element: <TestView route="/protected/login" />,
handle: {
requiresLogin: true,
},
},
{
path: '/protected/role/user',
element: <TestView route="/protected/role/user" />,
handle: {
requiresLogin: true,
rolesAllowed: ['user'],
},
},
{
path: '/protected/role/admin',
element: <TestView route="/protected/role/admin" />,
handle: {
requiresLogin: true,
rolesAllowed: ['admin'],
},
},
];

function TestApp({ user, initialRoute }: { user?: AuthUser; initialRoute: string }) {
const auth = useAuth(async () => Promise.resolve(user));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(auth.state as any).user = user;
const protectedRoutes = protectRoutes(testRoutes);
const router = createMemoryRouter(protectedRoutes, {
initialEntries: [initialRoute],
});

return (
<AuthContext.Provider value={auth}>
<RouterProvider router={router} />
</AuthContext.Provider>
);
}

function SuccessfulLoginComponent() {
const getAuthenticatedUser = async () => Promise.resolve({ name: 'John', roles: ['admin'] });
Expand Down Expand Up @@ -36,4 +91,47 @@ describe('@hilla/react-auth', () => {
await waitFor(() => expect(getByText('Not logged in')).to.exist);
});
});

describe('protectRoutes', () => {
function testRoute(route: string, user: AuthUser | undefined, canAccess: boolean) {
const result = render(<TestApp initialRoute={route} user={user} />);
if (canAccess) {
expect(result.getByText(`route: ${route}`)).to.exist;
} else {
expect(result.getByText('route: /login')).to.exist;
}
result.unmount();
}

it('should protect routes when no user is authenticated', async () => {
testRoute('/public', undefined, true);
testRoute('/protected/login', undefined, false);
testRoute('/protected/role/user', undefined, false);
testRoute('/protected/role/admin', undefined, false);
});

it('should protect routes when user without roles is authenticated', async () => {
const user = { name: 'John' };
testRoute('/public', user, true);
testRoute('/protected/login', user, true);
testRoute('/protected/role/user', user, false);
testRoute('/protected/role/admin', user, false);
});

it('should protect routes when user with user role is authenticated', async () => {
const user = { name: 'John', roles: ['user'] };
testRoute('/public', user, true);
testRoute('/protected/login', user, true);
testRoute('/protected/role/user', user, true);
testRoute('/protected/role/admin', user, false);
});

it('should protect routes when user with all roles is authenticated', async () => {
const user = { name: 'John', roles: ['user', 'admin'] };
testRoute('/public', user, true);
testRoute('/protected/login', user, true);
testRoute('/protected/role/user', user, true);
testRoute('/protected/role/admin', user, true);
});
});
});

0 comments on commit 3c7d8c0

Please sign in to comment.