Compare commits

..

3 Commits

9 changed files with 619 additions and 36 deletions

View File

@ -5,3 +5,4 @@ KEYCLOAK_CLIENT_ID=front
KEYCLOAK_CLIENT_SECRET=Klsbm7hzyXscypXU0wUPPVBrttFPt6Pn
KEYCLOAK_REALM=master
KEYCLOAK_ISSUER=http://172.16.32.141:8090/realms/master
KEYCLOAK_BASE_URL=http://172.16.32.141:8090

View File

@ -22,23 +22,69 @@ export const authOptions: NextAuthOptions = {
],
callbacks: {
async jwt({ token, account, profile }) {
console.log("Token", token);
console.log("Account", account);
console.log("Profile", profile);
// Au moment de la première connexion, sauvegarde de l'access token et du rôle dans le JWT
if (account && profile) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = account.expires_at! * 1000;
token.first_name = profile.given_name;
token.last_name = profile.family_name;
token.username = profile.preferred_username;
token.role = profile.realm_roles;
}
return token;
}
// Retourner le token précédent si le token d'accès n'a pas expiré
if (Date.now() < (token.accessTokenExpires as number)) {
return token;
}
// Rafraîchir le token si expiré
try {
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
refresh_token: token.refreshToken as string,
}),
}
);
const refreshedTokens = await response.json();
if (!response.ok) {
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
};
} catch (error) {
console.error("Erreur lors du rafraîchissement du token:", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
},
async session({ session, token }) {
if (token.error) {
// Rediriger vers la page de connexion si le rafraîchissement a échoué
throw new Error("RefreshAccessTokenError");
}
// On injecte l'access token et le rôle dans la session accessible côté client
session.accessToken = token.accessToken as string;
session.refreshToken = token.refreshToken as string;
session.error = token.error as string;
session.user.first_name = token.first_name as string;
session.user.last_name = token.last_name as string;
session.user.username = token.username as string;
@ -48,12 +94,11 @@ export const authOptions: NextAuthOptions = {
},
session: {
strategy: "jwt",
maxAge: 60 * 60, // 1 heure
},
events: {
async signOut({ token }) {
try {
console.log("Déconnexion Keycloak");
console.log("Token", token);
const issuerUrl = process.env.KEYCLOAK_ISSUER!;
const logoutUrl = `${issuerUrl}/protocol/openid-connect/logout`;
await fetch(logoutUrl, {

View File

@ -0,0 +1,40 @@
import { getServerSession } from "next-auth";
import { authOptions } from "../../auth/[...nextauth]/route";
import { NextResponse } from "next/server";
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (
!session?.user?.role?.includes("admin") &&
!session?.user?.role?.includes("TEACHERS")
) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${params.id}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
if (response.ok) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json(
{ error: "Erreur suppression utilisateur" },
{ status: 400 }
);
}
} catch (error) {
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,187 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
// Récupérer la liste des utilisateurs
const usersResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
const users = await usersResponse.json();
// Récupérer les rôles pour chaque utilisateur
const usersWithRoles = await Promise.all(
users.map(async (user: any) => {
try {
const rolesResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${user.id}/role-mappings/realm`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
const roles = await rolesResponse.json();
// Suppression de "default-roles-master" qui est un rôle technique
const filteredRoles = roles.filter(
(role: any) => role.name !== "default-roles-master"
);
return {
...user,
roles: filteredRoles.map((role: any) => role.name),
};
} catch (error) {
console.error(
`Erreur lors de la récupération des rôles pour l'utilisateur ${user.id}:`,
error
);
return {
...user,
roles: [],
};
}
})
);
return NextResponse.json(usersWithRoles);
} catch (error) {
console.error("Erreur lors de la récupération des utilisateurs:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (
!session?.user?.role?.includes("admin") &&
!session?.user?.role?.includes("TEACHERS")
) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const data = await req.json();
// Formater les données pour Keycloak
const keycloakUser = {
username: data.username,
enabled: true,
emailVerified: true,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
realmRoles: data.realmRoles ? [data.realmRoles] : [],
};
if (keycloakUser.realmRoles.length === 0) {
return NextResponse.json(
{ error: "Veuillez sélectionner un rôle" },
{ status: 400 }
);
}
// Créer l'utilisateur
const createResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`,
{
method: "POST",
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(keycloakUser),
}
);
if (!createResponse.ok) {
const errorData = await createResponse.json();
console.log(errorData);
if (errorData.errorMessage?.includes("User exists with same username")) {
return NextResponse.json(
{ error: "Un utilisateur existe déjà avec ce nom d'utilisateur" },
{ status: 400 }
);
} else if (
errorData.errorMessage?.includes("User exists with same email")
) {
return NextResponse.json(
{ error: "Un utilisateur existe déjà avec cet email" },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Erreur création utilisateur" },
{ status: 400 }
);
}
// Récupérer l'utilisateur créé
const userResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users?username=${data.username}`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
const user = await userResponse.json();
console.log("Utilisateur créé:", user[0]);
if (keycloakUser.realmRoles.length > 0) {
// Récupérer l'ID du rôle
const roleResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles/${data.realmRoles}`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
}
);
const roleData = await roleResponse.json();
// Ajouter le rôle à l'utilisateur
await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${user[0].id}/role-mappings/realm`,
{
method: "POST",
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify([roleData]),
}
);
// Ajouter les realmRoles a l'utilisateur avant de le renvoyer
user[0].roles = [keycloakUser.realmRoles];
}
return NextResponse.json({ success: true, user: user[0] });
} catch (error) {
console.error("Erreur complète:", error);
return NextResponse.json(
{ error: "Erreur serveur", details: error },
{ status: 500 }
);
}
}

22
front/app/users/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { UsersTable } from "@/components/users/users-table";
export const metadata = {
title: "Enkun - Utilisateurs",
};
export default async function UsersPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<div className='container mx-auto py-10'>
<UsersTable userRole={session.user.role} />
</div>
);
}

