Compare commits

...

8 Commits

13 changed files with 445 additions and 60 deletions

12
auth.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
export interface User extends Partial<DefaultSession<User>> {
access_token: string;
refresh_token: string;
}
export interface Session {
user: User;
}
}

View File

@ -10,7 +10,9 @@
},
"dependencies": {
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"jwt-decode": "^4.0.0",
"next": "15.2.3",
"next-auth": "^4.24.11",

31
pnpm-lock.yaml generated
View File

@ -11,9 +11,15 @@ importers:
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.69.0(react@19.0.0)
'@tanstack/react-table':
specifier: ^8.21.2
version: 8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
axios:
specifier: ^1.8.4
version: 1.8.4
clsx:
specifier: ^2.1.1
version: 2.1.1
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@ -1153,6 +1159,17 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.2':
resolution: {integrity: sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.2':
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
engines: {node: '>=12'}
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@ -1437,6 +1454,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -3996,6 +4017,14 @@ snapshots:
'@tanstack/query-core': 5.69.0
react: 19.0.0
'@tanstack/react-table@8.21.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@tanstack/table-core': 8.21.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@tanstack/table-core@8.21.2': {}
'@trysound/sax@0.2.0': {}
'@tybys/wasm-util@0.9.0':
@ -4327,6 +4356,8 @@ snapshots:
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4

View File

@ -1,4 +1,7 @@
"use client";
import Form from "#/components/form/form"
import { loginSchema } from "#/schema/loginSchema"
export default function LoginPage() {
return(
@ -17,10 +20,12 @@ export default function LoginPage() {
label: "Password",
name: "password",
type: "password",
placeholder: "Enter votre mot de passe"
placeholder: "Enter votre mot de passe",
showPasswordToggle: true
}
]}
submit={undefined}
schema={loginSchema}
child={<button type="submit" className="btn-auth">Connexion</button>}
/>
</div>

View File

@ -1,10 +1,129 @@
"use client"
import Table from "#/components/table/table"
import { ColumnDef } from "@tanstack/react-table"
export default function HomePage () {
type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
const data: Payment[] = [
{
id: "728ed52f",
amount: 100,
status: "pending",
email: "m@example.com",
},
{
id: "728ed521",
amount: 200,
status: "pending",
email: "j@example.com",
},
{
id: "728ed528",
amount: 300,
status: "processing",
email: "f@example.com",
},
{
id: "728ed52g",
amount: 600,
status: "success",
email: "h@example.com",
},
{
id: "728ed520",
amount: 50,
status: "failed",
email: "k@example.com",
},
{
id: "728ed529",
amount: 200,
status: "pending",
email: "l@example.com",
},
{
id: "728ed526",
amount: 150,
status: "processing",
email: "d@example.com",
},
{
id: "728ed523",
amount: 100,
status: "success",
email: "o@example.com",
},
{
id: "728ed52y",
amount: 100,
status: "failed",
email: "v@example.com",
},
// ...
]
const columns: ColumnDef<Payment>[] = [
{
id: "select",
header: ({ table }) => (
<input checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && undefined)
} onChange={(value) => table.toggleAllPageRowsSelected(!!value)} type="checkbox" name="" id="" />
),
cell: ({ row }) => (
<input checked={row.getIsSelected()} onChange={(value) => row.toggleSelected(!!value)} type="checkbox" name="" id="" />
),
},
{
accessorKey: "status",
header: "Status",
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<p>Email</p>
)
},
},
{
accessorKey: "amount",
header: () => <div className="">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
return <div className="font-medium">{formatted}</div>
},
},
]
return(
<>
<Table
columns={columns}
data={data}
pageSize={5}
/>
</>
)
}

View File

