Add login/register logic

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-25 17:32:26 +01:00
parent 49c3807b4f
commit e9ae77a4a0
28 changed files with 2075 additions and 419 deletions

View File

@@ -8,7 +8,10 @@ import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import "App.css";
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,
@@ -29,11 +32,12 @@ const VendorOverviewPage = lazy(() => import("./pages/VendorOverviewPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const CreatePeoplePage = lazy(() => import("./pages/CreatePeoplePage"));
const FrameworkOverviewPage = lazy(
() => import("./pages/FrameworkOverviewPage"),
() => import("./pages/FrameworkOverviewPage")
);
const ControlOverviewPage = lazy(() => import("./pages/ControlOverviewPage"));
const PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
function App() {
return (
@@ -43,125 +47,171 @@ function App() {
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route
path="/*"
element={
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
}
>
<AuthProvider>
<Routes>
{/* Authentication Routes - Accessible without login */}
<Route
index
path="/login"
element={
<Suspense>
<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>
{/* Protected Routes - Require authentication */}
<Route element={<ProtectedRoute />}>
<Route
path="/*"
element={
<ErrorBoundaryWithLocation>
<HomePage />
<ConsoleLayout />
</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/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense>
<NotFoundPage />
</Suspense>
}
/>
</Route>
</Routes>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<HomePage />
</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/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense>
<NotFoundPage />
</Suspense>
}
/>
</Route>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>

View File

@@ -0,0 +1,20 @@
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,5 +1,5 @@
/**
* @generated SignedSource<<578d66fa0d0065452d9fa64611b7cfda>>
* @generated SignedSource<<717a5c6c939c65b7e0790f84e2c41bcd>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -72,7 +72,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -109,7 +109,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -0,0 +1,78 @@
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>;
}
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("/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 (error) {
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

@@ -0,0 +1,40 @@
import { Outlet } from "react-router";
import { Toaster } from "@/components/ui/toaster";
export default function AuthLayout() {
return (
<div className="flex min-h-screen flex-col">
<div className="flex flex-1">
<div className="flex flex-1 flex-col bg-muted/40">
<div className="flex flex-1 items-center justify-center">
<main className="w-full max-w-md p-6">
<Outlet />
</main>
</div>
</div>
<div className="hidden flex-1 bg-gradient-to-br from-gray-900 to-gray-950 lg:block">
<div className="flex h-full items-center justify-center p-8">
<div className="relative h-full w-full">
<div className="absolute right-0 top-1/4 z-10">
<img
src="/assets/android-chrome-512x512.png"
alt="Probo Mascot"
className="h-auto w-96"
/>
</div>
<div className="absolute left-8 top-1/3 z-0 text-white">
<h1 className="text-4xl font-bold leading-tight">
Navigate compliance with
<br />
confidence thanks to{" "}
<span className="text-lime-400">probo</span>
</h1>
</div>
</div>
</div>
</div>
</div>
<Toaster />
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
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 location = useLocation();
const { checkAuth } = useAuth();
// Get the redirect path from location state or default to home
const from = location.state?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({
title: "Error",
description: "Email and password are required",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Login failed");
}
// Check authentication status after login
await checkAuth();
// Redirect to the original page or home after successful login
navigate(from, { replace: true });
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Login failed",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Log in - Probo</title>
</Helmet>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Log in</h1>
<p className="text-gray-500 dark:text-gray-400">
Enter your credentials to access your account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log in with email"}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Don't have an account?{" "}
<Link to="/register" className="underline">
Sign up here
</Link>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" className="w-full" disabled={isLoading}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<circle cx="12" cy="12" r="10" />
<path d="M17.13 17.13v-4.26l-3.2 3.2a4.33 4.33 0 0 1-6.13-6.13l3.2-3.2h-4.26" />
</svg>
Log in with Google
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
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 RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const { checkAuth } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({
title: "Error",
description: "Email and password are required",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/register"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Registration failed");
}
toast({
title: "Success",
description: "Account created successfully",
});
// Check authentication status after registration
await checkAuth();
// Redirect to home page after successful registration
navigate("/");
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Registration failed",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Sign up - Probo</title>
</Helmet>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Sign up</h1>
<p className="text-gray-500 dark:text-gray-400">
Enter your information to create an account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Sign up with email"}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Already have an account?{" "}
<Link to="/login" className="underline">
Log in here
</Link>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" className="w-full" disabled={isLoading}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<circle cx="12" cy="12" r="10" />
<path d="M17.13 17.13v-4.26l-3.2 3.2a4.33 4.33 0 0 1-6.13-6.13l3.2-3.2h-4.26" />
</svg>
Sign up with Google
</Button>
</div>
</>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<25866c1f4e6fb640f91b8d05651df9f4>>
* @generated SignedSource<<bcc0482212bee6fc9682545cc8255139>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -36,7 +36,7 @@ v1 = [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<b79fe4cf20a9bc930e7bd90ccc2daf03>>
* @generated SignedSource<<903f19e5b3c193151107de5969fe5259>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -163,7 +163,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -197,7 +197,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<d57e3b87db862a1cfa086ea4b410d567>>
* @generated SignedSource<<7ca2f2186b657853d9fce4c7a21a7db1>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -615,7 +615,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -654,7 +654,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<dde370798e95a61988dd69cdde3acaad>>
* @generated SignedSource<<fd54290261cf29d9b3cf42b70a68f398>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -94,7 +94,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -137,7 +137,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<fab1b817b8d6876b5bf4dc9320c4deef>>
* @generated SignedSource<<0b5e0bcf4f6519e1ccb1d7ffa3e488cb>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -56,7 +56,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -91,7 +91,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4b2d9a3c1a701053fe929da59580308b>>
* @generated SignedSource<<42f5a79d59c05d56dc664163fd507fed>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -94,7 +94,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -137,7 +137,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,