View File

@ -1,24 +1,31 @@
"use client"
"use client";
import type React from "react"
import type React from "react";
import { cn } from "@/lib/utils"
import { LayoutGrid, BookOpen, Share2, Palette, GitFork, Building2, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useRouter, usePathname } from "next/navigation"
import { cn } from "@/lib/utils";
import {
BookOpen,
Share2,
Palette,
GitFork,
Building2,
Users,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useRouter, usePathname } from "next/navigation";
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
isOpen: boolean
onClose: () => void
isOpen: boolean;
onClose: () => void;
}
const menuItems = [
{
title: "Board",
icon: LayoutGrid,
href: "/board",
iframe: "https://example.com/board",
title: "Users",
icon: Users,
href: "/users",
},
{
title: "Chapter",
@ -57,43 +64,48 @@ const menuItems = [
href: "/missions",
iframe: "https://example.com/missions",
},
]
];
export function Sidebar({ isOpen, onClose, className }: SidebarProps) {
const router = useRouter()
const pathname = usePathname()
const router = useRouter();
const pathname = usePathname();
const handleNavigation = (href: string) => {
router.push(href)
onClose()
}
router.push(href);
onClose();
};
return (
<>
{isOpen && <div className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" onClick={onClose} />}
{isOpen && (
<div
className='fixed inset-0 z-40 bg-background/80 backdrop-blur-sm'
onClick={onClose}
/>
)}
<div
className={cn(
"fixed top-0 left-0 z-50 h-full w-64 transform bg-black text-white transition-transform duration-200 ease-in-out",
isOpen ? "translate-x-0" : "-translate-x-full",
className,
className
)}
>
<ScrollArea className="h-full w-full">
<div className="space-y-1 p-4">
<ScrollArea className='h-full w-full'>
<div className='space-y-1 p-4'>
{menuItems.map((item) => (
<Button
key={item.title}
variant="ghost"
variant='ghost'
className={cn(
"w-full justify-start gap-2 text-white hover:bg-gray-800 hover:text-white",
pathname === item.href && "bg-gray-800",
pathname === item.href && "bg-gray-800"
)}
onClick={() => handleNavigation(item.href)}
>
<item.icon className="h-5 w-5" />
<item.icon className='h-5 w-5' />
<span>{item.title}</span>
{item.badge && (
<span className="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs text-white">
<span className='ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-xs text-white'>
{item.badge}
</span>
)}
@ -103,6 +115,5 @@ export function Sidebar({ isOpen, onClose, className }: SidebarProps) {
</ScrollArea>
</div>
</>
)
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useContext, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface AddUserButtonProps {
userRole: string[];
handleAddUser: (newUser: any) => void;
}
export function AddUserButton({ userRole, handleAddUser }: AddUserButtonProps) {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({
username: "",
firstName: "",
lastName: "",
email: "",
realmRoles: "",
});
const [error, setError] = useState<string | null>(null);
const availableRoles = userRole.includes("admin")
? ["admin", "TEACHERS", "STUDENTS"]
: ["STUDENTS"];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
setOpen(false);
setFormData({
username: "",
firstName: "",
lastName: "",
email: "",
realmRoles: "",
});
setError(null);
handleAddUser(data.user);
} catch (error) {
console.error("Erreur lors de la création:", error);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Ajouter un utilisateur</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nouvel utilisateur</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className='space-y-4'>
{error && <div className='text-red-500 text-sm'>{error}</div>}
<Input
placeholder="Nom d'utilisateur"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
<Input
placeholder='Prénom'
value={formData.firstName}
onChange={(e) =>
setFormData({ ...formData, firstName: e.target.value })
}
/>
<Input
placeholder='Nom'
value={formData.lastName}
onChange={(e) =>
setFormData({ ...formData, lastName: e.target.value })
}
/>
<Input
type='email'
placeholder='Email'
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
/>
<Select
value={formData.realmRoles}
onValueChange={(value) =>
setFormData({ ...formData, realmRoles: value })
}
>
<SelectTrigger>
<SelectValue placeholder='Sélectionner un rôle' />
</SelectTrigger>
<SelectContent>
{availableRoles.map((role) => (
<SelectItem key={role} value={role}>
{role.charAt(0).toUpperCase() + role.slice(1).toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type='submit' className='w-full'>
Créer
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { useSession } from "next-auth/react";
import { AddUserButton } from "./add-user-button";
interface User {
id: string;
username: string;
firstName: string;
lastName: string;
email: string;
createdTimestamp: number;
roles: string[];
}
interface UsersTableProps {
userRole: string[];
}
export function UsersTable({ userRole }: UsersTableProps) {
const { data: session } = useSession();
const [users, setUsers] = useState<User[]>([]);
const handleAddUser = (newUser: User) => {
console.log("Nouvel utilisateur:", newUser);
console.log("Utilisateurs actuels:", users);
setUsers((prevUsers) => [...prevUsers, newUser]);
};
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch("/api/users");
const data = await response.json();
setUsers(data);
} catch (error) {
console.error("Erreur lors de la récupération des utilisateurs:", error);
}
};
const canDelete = (targetUserRole: string[]) => {
if (userRole.includes("admin")) return true;
if (userRole.includes("TEACHERS")) {
return targetUserRole.includes("STUDENTS");
}
return false;
};
const handleDelete = async (userId: string) => {
try {
await fetch(`/api/users/${userId}`, { method: "DELETE" });
fetchUsers();
} catch (error) {
console.error("Erreur lors de la suppression:", error);
}
};
const filterUsers = (users: User[]) => {
if (userRole.includes("admin")) return users;
if (userRole.includes("TEACHERS")) {
return users.filter(
(user) =>
user.roles.includes("TEACHERS") || user.roles.includes("STUDENTS")
);
}
return users.filter((user) => user.roles.includes("STUDENTS"));
};
if (!session) return null;
return (
<>
<div className='flex justify-between items-center mb-6'>
<h1 className='text-2xl font-bold'>Gestion des utilisateurs</h1>
{(session.user.role.includes("admin") ||
session.user.role.includes("TEACHERS")) && (
<AddUserButton
userRole={session.user.role}
handleAddUser={handleAddUser}
/>
)}
</div>
{users.length === 0 ? (
<div className='text-center'>Chargement...</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom d'utilisateur</TableHead>
<TableHead>Prénom</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Date d'inscription</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filterUsers(users).map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.firstName}</TableCell>
<TableCell>{user.lastName}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.createdTimestamp}</TableCell>
<TableCell>{user.roles.join(", ")}</TableCell>
<TableCell>
{canDelete(user.roles) && (
<Button
variant='destructive'
size='sm'
onClick={() => handleDelete(user.id)}
>
Supprimer
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
);
}

View File

@ -10,6 +10,8 @@ declare module "next-auth" {
role: string[];
} & DefaultSession["user"];
accessToken?: string;
refreshToken?: string;
error?: string;
}
interface JWT {
@ -18,6 +20,7 @@ declare module "next-auth" {
last_name?: string;
username?: string;
role?: string[] | string | null;
error?: string;
}
interface User extends DefaultUser {