Skip to content

fix: add authorization for UI routes #917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cypress/e2e/repo.cy.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
describe('Repo', () => {
beforeEach(() => {
cy.login('admin', 'admin');

cy.visit('/admin/repo');

// prevent failures on 404 request and uncaught promises
Expand Down
2 changes: 1 addition & 1 deletion cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ Cypress.Commands.add('login', (username, password) => {
cy.get('[data-test=username]').type(username);
cy.get('[data-test=password]').type(password);
cy.get('[data-test=login]').click();
cy.url().should('contain', '/admin/profile');
cy.url().should('contain', '/admin/repo');
});
});
21 changes: 14 additions & 7 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { createBrowserHistory } from 'history';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './ui/auth/AuthProvider';

// core components
import Admin from './ui/layouts/Admin';
import Login from './ui/views/Login/Login';
import './ui/assets/css/material-dashboard-react.css';
import NotAuthorized from './ui/views/Extras/NotAuthorized';
import NotFound from './ui/views/Extras/NotFound';

const hist = createBrowserHistory();

ReactDOM.render(
<Router history={hist}>
<Routes>
<Route exact path='/admin/*' element={<Admin />} />
<Route exact path='/login' element={<Login />} />
<Route exact path='/' element={<Navigate from='/' to='/admin/repo' />} />
</Routes>
</Router>,
<AuthProvider>
<Router history={hist}>
<Routes>
<Route exact path='/admin/*' element={<Admin />} />
<Route exact path='/login' element={<Login />} />
<Route exact path='/not-authorized' element={<NotAuthorized />} />
<Route exact path='/' element={<Navigate from='/' to='/admin/repo' />} />
<Route path='*' element={<NotFound />} />
</Routes>
</Router>
</AuthProvider>,
document.getElementById('root'),
);
42 changes: 22 additions & 20 deletions src/routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

*/

import React from 'react';
import PrivateRoute from './ui/components/PrivateRoute/PrivateRoute';
import Person from '@material-ui/icons/Person';
import OpenPushRequests from './ui/views/OpenPushRequests/OpenPushRequests';
import PushDetails from './ui/views/PushDetails/PushDetails';
Expand All @@ -33,58 +35,58 @@ const dashboardRoutes = [
path: '/repo',
name: 'Repositories',
icon: RepoIcon,
component: RepoList,
component: (props) => <PrivateRoute component={RepoList} />,
layout: '/admin',
visible: true,
},
{
path: '/repo/:id',
name: 'Repo Details',
icon: Person,
component: (props) => <PrivateRoute component={RepoDetails} />,
layout: '/admin',
visible: false,
},
{
path: '/push',
name: 'Dashboard',
icon: Dashboard,
component: OpenPushRequests,
component: (props) => <PrivateRoute component={OpenPushRequests} />,
layout: '/admin',
visible: true,
},
{
path: '/push/:id',
name: 'Open Push Requests',
icon: Person,
component: PushDetails,
component: (props) => <PrivateRoute component={PushDetails} />,
layout: '/admin',
visible: false,
},
{
path: '/profile',
name: 'My Account',
icon: AccountCircle,
component: User,
component: (props) => <PrivateRoute component={User} />,
layout: '/admin',
visible: true,
},
{
path: '/user/:id',
name: 'User',
icon: Person,
component: User,
path: '/user',
name: 'Users',
icon: Group,
component: (props) => <PrivateRoute adminOnly component={UserList} />,
layout: '/admin',
visible: false,
visible: true,
},
{
path: '/repo/:id',
name: 'Repo Details',
path: '/user/:id',
name: 'User',
icon: Person,
component: RepoDetails,
component: (props) => <PrivateRoute adminOnly component={User} />,
layout: '/admin',
visible: false,
},
{
path: '/user',
name: 'Users',
icon: Group,
component: UserList,
layout: '/admin',
visible: true,
},
];

export default dashboardRoutes;
2 changes: 1 addition & 1 deletion src/service/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ router.post('/gitAccount', async (req, res) => {
}
});

