Fix unauthenticate user not redirect to login page

fix #31

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-11 14:33:24 +01:00
parent cc1c843d0a
commit b41f417af6
9 changed files with 358 additions and 431 deletions

View File

@@ -11,8 +11,6 @@ import ErrorBoundary from "./components/ErrorBoundary";
import ConsoleLayout from "./layouts/ConsoleLayout";
import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import { AuthProvider } from "./contexts/AuthContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
@@ -63,294 +61,290 @@ function App() {
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Authentication Routes - Accessible without login */}
<Routes>
{/* Authentication Routes - Accessible without login */}
<Route
path="/login"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
path="/login"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<LoginPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/register"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<RegisterPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route element={<ProtectedRoute />}>
<Route
path="/"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<OrganizationSelectionPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="/organizations/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
</Suspense>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/organizations/:organizationId/*"
element={
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<HomePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/:peopleId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
{/* Policy Routes */}
<Route
path="policies"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PolicyListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/:policyId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PolicyOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/:policyId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
</Route>
<Route
path="*"
index
element={
<Suspense>
<NotFoundPage />
<ErrorBoundaryWithLocation>
<LoginPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Routes>
</AuthProvider>
</Route>
<Route
path="/register"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<RegisterPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<OrganizationSelectionPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="/organizations/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
</Suspense>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/organizations/:organizationId/*"
element={
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<HomePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateOrganizationPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/:peopleId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateFrameworkPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdateControlPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
{/* Policy Routes */}
<Route
path="policies"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PolicyListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/:policyId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PolicyOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="policies/:policyId/update"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<UpdatePolicyPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="*"
element={
<Suspense>
<NotFoundPage />
</Suspense>
}
/>
</Routes>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>

View File

@@ -6,6 +6,17 @@ import {
Store,
} from "relay-runtime";
import { buildEndpoint } from "./utils";
import { GraphQLError } from "graphql";
export class UnAuthenticatedError extends Error {
constructor() {
super("UNAUTHENTICATED");
this.name = "UnAuthenticatedError";
}
}
const hasUnauthenticatedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHENTICATED";
const fetchRelay: FetchFunction = async (
request,
@@ -66,7 +77,13 @@ const fetchRelay: FetchFunction = async (
const json = await response.json();
if (Array.isArray(json.errors)) {
if (json.errors) {
const errors = json.errors as GraphQLError[];
if (errors.find(hasUnauthenticatedError)) {
throw new UnAuthenticatedError();
}
throw new Error(
`Error fetching GraphQL query '${
request.name

View File

@@ -1,5 +1,7 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorPage } from "@/pages/ErrorPage";
import { UnAuthenticatedError } from "@/RelayEnvironment";
import { Navigate } from "react-router";
interface ErrorBoundaryProps {
children: ReactNode;
@@ -9,16 +11,20 @@ interface ErrorBoundaryProps {
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
isUnAuthenticated: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
this.state = { hasError: false, isUnAuthenticated: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
if (error instanceof UnAuthenticatedError) {
return { hasError: true, error, isUnAuthenticated: true };
}
return { hasError: true, error, isUnAuthenticated: false };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
@@ -27,6 +33,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
render(): ReactNode {
if (this.state.isUnAuthenticated) {
return <Navigate to="/login" replace state={{ authRequired: true }} />;
}
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;

View File

@@ -27,9 +27,9 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { useAuth } from "@/contexts/AuthContext";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { NavUserSkeleton } from "./NavUserSkeleton";
import { buildEndpoint } from "@/utils";
export const navUserFragment = graphql`
fragment NavUser_viewer on User {
@@ -42,13 +42,16 @@ export const navUserFragment = graphql`
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer);
const { logout } = useAuth();
const navigate = useNavigate();
const { organizationId } = useParams();
const handleLogout = async () => {
await logout();
navigate("/login");
fetch(buildEndpoint("/auth/logout"), {
method: "POST",
credentials: "include",
}).then(() => {
navigate("/login");
});
};
if (!organizationId) {

View File

@@ -1,20 +0,0 @@
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "@/contexts/AuthContext";
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
// Show nothing while checking authentication
if (isLoading) {
return null;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
// Render the protected content
return <Outlet />;
}

View File

@@ -1,80 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { buildEndpoint } from "@/utils";
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
checkAuth: () => Promise<boolean>;
logout: () => Promise<void>;
}
// TODO: should be remove when backend returns proper authenticated error
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const navigate = useNavigate();
const checkAuth = async (): Promise<boolean> => {
try {
// Make a request to an endpoint that requires authentication
const response = await fetch(buildEndpoint("/api/console/v1/query"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "CheckAuth",
query: `query CheckAuth { viewer { id } }`,
variables: {},
}),
});
const authenticated =
response.ok && !response.headers.get("WWW-Authenticate");
setIsAuthenticated(authenticated);
return authenticated;
} catch {
setIsAuthenticated(false);
return false;
} finally {
setIsLoading(false);
}
};
const logout = async (): Promise<void> => {
try {
await fetch(buildEndpoint("/console/v1/auth/logout"), {
method: "POST",
credentials: "include",
});
} finally {
setIsAuthenticated(false);
navigate("/login");
}
};
useEffect(() => {
checkAuth();
}, []);
return (
<AuthContext.Provider
value={{ isAuthenticated, isLoading, checkAuth, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -1,50 +1,49 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { useState, useEffect } from "react";
import { Link, useLocation } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buildEndpoint } from "@/utils";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const { checkAuth } = useAuth();
const location = useLocation();
useEffect(() => {
if (location.state?.authRequired) {
toast({
title: "Authentication Required",
description: "You need to log in to access this resource",
variant: "destructive",
});
}
}, [location, toast]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/api/console/v1/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
credentials: "include",
});
const response = await fetch(
buildEndpoint("/api/console/v1/auth/login"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
credentials: "include",
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to login");
}
const authenticated = await checkAuth();
if (authenticated) {
toast({
title: "Success",
description: "Logged in successfully",
variant: "default",
});
navigate("/");
} else {
throw new Error("Authentication failed");
}
} catch (error: unknown) {
toast({
title: "Error",

View File

@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buildEndpoint } from "@/utils";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function RegisterPage() {
const [email, setEmail] = useState("");
@@ -15,7 +14,6 @@ export default function RegisterPage() {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const { checkAuth } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -32,14 +30,17 @@ export default function RegisterPage() {
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/api/console/v1/auth/register"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password, fullName }),
});
const response = await fetch(
buildEndpoint("/api/console/v1/auth/register"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password, fullName }),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -51,10 +52,6 @@ export default function RegisterPage() {
description: "Account created successfully",
});
// Check authentication status after registration
await checkAuth();
// Redirect to home page after successful registration
navigate("/");
} catch (error) {
toast({

View File

@@ -110,7 +110,14 @@ func graphqlHandler(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg
if user == nil {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{
Errors: gqlerror.List{gqlerror.Errorf("authentication required")},
Errors: gqlerror.List{
&gqlerror.Error{
Message: "authentication required",
Extensions: map[string]any{
"code": "UNAUTHENTICATED",
},
},
},
}
}
}