@ -1,6 +1,6 @@
import NextAuth, { User } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import axios, { AxiosError } from "axios";
import axios from "axios";
import { jwtDecode } from "jwt-decode";
const handler = NextAuth({
@ -11,37 +11,58 @@ const handler = NextAuth({
password: {},
},
async authorize(credentials) {
let user: User | null = null;
const response = axios({
method: 'post',
url: 'private-docs-api.intside.co/users/login/',
data: {
email: credentials?.email,
password: credentials?.password,
}
})
.then(function (response: any) {
const { user_id } = jwtDecode(response.access_token) as {
user_id: string;
};
})
.catch(function (error) {
if (error instanceof AxiosError) {
if (error.status === 401) {
throw new Error("Email ou mot de passe incorrect");
} else {
throw new Error(error.message, error);
try {
const response = await axios.post(
'private-docs-api.intside.co/users/login/',
{
email: credentials?.email,
password: credentials?.password,
}
}
throw new Error("Une erreur est survenue");
});
)
const { access_token, refresh_token } = response.data;
const { id } = jwtDecode(access_token) as { id: string };
return {
id: id,
email: credentials?.email,
access_token: access_token,
refresh_token: refresh_token
} as User;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
throw new Error("Email ou mot de passe incorrect");
}
throw new Error(error.response?.data?.message || error.message);
}
throw new Error("Une erreur est survenue");
}
},
})
]
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.access_token = user.access_token;
token.refresh_token = user.refresh_token;
}
return token;
},
async session({ session, token }) {
return {
...session,
user: {
...session.user,
...token,
},
};
},
},
secret: process.env.AUTH_SECRET ?? "",
});
export { handler as GET, handler as POST };

View File

