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 ConsoleLayout from "./layouts/ConsoleLayout";
import AuthLayout from "./layouts/AuthLayout"; import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment"; import { RelayEnvironment } from "./RelayEnvironment";
import { AuthProvider } from "./contexts/AuthContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
posthog.init(process.env.POSTHOG_KEY!, { posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST, api_host: process.env.POSTHOG_HOST,
@@ -63,7 +61,6 @@ function App() {
<RelayEnvironmentProvider environment={RelayEnvironment}> <RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider> <HelmetProvider>
<BrowserRouter> <BrowserRouter>
<AuthProvider>
<Routes> <Routes>
{/* Authentication Routes - Accessible without login */} {/* Authentication Routes - Accessible without login */}
<Route <Route
@@ -106,7 +103,6 @@ function App() {
/> />
</Route> </Route>
<Route element={<ProtectedRoute />}>
<Route <Route
path="/" path="/"
element={ element={
@@ -340,7 +336,6 @@ function App() {
} }
/> />
</Route> </Route>
</Route>
<Route <Route
path="*" path="*"
element={ element={
@@ -350,7 +345,6 @@ function App() {
} }
/> />
</Routes> </Routes>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</HelmetProvider> </HelmetProvider>
</RelayEnvironmentProvider> </RelayEnvironmentProvider>

View File

@@ -6,6 +6,17 @@ import {
Store, Store,
} from "relay-runtime"; } from "relay-runtime";
import { buildEndpoint } from "./utils"; 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 ( const fetchRelay: FetchFunction = async (
request, request,
@@ -66,7 +77,13 @@ const fetchRelay: FetchFunction = async (
const json = await response.json(); 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( throw new Error(
`Error fetching GraphQL query '${ `Error fetching GraphQL query '${
request.name request.name

View File

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

View File

@@ -27,9 +27,9 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useAuth } from "@/contexts/AuthContext";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql"; import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { NavUserSkeleton } from "./NavUserSkeleton"; import { NavUserSkeleton } from "./NavUserSkeleton";
import { buildEndpoint } from "@/utils";
export const navUserFragment = graphql` export const navUserFragment = graphql`
fragment NavUser_viewer on User { fragment NavUser_viewer on User {
@@ -42,13 +42,16 @@ export const navUserFragment = graphql`
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) { export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer); const currentUser = useFragment(navUserFragment, viewer);
const { logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { organizationId } = useParams(); const { organizationId } = useParams();
const handleLogout = async () => { const handleLogout = async () => {
await logout(); fetch(buildEndpoint("/auth/logout"), {
method: "POST",
credentials: "include",
}).then(() => {
navigate("/login"); navigate("/login");
});
}; };
if (!organizationId) { 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 { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useLocation } from "react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { buildEndpoint } from "@/utils"; import { buildEndpoint } from "@/utils";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const location = useLocation();
const { checkAuth } = useAuth();
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(buildEndpoint("/api/console/v1/auth/login"), { const response = await fetch(
buildEndpoint("/api/console/v1/auth/login"),
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
credentials: "include", credentials: "include",
}); }
);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.message || "Failed to login"); 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) { } catch (error: unknown) {
toast({ toast({
title: "Error", title: "Error",

View File

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

View File

@@ -110,7 +110,14 @@ func graphqlHandler(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg
if user == nil { if user == nil {
return func(ctx context.Context) *graphql.Response { return func(ctx context.Context) *graphql.Response {
return &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",
},
},
},
} }
} }
} }