🎉 Initial commit
Some checks failed
Deploy to Netlify / Deploy to Netlify (push) Failing after 1m8s

This commit is contained in:
Nathan 2025-06-19 15:48:47 +02:00
commit b54d087a51
30 changed files with 6290 additions and 0 deletions

View file

@ -0,0 +1,24 @@
name: "Deploy to Netlify"
on:
push:
branches:
- main
jobs:
deploy:
name: "Deploy to Netlify"
steps:
- uses: actions/checkout@v4
name: Checkout
- name: Build
run: |
npm --version && node --version
npm ci --no-update-notifier
npm run build
- name: Deploy to Netlify
run: npm run deploy
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.DS_Store
/node_modules/
.env
design/
# React Router
/.react-router/
/build/

87
README.md Normal file
View file

@ -0,0 +1,87 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
To build and run using Docker:
```bash
docker build -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

128
app/app.css Normal file
View file

@ -0,0 +1,128 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
html,
body {
@apply w-full h-full;
}

View file

@ -0,0 +1,70 @@
import { AlertCircleIcon, LoaderIcon } from "lucide-react";
import { useState } from "react";
import { Alert, AlertDescription } from "./ui/alert";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { verifyOtp } from "~/lib/api";
export default function OtpInput({
email,
setOtpError,
otpError,
}: {
email: string;
setOtpError: (error: string | null) => void;
otpError: string | null;
}) {
const [isVerifying, setIsVerifying] = useState(false);
// Handle OTP verification
const handleVerifyOtp = (otpCode: string) => {
setIsVerifying(true);
setOtpError(null);
verifyOtp({ otpCode, email })
.then(() => {
setIsVerifying(false);
// TODO: Proper redirect to success page
})
.catch((error) =>
setOtpError(
error instanceof Error
? error.message
: "Code de vérification invalide"
)
);
};
return (
<div className="space-y-4">
<div className="flex-1 flex items-center justify-center">
<InputOTP
maxLength={6}
onComplete={(value) => handleVerifyOtp(value)}
disabled={isVerifying}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
{isVerifying && (
<div className="flex justify-center items-center py-2">
<LoaderIcon className="h-5 w-5 animate-spin text-primary mr-2" />
<span className="text-sm">Vérification en cours...</span>
</div>
)}
{otpError && (
<Alert variant="destructive" className="mt-2 pb-2">
<AlertCircleIcon className="h-4 w-4" />
<AlertDescription>{otpError}</AlertDescription>
</Alert>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "~/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,68 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "~/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View file

@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

47
app/layout.tsx Normal file
View file

@ -0,0 +1,47 @@
import { UserLock } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./components/ui/card";
import { Button } from "./components/ui/button";
import { Link } from "react-router";
const SUPPORT_MAIL = "mailto:" + import.meta.env.VITE_SUPPORT_EMAIL;
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="flex h-full flex-col items-center justify-center p-4">
<div className="w-full max-w-md">
<Card className="w-full">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center justify-center">
<UserLock className="h-6 w-6 mr-2" />
Connexion
</CardTitle>
<CardDescription className="text-center">
Entrez votre email pour recevoir un lien de connexion ou un code
de vérification.
</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
<CardFooter className="flex justify-between text-sm text-muted-foreground">
<p className="text-xs">Besoin d'aide ?</p>
<Button variant="link" size="sm" asChild className="h-auto p-0">
<Link to={SUPPORT_MAIL} className="text-xs">
Contactez le support
</Link>
</Button>
</CardFooter>
</Card>
</div>
</main>
);
}

50
app/lib/api.ts Normal file
View file

@ -0,0 +1,50 @@
const BASE_URL = import.meta.env.VITE_API_URL;
const makeRequest = async (
url: string,
body: object,
error = "Une erreur est survenue",
method = "POST"
) => {
const response = await fetch(BASE_URL + url, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || error);
return data?.data || data;
};
export const requestLogin = async (email: string, token: string) => {
return makeRequest(
"/auth/request",
{ email, token },
"Échec de la demande de connexion"
);
};
export const verifyOtp = async ({
otpCode,
email,
}: {
otpCode: string;
email: string;
}) => {
return makeRequest(
"/auth/verify",
{ email, code: otpCode },
"Code de vérification invalide"
);
};
export const sendOtp = async (email: string) => {
return makeRequest(
"/auth/send-otp",
{ email },
"Échec de l'envoi du code de vérification"
);
};

6
app/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

80
app/root.tsx Normal file
View file

@ -0,0 +1,80 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
{/* Favicon */}
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Ooups!";
let details = "Une erreur est survenue.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Erreur";
details =
error.status === 404
? "La page que vous cherchez n'existe pas."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

7
app/routes.ts Normal file
View file

@ -0,0 +1,7 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/login.tsx"),
route("/verify", "routes/verify.tsx"),
route("/success", "routes/success.tsx"),
] satisfies RouteConfig;

106
app/routes/login.tsx Normal file
View file

@ -0,0 +1,106 @@
import type { Route } from "./+types/login";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AlertCircleIcon, LoaderCircle, LogIn, MailIcon } from "lucide-react";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { useState } from "react";
import AuthLayout from "~/layout";
import { requestLogin } from "~/lib/api";
import { useNavigate } from "react-router";
import { Turnstile } from "@marsidev/react-turnstile";
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
export function meta({}: Route.MetaArgs) {
return [
{ title: "Khollisé - Connexion" },
{ name: "description", content: "Connectez-vous à Khollisé" },
];
}
export default function Login() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const validateEmail = (email: string) => email.endsWith("@bginette.fr");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!validateEmail(email))
return setError("Seules les adresses email @bginette.fr sont autorisées");
if (!token)
return setError("Veuillez valider le captcha avant de continuer");
setIsLoading(true);
await requestLogin(email, token)
.then((data) => {
setIsLoading(false);
const url = `/verify?email=${encodeURIComponent(email)}&token=${
data?.token
}`;
navigate(url, {
replace: true,
});
})
.catch((err) => {
setIsLoading(false);
setError(err.message);
});
};
return (
<AuthLayout>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500">
<MailIcon className="h-5 w-5" />
</div>
<Input
id="email"
type="email"
placeholder="prenom.nom@bginette.fr"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<p className="text-xs text-muted-foreground">
Seules les adresses email{" "}
<span className="font-bold">@bginette.fr</span> sont acceptées.
</p>
</div>
{error && (
<Alert variant="destructive" className="pb-2">
<AlertCircleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Turnstile siteKey={siteKey} onSuccess={setToken} />
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin mr-2" />
Connexion en cours...
</>
) : (
<>
<LogIn className="h-4 w-4 mr-2" />
Se connecter
</>
)}
</Button>
</form>
</AuthLayout>
);
}

132
app/routes/success.tsx Normal file
View file

@ -0,0 +1,132 @@
import type { Route } from "./+types/success";
import { useState, useEffect } from "react";
import {
CheckCircleIcon,
ArrowRightIcon,
MonitorSmartphone,
} from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Khollisé - Connexion" },
{ name: "description", content: "Connexion réussie à Khollisé" },
];
}
export default function AuthSuccessPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, redirect, code] = ["email", "redirect", "code"].map((param) =>
searchParams.get(param)
);
const [countdown, setCountdown] = useState(5000);
// Valider l'URL de redirection
const redirectUrl = "/home";
// isValidRedirectUrl(redirectParam) ? redirectParam : "/accueil"
useEffect(() => {
// Démarrer le compte à rebours pour la redirection
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
navigate(redirectUrl);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [redirectUrl, navigate]);
return (
<main className="flex h-full flex-col items-center justify-center p-4 bg-gray-50">
<div className="w-full max-w-md">
<Card className="w-full overflow-hidden">
<div className="bg-green-500 h-2" />
<CardHeader className="space-y-1 pb-6">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-green-100 p-3">
<CheckCircleIcon className="h-10 w-10 text-green-600" />
</div>
</div>
<CardTitle className="text-2xl font-bold text-center">
Authentification réussie
</CardTitle>
<CardDescription className="text-center">
Vous êtes maintenant connecté en tant que
<span className="font-bold"> {email}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg border border-green-100">
<CheckCircleIcon className="h-5 w-5 text-green-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-green-800">
Cet appareil a é authentifié avec succès
</p>
<p className="text-xs text-green-700 mt-1">
Vous êtes maintenant connecté et pouvez accéder à votre
compte.
</p>
</div>
</div>
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg border border-blue-100">
<MonitorSmartphone className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800">
Authentification multi-appareils
</p>
<p className="text-xs text-blue-700 mt-1">
Si vous avez initié cette connexion depuis un autre
appareil, celui-ci a également é authentifié.
</p>
</div>
</div>
</div>
<div className="flex flex-col space-y-3">
<p className="text-sm text-center text-gray-500">
Vous allez être redirigé dans{" "}
<span className="font-bold">{countdown}</span> secondes...
</p>
<Button asChild className="w-full">
<Link
to={redirectUrl}
className="flex items-center justify-center"
>
Continuer maintenant
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="pt-4 border-t border-gray-100">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">Besoin d'aide ?</p>
<Button variant="link" size="sm" asChild className="h-auto p-0">
<Link to="/support" className="text-xs">
Contacter le support
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</main>
);
}

167
app/routes/verify.tsx Normal file
View file

@ -0,0 +1,167 @@
import type { Route } from "./+types/verify";
import {
ArrowLeft,
CheckCircleIcon,
LoaderIcon,
RefreshCwIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useSearchParams } from "react-router";
import OtpInput from "~/components/input-otp";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import AuthLayout from "~/layout";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Khollisé - Connexion" },
{ name: "description", content: "Connectez-vous à Khollisé" },
];
}
export default function Verify() {
const navigate = useNavigate();
const [searchParams] = useSearchParams()
const email = searchParams.get("email");
if (!email) {
// TODO: Redirect to login page
return
}
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [otpError, setOtpError] = useState<string | null>(null);
// Countdown for the resend button
useEffect(() => {
if (cooldown <= 0) return;
const timer = setInterval(() => {
setCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [cooldown]);
const handleResendOtp = async () => {
if (cooldown > 0) return;
setIsResending(true);
setResendSuccess(false);
try {
// Appeler l'API pour renvoyer un code OTP
const response = await fetch("/api/auth/resend-otp", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Échec de l'envoi du nouveau code");
}
// Afficher le message de succès
setResendSuccess(true);
// Démarrer le cooldown (60 secondes)
setCooldown(60);
} catch (error) {
console.error("Erreur lors de la demande d'un nouveau code:", error);
setOtpError(
error instanceof Error
? error.message
: "Échec de l'envoi du nouveau code"
);
} finally {
setIsResending(false);
}
};
return (
<AuthLayout>
<div className="space-y-4">
<Alert className="bg-green-50 border-green-200 pb-2">
<CheckCircleIcon className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-600">
Nous avons envoyé un email à <strong>{email}</strong>
</AlertDescription>
</Alert>
<div className="space-y-2 mt-4">
<h3 className="font-medium">Que se passe-t-il ensuite ?</h3>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Consultez votre email (vérifiez le dossier spam)</li>
<li>
Cliquez sur le lien pour vous connecter ou entrez le code à 6
chiffres
</li>
</ul>
</div>
<div className="mt-6 border-t pt-4">
<h3 className="text-sm text-center font-medium mb-2">
Entrez le code de vérification
</h3>
<OtpInput
email={email}
setOtpError={setOtpError}
otpError={otpError}
/>
{resendSuccess && (
<Alert className="bg-green-50 border-green-200 mt-2">
<CheckCircleIcon className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-600">
Un nouveau code a é envoyé à votre adresse email
</AlertDescription>
</Alert>
)}
<div className="flex justify-between mt-4">
<Button
className="flex items-center text-sm"
size="sm"
variant="ghost"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
Retour
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleResendOtp}
disabled={cooldown > 0 || isResending}
className="flex items-center text-sm"
>
{isResending ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
Envoi en cours...
</>
) : cooldown > 0 ? (
<>
<RefreshCwIcon className="h-4 w-4" />
Renvoyer le code ({cooldown}s)
</>
) : (
<>
<RefreshCwIcon className="h-4 w-4" />
Renvoyer le code
</>
)}
</Button>
</div>
</div>
</div>
</AuthLayout>
);
}

4984
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "khollise-auth",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev --host",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc",
"deploy": "netlify deploy --prod --dir=./build/client --message=\"Deploy to Netlify from Forgejo\" --site=$NETLIFY_SITE_ID --auth=$NETLIFY_AUTH_TOKEN"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-label": "^2.1.6",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"input-otp": "^1.4.2",
"isbot": "^5.1.27",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.5.3",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.3.0"
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4"
}
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

7
react-router.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

8
vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});