@ -30,6 +30,7 @@ body {
padding: 12px;
border: 1px solid #d1d5dc;
border-radius: 9999px;
color: black;
&:focus {
outline-color: none;
@ -58,4 +59,9 @@ body {
color: white;
width: 100%;
padding: 8px;
cursor: pointer;
&:hover {
background-color: rgb(22, 77, 185);
}
}

View File

@ -2,8 +2,8 @@
import { FloatingLabelInputProps } from '#/types';
import React, { useState } from 'react';
import Image from 'next/image';
import { icons } from '#/assets/icons';
export default function FloatingLabelInput({
label,
@ -47,19 +47,29 @@ export default function FloatingLabelInput({
defaultValue={defaultValue}
required
/>
{/* {showPasswordToggle && (
{showPasswordToggle && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="btn-floating text-gray-500 hover:text-gray-700 focus:outline-none"
>
{showPassword ? (
<EyeClosedIcon size={20} />
<Image
src={icons.eyeSlashIcon}
width={20}
height={20}
alt=''
/>
) : (
<EyeOpenIcon size={20} />
<Image
src={icons.eyeIcon}
width={20}
height={20}
alt=''
/>
)}
</button>
)} */}
)}
</div>
);
@ -81,7 +91,7 @@ export default function FloatingLabelInput({
};
return (
<div className="relative mb-9">
<div className="relative">
<label
htmlFor={name}
className="input-label text-gray-400 text-sm"

View File

@ -1,38 +1,73 @@
"use client";
import FloatingLabelInput from "../floatingLabelInput"
import { FormProps } from "#/types"
import { FormEvent, useState } from "react"
export default function Form({
fields,
submit,
className,
child,
title
title,
schema
} : FormProps) {
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const data = Object.fromEntries(formData)
console.log("FORM DATA = ", data)
const result = schema.safeParse(data);
console.log("ZOD = ", result.error?.format())
if(!result.success) {
const formatedErrors = result.error.format() as Record<string, { _errors?: string[] }>;
const newErrors: Record<string, string> = {};
Object.keys(formatedErrors).forEach((field) => {
if (field !== "_errors" && formatedErrors[field]._errors?.length) {
newErrors[field] = formatedErrors[field]._errors[0];
}
});
setErrors(newErrors)
} else {
setErrors({})
}
}
return (
<form className={className} onSubmit={submit}>
<form className={className} onSubmit={handleSubmit}>
<div className="flex justify-center text-black">
<p className="text-3xl font-bold">{title}</p>
</div>
<div className="gap-2 mt-2">
<div className="flex flex-col gap-8 mt-2">
{
fields.map((item, index) => (
<FloatingLabelInput
key={index}
label={item.label}
name={item.name}
type={item.type}
button={item.button}
defaultValue={item.defaultValue}
options={item.options}
placeholder={item.placeholder}
showPasswordToggle={item.showPasswordToggle}
/>
<div key={index}>
<FloatingLabelInput
label={item.label}
name={item.name}
type={item.type}
button={item.button}
defaultValue={item.defaultValue}
options={item.options}
placeholder={item.placeholder}
showPasswordToggle={item.showPasswordToggle}
/>
<span className="text-red-500 text-xs mt-1">{errors[item.name]}</span>
</div>
))
}
</div>
{child}
</div>
</form>
)

View File

@ -1,16 +1,16 @@
import { icons } from "#/assets/icons";
import Image from "next/image";
export default function Header() {
return(
<div className="w-full bg-white shadow-md py-4">
<div className="container mx-auto text-center flex items-center justify-center">
<div className="container mx-auto text-center flex items-center justify-center gap-2">
<Image
src="/file.svg"
src={icons.logo}
alt="Private Docs"
width={100}
height={100}
className="text-red-500 h-auto"
/>
<p className="text-2xl font-bold text-black">Private Docs</p>
</div>
</div>
)

View File

@ -1,5 +1,141 @@
export default function Table() {
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
ColumnFiltersState,
getFilteredRowModel,
} from "@tanstack/react-table"
import { useState } from "react";
import { clsx, type ClassValue } from "clsx"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[],
pageSize?: number
}
export default function Table<TData, TValue>({
columns,
data,
pageSize = 10
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
[]
)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
state: {
rowSelection,
columnFilters,
},
initialState: {
pagination: {
pageSize: pageSize,
}
},
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
})
const totalPages = table.getPageCount()
const currentPage = table.getState().pagination.pageIndex + 1
const getPageNumbers = () => {
const pages = []
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
return pages
}
return(
<></>
<div>
<div className="rounded-lg border border-gray-200">
<table className="w-full overflow-x-auto rounded-lg " style={{ borderTopLeftRadius: '10px', borderTopRightRadius: '10px', }}>
<thead className="h-10">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="rounded-lg">
{headerGroup.headers.map((header) => {
return(
<th key={header.id} className="bg-blue-300 p-3 text-start">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className={clsx('hover:bg-gray-300 border-t border-gray-200', { 'bg-gray-300': row.getIsSelected()})}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-3 text-start">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)
: (
<tr>
<td colSpan={columns.length}>
Aucun résultats
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<button
className="border bg-gray-200 shadow-xs hover:bg-gray-300 hover:text-black px-3 py-1 rounded"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Précédent
</button>
<div className="flex space-x-1">
{getPageNumbers().map((pageNumber) => (
<button
key={pageNumber}
className={clsx(
"px-3 py-1 rounded",
pageNumber === currentPage
? "bg-blue-500 text-white"
: "bg-gray-200 hover:bg-gray-300"
)}
onClick={() => table.setPageIndex(pageNumber - 1)}
>
{pageNumber}
</button>
))}
</div>
<button
className="border bg-gray-200 shadow-xs hover:bg-gray-300 hover:text-black px-3 py-1 rounded"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Suivant
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,6 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email("Email invalide"),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères")
});

View File

@ -1,4 +1,5 @@
import { FormEventHandler, ReactNode } from "react";
import { ZodSchema } from "zod";
export interface FloatingLabelInputProps {
label: string;
@ -16,5 +17,6 @@ export interface FormProps {
fields: FloatingLabelInputProps[],
submit: FormEventHandler<HTMLFormElement> | undefined,
className: string,
child: ReactNode
child: ReactNode,
schema: ZodSchema
}