Skip to content
Merged
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
311 changes: 309 additions & 2 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@heroicons/react": "^2.2.0",
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Landing from './pages/Landing'
import Login from './pages/auth/Login'

export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
{/* auth and app routes come later */}
<Route path="/login" element={<Login />} />
{/* protected app routes come next */}
</Routes>
</BrowserRouter>
)
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { apiClient } from "./client";

export interface AuthResponse {
accessToken: string;
refreshToken: string;
}

export interface LoginRequest {
email: string;
password: string;
}

export const authApi = {
login: (data: LoginRequest) =>
apiClient.post<AuthResponse>("/api/auth/login", data),

refresh: (refreshToken: string) =>
apiClient.post<AuthResponse>("/api/auth/refresh", { refreshToken }),

logout: (refreshToken: string) =>
apiClient.post<void>("/api/auth/logout", { refreshToken }),
};
91 changes: 91 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
import { tokenStorage } from "../auth/tokenStorage";

const BASE_URL = import.meta.env.VITE_API_BASE_URL;

export const apiClient = axios.create({
baseURL: BASE_URL,
headers: { "Content-Type": "application/json" },
timeout: 15_000,
});

// ── Request interceptor — inject access token ──────────────────────────────
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = tokenStorage.getAccess();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);

// ── Response interceptor — silent token refresh on 401 ────────────────────
let isRefreshing = false;
let refreshQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];

function processQueue(error: unknown, token: string | null) {
refreshQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else if (token) resolve(token);
});
refreshQueue = [];
}

apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};

// only attempt refresh on 401, and not on the refresh endpoint itself
const is401 = error.response?.status === 401;
const isRefreshEndpoint = original.url?.includes("/auth/refresh");

if (!is401 || isRefreshEndpoint || original._retry) {
return Promise.reject(error);
}

if (isRefreshing) {
// queue this request until the refresh completes
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`;
return apiClient(original);
});
}

original._retry = true;
isRefreshing = true;

try {
const refreshToken = tokenStorage.getRefresh();
if (!refreshToken) throw new Error("No refresh token");

const { data } = await axios.post<{
accessToken: string;
refreshToken: string;
}>(`${BASE_URL}/api/auth/refresh`, { refreshToken });

tokenStorage.set(data.accessToken, data.refreshToken);
processQueue(null, data.accessToken);

original.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(original);
} catch (refreshError) {
processQueue(refreshError, null);
tokenStorage.clear();
// signal the auth context to reset state
window.dispatchEvent(new Event("gatelog:session-expired"));
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
150 changes: 150 additions & 0 deletions frontend/src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { authApi } from "../api/auth";
import {
getEmailFromToken,
getRoleFromToken,
getUserIdFromToken,
isTokenExpired,
} from "./jwt";
import { tokenStorage } from "./tokenStorage";

export type Role = "SUPER_ADMIN" | "MANAGER" | "STAFF";

export interface AuthUser {
userId: string;
email: string;
role: Role;
}

interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
}

interface AuthContextValue extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isRole: (...roles: Role[]) => boolean;
}

const AuthContext = createContext<AuthContextValue | null>(null);

function buildUser(accessToken: string): AuthUser {
return {
userId: getUserIdFromToken(accessToken),
email: getEmailFromToken(accessToken),
role: getRoleFromToken(accessToken) as Role,
};
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>(() => {
const accessToken = tokenStorage.getAccess();
const refreshToken = tokenStorage.getRefresh();

if (!accessToken || !refreshToken) {
return { user: null, isAuthenticated: false, isLoading: false };
}

if (!isTokenExpired(accessToken)) {
return {
user: buildUser(accessToken),
isAuthenticated: true,
isLoading: false,
};
}

return { user: null, isAuthenticated: false, isLoading: true };
});

// ── Bootstrap — restore session from stored token ──────────────────────
useEffect(() => {
const accessToken = tokenStorage.getAccess();
const refreshToken = tokenStorage.getRefresh();

if (!accessToken || !refreshToken) return;

if (!isTokenExpired(accessToken)) {
return;
}

// access token expired — try silent refresh
authApi
.refresh(refreshToken)
.then(({ data }) => {
tokenStorage.set(data.accessToken, data.refreshToken);
setState({
user: buildUser(data.accessToken),
isAuthenticated: true,
isLoading: false,
});
})
.catch(() => {
tokenStorage.clear();
setState({ user: null, isAuthenticated: false, isLoading: false });
});
}, []);

// ── Listen for session expiry from axios interceptor ──────────────────
useEffect(() => {
const onExpired = () => {
setState({ user: null, isAuthenticated: false, isLoading: false });
};
window.addEventListener("gatelog:session-expired", onExpired);
return () => window.removeEventListener("gatelog:session-expired", onExpired);
}, []);

// ── Login ──────────────────────────────────────────────────────────────
const login = useCallback(async (email: string, password: string) => {
const { data } = await authApi.login({ email, password });
tokenStorage.set(data.accessToken, data.refreshToken);
setState({
user: buildUser(data.accessToken),
isAuthenticated: true,
isLoading: false,
});
}, []);

// ── Logout ─────────────────────────────────────────────────────────────
const logout = useCallback(async () => {
const refreshToken = tokenStorage.getRefresh();
try {
if (refreshToken) await authApi.logout(refreshToken);
} catch {
// server-side logout failure is non-critical — clear locally regardless
} finally {
tokenStorage.clear();
setState({ user: null, isAuthenticated: false, isLoading: false });
}
}, []);

// ── Role helper ────────────────────────────────────────────────────────
const isRole = useCallback(
(...roles: Role[]) => {
return state.user ? roles.includes(state.user.role) : false;
},
[state.user]
);

const value = useMemo(
() => ({ ...state, login, logout, isRole }),
[state, login, logout, isRole]
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// eslint-disable-next-line react-refresh/only-export-components
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
66 changes: 66 additions & 0 deletions frontend/src/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth, type Role } from "./AuthContext";

interface ProtectedRouteProps {
allowedRoles?: Role[];
}

export default function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();

// still bootstrapping from localStorage — render nothing
if (isLoading) return <AppLoader />;

// not authenticated — send to login, preserve intended destination
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}

// authenticated but wrong role
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}

return <Outlet />;
}

function AppLoader() {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: "var(--white)",
}}
aria-label="Loading"
role="status"
>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
style={{ animation: "spin 0.8s linear infinite" }}
>
<circle
cx="16"
cy="16"
r="13"
stroke="var(--neutral-200)"
strokeWidth="3"
/>
<path
d="M16 3a13 13 0 0113 13"
stroke="var(--green-500)"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
35 changes: 35 additions & 0 deletions frontend/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface JwtPayload {
sub: string; // userId
email: string;
role: string;
iat: number;
exp: number;
}

export function parseJwt(token: string): JwtPayload {
const payload = token.split(".")[1];
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(decoded);
}

export function isTokenExpired(token: string): boolean {
try {
const { exp } = parseJwt(token);
// treat as expired 30 seconds early to avoid edge cases
return Date.now() / 1000 >= exp - 30;
} catch {
return true;
}
}

export function getRoleFromToken(token: string): string {
return parseJwt(token).role;
}

export function getUserIdFromToken(token: string): string {
return parseJwt(token).sub;
}

export function getEmailFromToken(token: string): string {
return parseJwt(token).email;
}
Loading
Loading