router.get('/userLoggedIn', async (req, res) => {
router.get('/me', async (req, res) => {
if (req.user) {
const user = JSON.parse(JSON.stringify(req.user));
if (user && user.password) delete user.password;
Expand Down
49 changes: 49 additions & 0 deletions src/ui/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getUserInfo } from '../services/auth';

// Interface for when we convert to TypeScript
// interface AuthContextType {
// user: any;
// setUser: (user: any) => void;
// refreshUser: () => Promise<void>;
// isLoading: boolean;
// }

const AuthContext = createContext(undefined);

export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);

const refreshUser = async () => {
console.log('Refreshing user');
try {
const data = await getUserInfo();
setUser(data);
console.log('User refreshed:', data);
} catch (error) {
console.error('Error refreshing user:', error);
setUser(null);
} finally {
setIsLoading(false);
}
};

useEffect(() => {
refreshUser();
}, []);

return (
<AuthContext.Provider value={{ user, setUser, refreshUser, isLoading }}>
{children}
</AuthContext.Provider>
);
};

export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
27 changes: 27 additions & 0 deletions src/ui/components/PrivateRoute/PrivateRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthProvider';

const PrivateRoute = ({ component: Component, adminOnly = false }) => {
const { user, isLoading } = useAuth();
console.debug('PrivateRoute', { user, isLoading, adminOnly });

if (isLoading) {
console.debug('Auth is loading, waiting');
return <div>Loading...</div>; // TODO: Add loading spinner
}

if (!user) {
console.debug('User not logged in, redirecting to login page');
return <Navigate to="/login" />;
}

if (adminOnly && !user.admin) {
console.debug('User is not an admin, redirecting to not authorized page');
return <Navigate to="/not-authorized" />;
}

return <Component />;
};

export default PrivateRoute;
22 changes: 22 additions & 0 deletions src/ui/services/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const baseUrl = import.meta.env.VITE_API_URI
? `${import.meta.env.VITE_API_URI}`
: `${location.origin}`;

/**
* Gets the current user's information
* @return {Promise<Object>} The user's information
*/
export const getUserInfo = async () => {
try {
const response = await fetch(`${baseUrl}/api/auth/me`, {
credentials: 'include', // Sends cookies
});

if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`);

return await response.json();
} catch (error) {
console.error('Error fetching user info:', error);
return null;
}
};
2 changes: 1 addition & 1 deletion src/ui/services/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const updateUser = async (data) => {
};

const getUserLoggedIn = async (setIsLoading, setIsAdmin, setIsError, setAuth) => {
const url = new URL(`${baseUrl}/api/auth/userLoggedIn`);
const url = new URL(`${baseUrl}/api/auth/me`);

await axios(url.toString(), { withCredentials: true })
.then((response) => {
Expand Down
39 changes: 39 additions & 0 deletions src/ui/views/Extras/NotAuthorized.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import Card from '../../components/Card/Card';
import CardBody from '../../components/Card/CardBody';
import GridContainer from '../../components/Grid/GridContainer';
import GridItem from '../../components/Grid/GridItem';
import { Button } from '@material-ui/core';
import LockIcon from '@material-ui/icons/Lock';

const NotAuthorized = () => {
const navigate = useNavigate();

return (
<GridContainer justifyContent='center' style={{ marginTop: '50px' }}>
<GridItem xs={12} sm={8} md={6}>
<Card>
<CardBody style={{ textAlign: 'center', padding: '40px' }}>
<LockIcon style={{ fontSize: '60px', color: 'red' }} />
<h2>403 - Not Authorized</h2>
<p>
You do not have permission to access this page. Contact your administrator for more
information, or try logging in with a different account.
</p>
<Button
variant='contained'
color='primary'
onClick={() => navigate('/')}
style={{ marginTop: '20px' }}
>
Go to Home
</Button>
</CardBody>
</Card>
</GridItem>
</GridContainer>
);
};

export default NotAuthorized;
36 changes: 36 additions & 0 deletions src/ui/views/Extras/NotFound.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import Card from '../../components/Card/Card';
import CardBody from '../../components/Card/CardBody';
import GridContainer from '../../components/Grid/GridContainer';
import GridItem from '../../components/Grid/GridItem';
import { Button } from '@material-ui/core';
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';

const NotFound = () => {
const navigate = useNavigate();

return (
<GridContainer justifyContent='center' style={{ marginTop: '50px' }}>
<GridItem xs={12} sm={8} md={6}>
<Card>
<CardBody style={{ textAlign: 'center', padding: '40px' }}>
<ErrorOutlineIcon style={{ fontSize: '60px', color: 'gray' }} />
<h2>404 - Page Not Found</h2>
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
<Button
variant='contained'
color='primary'
onClick={() => navigate('/')}
style={{ marginTop: '20px' }}
>
Go to Home
</Button>
</CardBody>
</Card>
</GridItem>
</GridContainer>
);
};

export default NotFound;
18 changes: 7 additions & 11 deletions src/ui/views/Login/Login.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
// @material-ui/core components
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
Expand All @@ -12,21 +13,23 @@ import CardHeader from '../../components/Card/CardHeader';
import CardBody from '../../components/Card/CardBody';
import CardFooter from '../../components/Card/CardFooter';
import axios from 'axios';
import { Navigate } from 'react-router-dom';
import logo from '../../assets/img/git-proxy.png';
import { Badge, CircularProgress, Snackbar } from '@material-ui/core';
import { getCookie } from '../../utils';
import { useAuth } from '../../auth/AuthProvider';

const loginUrl = `${import.meta.env.VITE_API_URI}/api/auth/login`;

export default function UserProfile() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [success, setSuccess] = useState(false);
const [gitAccountError, setGitAccountError] = useState(false);
const [, setGitAccountError] = useState(false);
const [isLoading, setIsLoading] = useState(false);

const navigate = useNavigate();
const { refreshUser } = useAuth();

function validateForm() {
return (
username.length > 0 && username.length < 100 && password.length > 0 && password.length < 200
Expand Down Expand Up @@ -57,8 +60,8 @@ export default function UserProfile() {
.then(function () {
window.sessionStorage.setItem('git.proxy.login', 'success');
setMessage('Success!');
setSuccess(true);
setIsLoading(false);
refreshUser().then(() => navigate('/admin/repo'));
})
.catch(function (error) {
if (error.response.status === 307) {
Expand All @@ -75,13 +78,6 @@ export default function UserProfile() {
event.preventDefault();
}

if (gitAccountError) {
return <Navigate to={{ pathname: '/admin/profile' }} />;
}
if (success) {
return <Navigate to={{ pathname: '/admin/profile', state: { authed: true } }} />;
}

return (
<form onSubmit={handleSubmit}>
<Snackbar
Expand Down
Loading
Loading