Initial commit

This commit is contained in:
alma 2025-04-17 11:39:15 +02:00
commit 550e5d7ed2
240 changed files with 30011 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

72
.env Normal file
View File

@ -0,0 +1,72 @@
NEXTAUTH_URL=https://lab.slm-lab.net
NEXTAUTH_SECRET=9eff5ad2f4b5ea744a34d9d8004cb5236f1931b26bf75f01a0a26203312fe1ec
KEYCLOAK_CLIENT_ID=lab
KEYCLOAK_CLIENT_SECRET=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
KEYCLOAK_REALM=cercle
KEYCLOAK_ISSUER=https://connect.slm-lab.net/realms/cercle
KEYCLOAK_BASE_URL=https://connect.slm-lab.net
NEXTCLOUD_URL=https://espace.slm-lab.net
NEXTCLOUD_CLIENT_ID=espace.slm-lab.net
NEXTCLOUD_CLIENT_SECRET=YHLVMGpu0nGRaP7gMDpSjRr1ia6HiSr1
# Agenda/Calendar database (Prisma)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/calendar_db?schema=public"
# Sidebar iframes
NEXT_PUBLIC_IFRAME_DIARY_URL=https://espace.slm-lab.net/apps/diary/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_MAIL_URL=https://espace.slm-lab.net/apps/mail/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_DRIVE_URL=https://espace.slm-lab.net/apps/files/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_CONTACTS_URL=https://espace.slm-lab.net/apps/contacts/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_LEARN_URL=https://apprendre.slm-lab.net
NEXT_PUBLIC_IFRAME_PAROLE_URL=https://parole.slm-lab.net/channel/City
NEXT_PUBLIC_IFRAME_CHAPTER_URL=https://chapitre.slm-lab.net
NEXT_PUBLIC_IFRAME_AGILITY_URL=https://agilite.slm-lab.net/oidc/login
NEXT_PUBLIC_IFRAME_ARTLAB_URL=https://artlab.slm-lab.net
NEXT_PUBLIC_IFRAME_GITE_URL=https://gite.slm-lab.net/user/oauth2/cube
NEXT_PUBLIC_IFRAME_CALCULATION_URL=https://calcul.slm-lab.net
NEXT_PUBLIC_IFRAME_MEDIATIONS_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?client_id=mediations.slm-lab.net&redirect_uri=https%3A%2F%2Fmediations.slm-lab.net%2F%3Fopenid_mode%3Dtrue&scope=openid%20profile%20email&response_type=code
NEXT_PUBLIC_IFRAME_SHOWCASE_URL=https://page.slm-lab.net
# Navigation bar iframes
NEXT_PUBLIC_IFRAME_CONFERENCE_URL=https://vision.slm-lab.net
NEXT_PUBLIC_IFRAME_RADIO_URL=https://galaorangelique.wixsite.com/website
NEXT_PUBLIC_IFRAME_TIMETRACKER_URL=https://espace.slm-lab.net/apps/timemanager/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_NOTES_URL=https://espace.slm-lab.net/apps/notes/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_ANNOUNCEMENT_URL=https://espace.slm-lab.net/apps/announcementcenter/?embedMode=true&hideNavigation=true
# Avatar menu iframes
NEXT_PUBLIC_IFRAME_HEALTHVIEW_URL=https://espace.slm-lab.net/apps/health/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?response_type=code&scope=openid&client_id=page.slm-lab.net&state=f72528f6756bc132e76dd258691b71cf&redirect_uri=https%3A%2F%2Fpage.slm-lab.net%2Fwp-admin%2F
NEXT_PUBLIC_IFRAME_USERSVIEW_URL=https://example.com/users-view
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/admin/
NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL=https://alma.slm-lab.net
ROCKET_CHAT_TOKEN=w91TYgkH-Z67Oz72usYdkW5TZLLRwnre7qyAhp7aHJB
ROCKET_CHAT_USER_ID=Tpuww59PJKsrGNQJB
LEANTIME_TOKEN=lt_lsdShQdoYHaPUWuL07XZR1Rf3GeySsIs_UDlll3VJPk5EwAuILpMC4BwzJ9MZFRrb
LEANTIME_API_URL=https://agilite.slm-lab.net
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/calendar_db?schema=public"
NEWSDB_URL=postgresql://alma:Sict33711###@172.16.0.104:5432/rivacube
DB_USER=alma
DB_PASSWORD=Sict33711###
DB_NAME=rivacube
DB_HOST=172.16.0.104
# Database Configuration
DB_HOST=172.16.0.104
DB_PORT=5432
DB_USER=alma
DB_PASSWORD=Sict33711###
DB_NAME=rivacube
# IMAP Configuration
IMAP_USER=alma@governance-labs.org
IMAP_PASSWORD=8s-hN8u37-IP#-y
IMAP_HOST=mail.infomaniak.com
IMAP_PORT=993

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Use Node.js 22 as the base image
FROM node:22-alpine
# Set working directory
WORKDIR /application
# Copy package files first
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the application
COPY . .
# Initialize Prisma
RUN npx prisma generate
RUN npx prisma migrate dev --name init
# Build the Next.js application
RUN npm run build
# Expose port 3000
EXPOSE 3000
# Set environment variable
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Start the application and keep the container running even if it fails
CMD ["sh", "-c", "npm start || tail -f /dev/null"]

31
app/[section]/page.tsx Normal file
View File

@ -0,0 +1,31 @@
import { notFound } from 'next/navigation'
const menuItems = {
board: "https://example.com/board",
chapter: "https://example.com/chapter",
flow: "https://example.com/flow",
design: "https://example.com/design",
gitlab: "https://gitlab.com",
crm: "https://example.com/crm",
missions: "https://example.com/missions"
}
export default function SectionPage({ params }: { params: { section: string } }) {
const iframeUrl = menuItems[params.section as keyof typeof menuItems]
if (!iframeUrl) {
notFound()
}
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={iframeUrl}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)
}

24
app/ai-assistant/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

View File

@ -0,0 +1,32 @@
import NextAuth from "next-auth";
import { authOptions } from '@/lib/auth';
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
username: string;
first_name: string;
last_name: string;
role: string[];
};
accessToken: string;
}
interface JWT {
accessToken: string;
refreshToken: string;
accessTokenExpires: number;
role: string[];
username: string;
first_name: string;
last_name: string;
}
}
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

166
app/api/calendar/route.ts Normal file
View File

@ -0,0 +1,166 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// GET events
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const start = searchParams.get("start");
const end = searchParams.get("end");
// First get all calendars for the user
const calendars = await prisma.calendar.findMany({
where: {
userId: session.user.id,
},
});
// Then get events with calendar information
const events = await prisma.event.findMany({
where: {
calendarId: {
in: calendars.map(cal => cal.id)
},
...(start && end
? {
start: {
gte: new Date(start),
},
end: {
lte: new Date(end),
},
}
: {}),
},
include: {
calendar: true,
},
orderBy: {
start: "asc",
},
});
// Map the events to include calendar color and name
const eventsWithCalendarInfo = events.map(event => ({
...event,
calendarColor: event.calendar.color,
calendarName: event.calendar.name,
calendar: undefined, // Remove the full calendar object
}));
return NextResponse.json(eventsWithCalendarInfo);
} catch (error) {
console.error("Error fetching events:", error);
return NextResponse.json(
{ error: "Erreur lors du chargement des événements" },
{ status: 500 }
);
}
}
// POST new event
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 401 }
);
}
const data = await req.json();
const { title, description, start, end, location, calendarId } = data;
const event = await prisma.event.create({
data: {
title,
description,
start: new Date(start),
end: new Date(end),
isAllDay: data.allDay || false,
location: location || null,
calendarId,
},
});
return NextResponse.json(event);
} catch (error) {
console.error("Error creating event:", error);
return NextResponse.json(
{ error: "Erreur lors de la création de l'événement" },
{ status: 500 }
);
}
}
// PUT update event
export async function PUT(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { id, ...data } = body;
const event = await prisma.event.update({
where: {
id,
},
data,
});
return NextResponse.json(event);
} catch (error) {
console.error("Error updating event:", error);
return NextResponse.json(
{ error: "Error updating event" },
{ status: 500 }
);
}
}
// DELETE event
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Event ID is required" },
{ status: 400 }
);
}
await prisma.event.delete({
where: {
id,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting event:", error);
return NextResponse.json(
{ error: "Error deleting event" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
/**
* Handles the GET request to retrieve a specific event from a calendar.
*
* @param req - The incoming Next.js request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar.
* @param params.eventId - The ID of the event.
* @returns A JSON response containing the event data or an error message.
*
* The function performs the following steps:
* 1. Checks if the user is authenticated.
* 2. Verifies that the calendar exists and belongs to the authenticated user.
* 3. Verifies that the event exists and belongs to the specified calendar.
* 4. Returns the event data if all checks pass.
*
* Possible error responses:
* - 401: User is not authenticated.
* - 403: User is not authorized to access the calendar.
* - 404: Calendar or event not found.
* - 500: Server error occurred while retrieving the event.
*/
export async function GET(
req: NextRequest,
{ params }: { params: { id: string; eventId: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier appartient à l'utilisateur
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
const event = await prisma.event.findUnique({
where: {
id: params.eventId,
},
});
if (!event) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
// Vérifier que l'événement appartient bien au calendrier
if (event.calendarId !== params.id) {
return NextResponse.json(
{ error: "Événement non trouvé dans ce calendrier" },
{ status: 404 }
);
}
return NextResponse.json(event);
} catch (error) {
console.error("Erreur lors de la récupération de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the PUT request to update an event in a calendar.
*
* @param req - The incoming request object.
* @param params - The route parameters containing the calendar ID and event ID.
* @returns A JSON response indicating the result of the update operation.
*
* The function performs the following steps:
* 1. Retrieves the server session to check if the user is authenticated.
* 2. Verifies that the calendar belongs to the authenticated user.
* 3. Checks if the event exists and belongs to the specified calendar.
* 4. Validates the request payload to ensure required fields are present.
* 5. Updates the event with the provided data.
* 6. Returns the updated event or an appropriate error response.
*
* Possible error responses:
* - 401: User is not authenticated.
* - 403: User is not authorized to update the calendar.
* - 404: Calendar or event not found.
* - 400: Validation error for missing required fields.
* - 500: Server error during the update process.
*/
export async function PUT(
req: NextRequest,
{ params }: { params: { id: string; eventId: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier appartient à l'utilisateur
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
// Vérifier que l'événement existe et appartient au calendrier
const existingEvent = await prisma.event.findUnique({
where: {
id: params.eventId,
},
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
if (existingEvent.calendarId !== params.id) {
return NextResponse.json(
{ error: "Événement non trouvé dans ce calendrier" },
{ status: 404 }
);
}
const { title, description, start, end, location, isAllDay } =
await req.json();
// Validation
if (!title) {
return NextResponse.json(
{ error: "Le titre est requis" },
{ status: 400 }
);
}
if (!start || !end) {
return NextResponse.json(
{ error: "Les dates de début et de fin sont requises" },
{ status: 400 }
);
}
const updatedEvent = await prisma.event.update({
where: {
id: params.eventId,
},
data: {
title,
description,
start: new Date(start),
end: new Date(end),
location,
isAllDay: isAllDay || false,
},
});
return NextResponse.json(updatedEvent);
} catch (error) {
console.error("Erreur lors de la mise à jour de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the DELETE request to remove an event from a calendar.
*
* @param req - The incoming Next.js request object.
* @param params - An object containing the parameters from the request URL.
* @param params.id - The ID of the calendar.
* @param params.eventId - The ID of the event to be deleted.
* @returns A JSON response indicating the result of the deletion operation.
*
* @throws Will return a 401 status if the user is not authenticated.
* @throws Will return a 404 status if the calendar or event is not found.
* @throws Will return a 403 status if the user is not authorized to delete the event.
* @throws Will return a 500 status if there is a server error during the deletion process.
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string; eventId: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier appartient à l'utilisateur
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
// Vérifier que l'événement existe et appartient au calendrier
const existingEvent = await prisma.event.findUnique({
where: {
id: params.eventId,
},
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
if (existingEvent.calendarId !== params.id) {
return NextResponse.json(
{ error: "Événement non trouvé dans ce calendrier" },
{ status: 404 }
);
}
await prisma.event.delete({
where: {
id: params.eventId,
},
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("Erreur lors de la suppression de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
/**
* Handles the GET request to retrieve events for a specific calendar.
*
* @param req - The incoming request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar.
* @returns A JSON response containing the events or an error message.
*
* The function performs the following steps:
* 1. Retrieves the server session to check if the user is authenticated.
* 2. Verifies that the calendar exists and belongs to the authenticated user.
* 3. Retrieves and filters events based on optional date parameters (`start` and `end`).
* 4. Returns the filtered events in ascending order of their start date.
*
* Possible response statuses:
* - 200: Successfully retrieved events.
* - 401: User is not authenticated.
* - 403: User is not authorized to access the calendar.
* - 404: Calendar not found.
* - 500: Server error occurred while retrieving events.
*/
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier appartient à l'utilisateur
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
// Récupérer les paramètres de filtrage de date s'ils existent
const { searchParams } = new URL(req.url);
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
let whereClause: any = {
calendarId: params.id,
};
if (startParam && endParam) {
whereClause.AND = [
{
start: {
lte: new Date(endParam),
},
},
{
end: {
gte: new Date(startParam),
},
},
];
}
const events = await prisma.event.findMany({
where: whereClause,
orderBy: {
start: "asc",
},
});
return NextResponse.json(events);
} catch (error) {
console.error("Erreur lors de la récupération des événements:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the creation of a new event for a specific calendar.
*
* @param req - The incoming request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar to which the event will be added.
* @returns A JSON response with the created event data or an error message.
*
* @throws {401} If the user is not authenticated.
* @throws {404} If the specified calendar is not found.
* @throws {403} If the user is not authorized to add events to the specified calendar.
* @throws {400} If the required fields (title, start, end) are missing.
* @throws {500} If there is a server error during event creation.
*/
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
const { title, description, start, end, location, isAllDay } =
await req.json();
// Validation
if (!title) {
return NextResponse.json(
{ error: "Le titre est requis" },
{ status: 400 }
);
}
if (!start || !end) {
return NextResponse.json(
{ error: "Les dates de début et de fin sont requises" },
{ status: 400 }
);
}
const event = await prisma.event.create({
data: {
title,
description,
start: new Date(start),
end: new Date(end),
location,
isAllDay: isAllDay || false,
calendarId: params.id,
},
});
return NextResponse.json(event, { status: 201 });
} catch (error) {
console.error("Erreur lors de la création de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
/**
* Handles GET requests to retrieve a calendar by its ID.
*
* @param req - The incoming request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar to retrieve.
* @returns A JSON response containing the calendar data if found and authorized,
* or an error message with the appropriate HTTP status code.
*
* - 401: If the user is not authenticated.
* - 403: If the user is not authorized to access the calendar.
* - 404: If the calendar is not found.
* - 500: If there is a server error during the retrieval process.
*/
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
// Vérification que l'utilisateur est bien le propriétaire
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
return NextResponse.json(calendar);
} catch (error) {
console.error("Erreur lors de la récupération du calendrier:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the PUT request to update a calendar.
*
* @param req - The incoming request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar to update.
* @returns A JSON response with the updated calendar data or an error message.
*
* @throws {401} If the user is not authenticated.
* @throws {404} If the calendar is not found.
* @throws {403} If the user is not authorized to update the calendar.
* @throws {400} If the calendar name is not provided.
* @throws {500} If there is a server error during the update process.
*/
export async function PUT(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier existe et appartient à l'utilisateur
const existingCalendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!existingCalendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (existingCalendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
const { name, color, description } = await req.json();
// Validation
if (!name) {
return NextResponse.json(
{ error: "Le nom du calendrier est requis" },
{ status: 400 }
);
}
const updatedCalendar = await prisma.calendar.update({
where: {
id: params.id,
},
data: {
name,
color,
description,
},
});
return NextResponse.json(updatedCalendar);
} catch (error) {
console.error("Erreur lors de la mise à jour du calendrier:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the DELETE request to remove a calendar by its ID.
*
* @param req - The incoming Next.js request object.
* @param params - An object containing the route parameters.
* @param params.id - The ID of the calendar to be deleted.
* @returns A JSON response indicating the result of the deletion operation.
*
* - If the user is not authenticated, returns a 401 status with an error message.
* - If the calendar does not exist, returns a 404 status with an error message.
* - If the calendar does not belong to the authenticated user, returns a 403 status with an error message.
* - If the calendar is successfully deleted, returns a 204 status with no content.
* - If an error occurs during the deletion process, returns a 500 status with an error message.
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Verify calendar ownership
const calendar = await prisma.calendar.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé ou non autorisé" },
{ status: 404 }
);
}
// Delete the calendar (this will also delete all associated events due to the cascade delete)
await prisma.calendar.delete({
where: {
id: params.id,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Erreur lors de la suppression du calendrier:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
import crypto from "crypto";
// Non testé, généré automatiquement par IA
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier que le calendrier appartient à l'utilisateur
const calendar = await prisma.calendar.findUnique({
where: {
id: params.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé" },
{ status: 404 }
);
}
if (calendar.userId !== session.user.username) {
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
}
// Générer un token de partage
const shareToken = crypto.randomBytes(32).toString("hex");
// Dans une implémentation réelle, on stockerait ce token dans la base de données
// avec une date d'expiration et des permissions
const shareUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/calendars/shared/${shareToken}`;
return NextResponse.json({
shareUrl,
shareToken,
});
} catch (error) {
console.error("Erreur lors de la création du lien de partage:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
/**
* Handles the creation of a default calendar for an authenticated user.
*
* This function checks if the user already has a default calendar named "Calendrier principal".
* If such a calendar exists, it returns the existing calendar.
* Otherwise, it creates a new default calendar for the user.
*
* @param req - The incoming request object.
* @returns A JSON response containing the existing or newly created calendar, or an error message.
*
* @throws Will return a 401 status if the user is not authenticated.
* @throws Will return a 500 status if there is a server error during the calendar creation process.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.username) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// Vérifier si l'utilisateur a déjà un calendrier par défaut
const existingCalendar = await prisma.calendar.findFirst({
where: {
userId: session.user.username,
name: "Calendrier principal",
},
});
if (existingCalendar) {
return NextResponse.json(existingCalendar);
}
// Créer un calendrier par défaut
const calendar = await prisma.calendar.create({
data: {
name: "Calendrier principal",
color: "#0082c9",
description: "Calendrier principal",
userId: session.user.username,
},
});
return NextResponse.json(calendar, { status: 201 });
} catch (error) {
console.error(
"Erreur lors de la création du calendrier par défaut:",
error
);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

101
app/api/calendars/route.ts Normal file
View File

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
/**
* Handles the GET request to retrieve calendars for the authenticated user.
*
* @param {NextRequest} req - The incoming request object.
* @returns {Promise<NextResponse>} - A promise that resolves to a JSON response containing the calendars or an error message.
*
* The function performs the following steps:
* 1. Retrieves the server session using `getServerSession`.
* 2. Checks if the user is authenticated by verifying the presence of `session.user.id`.
* - If not authenticated, returns a 401 response with an error message.
* 3. Attempts to fetch the calendars associated with the authenticated user from the database.
* - If successful, returns the calendars in a JSON response.
* - If an error occurs during the database query, logs the error and returns a 500 response with an error message.
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const calendars = await prisma.calendar.findMany({
where: {
userId: session.user.id,
},
include: {
events: {
orderBy: {
start: 'asc'
}
}
},
orderBy: {
createdAt: "desc",
},
});
console.log("Fetched calendars with events:", calendars);
return NextResponse.json(calendars);
} catch (error) {
console.error("Erreur lors de la récupération des calendriers:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
/**
* Handles the POST request to create a new calendar.
*
* @param {NextRequest} req - The incoming request object.
* @returns {Promise<NextResponse>} The response object containing the created calendar or an error message.
*
* @throws {Error} If there is an issue with the request or server.
*
* The function performs the following steps:
* 1. Retrieves the server session using `getServerSession`.
* 2. Checks if the user is authenticated by verifying the presence of `session.user.id`.
* 3. Parses the request body to extract `name`, `color`, and `description`.
* 4. Validates that the `name` field is provided.
* 5. Creates a new calendar entry in the database using Prisma.
* 6. Returns the created calendar with a 201 status code.
* 7. Catches and logs any errors, returning a 500 status code with an error message.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const { name, color, description } = await req.json();
// Validation
if (!name) {
return NextResponse.json(
{ error: "Le nom du calendrier est requis" },
{ status: 400 }
);
}
const calendar = await prisma.calendar.create({
data: {
name,
color: color || "#0082c9",
description,
userId: session.user.id,
},
});
return NextResponse.json(calendar, { status: 201 });
} catch (error) {
console.error("Erreur lors de la création du calendrier:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

59
app/api/emails/route.ts Normal file
View File

@ -0,0 +1,59 @@
import { NextResponse, NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const nextcloudUrl = process.env.NEXTCLOUD_URL;
if (!nextcloudUrl) {
console.error('Missing Nextcloud URL');
return NextResponse.json(
{ error: 'Nextcloud configuration is missing' },
{ status: 500 }
);
}
// Test Nextcloud connectivity
const testResponse = await fetch(`${nextcloudUrl}/status.php`);
if (!testResponse.ok) {
console.error('Nextcloud is not accessible:', await testResponse.text());
return NextResponse.json(
{
error: "Nextcloud n'est pas accessible. Veuillez contacter votre administrateur.",
emails: []
},
{ status: 503 }
);
}
// For now, return a test response
return NextResponse.json({
emails: [{
id: 'test-1',
subject: 'Test Email',
sender: {
name: 'System',
email: 'system@example.com'
},
date: new Date().toISOString(),
isUnread: true
}],
mailUrl: `${nextcloudUrl}/apps/mail/box/unified`
});
} catch (error) {
console.error('Error:', error);
return NextResponse.json(
{
error: "Une erreur est survenue. Veuillez contacter votre administrateur.",
emails: []
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
// First, find the event and its associated calendar
const event = await prisma.event.findUnique({
where: { id: params.id },
include: { calendar: true },
});
if (!event) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
// Verify that the user owns the calendar
if (event.calendar.userId !== session.user.id) {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 403 }
);
}
// Delete the event
await prisma.event.delete({
where: { id: params.id },
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("Erreur lors de la suppression de l'événement:", error);
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}

136
app/api/events/route.ts Normal file
View File

@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const { title, description, start, end, allDay, location, calendarId } = await req.json();
// Validation
if (!title || !start || !end || !calendarId) {
return NextResponse.json(
{ error: "Titre, début, fin et calendrier sont requis" },
{ status: 400 }
);
}
// Verify calendar ownership
const calendar = await prisma.calendar.findFirst({
where: {
id: calendarId,
userId: session.user.id,
},
});
if (!calendar) {
return NextResponse.json(
{ error: "Calendrier non trouvé ou non autorisé" },
{ status: 404 }
);
}
// Create event with all required fields
const event = await prisma.event.create({
data: {
title,
description: description || null,
start: new Date(start),
end: new Date(end),
isAllDay: allDay || false,
location: location || null,
calendarId,
userId: session.user.id,
},
});
console.log("Created event:", event);
return NextResponse.json(event, { status: 201 });
} catch (error) {
console.error("Erreur lors de la création de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
try {
const data = await req.json();
console.log("Received event update data:", data);
const { id, title, description, start, end, allDay, location, calendarId } = data;
// Validation
if (!id || !title || !start || !end || !calendarId) {
console.log("Validation failed. Missing fields:", {
id: !id,
title: !title,
start: !start,
end: !end,
calendarId: !calendarId
});
return NextResponse.json(
{ error: "ID, titre, début, fin et calendrier sont requis" },
{ status: 400 }
);
}
// Verify calendar ownership
const calendar = await prisma.calendar.findFirst({
where: {
id: calendarId,
userId: session.user.id,
},
include: {
events: {
where: {
id
}
}
}
});
console.log("Found calendar:", calendar);
if (!calendar || calendar.events.length === 0) {
console.log("Calendar or event not found:", {
calendarFound: !!calendar,
eventsFound: calendar?.events.length
});
return NextResponse.json(
{ error: "Événement non trouvé ou non autorisé" },
{ status: 404 }
);
}
const event = await prisma.event.update({
where: { id },
data: {
title,
description,
start: new Date(start),
end: new Date(end),
isAllDay: allDay || false,
location,
calendarId,
},
});
console.log("Updated event:", event);
return NextResponse.json(event);
} catch (error) {
console.error("Erreur lors de la mise à jour de l'événement:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@ -0,0 +1,121 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
export async function GET(
req: Request,
{ params }: { params: { groupId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
// Get client credentials token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok) {
console.error("Failed to get token:", tokenData);
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
}
// Get group members
const membersResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}/members`,
{
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
}
);
if (!membersResponse.ok) {
const errorData = await membersResponse.json();
console.error("Failed to get group members:", errorData);
return NextResponse.json({ error: "Failed to get group members" }, { status: membersResponse.status });
}
const members = await membersResponse.json();
return NextResponse.json(members);
} catch (error) {
console.error("Error in get group members:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function POST(
req: Request,
{ params }: { params: { groupId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const { userId } = await req.json();
// Get client credentials token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok) {
console.error("Failed to get token:", tokenData);
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
}
// Add user to group
const addResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}/groups/${params.groupId}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
}
);
if (!addResponse.ok) {
const errorData = await addResponse.json();
console.error("Failed to add user to group:", errorData);
return NextResponse.json({ error: "Failed to add user to group" }, { status: addResponse.status });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error in add user to group:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -0,0 +1,177 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
import { NextResponse } from "next/server";
async function getAdminToken() {
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok) {
throw new Error(data.error_description || 'Failed to get admin token');
}
return data.access_token;
}
export async function GET(
req: Request,
{ params }: { params: { groupId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch group');
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Get Group Error:', error);
return NextResponse.json(
{ error: "Erreur lors de la récupération du groupe" },
{ status: 500 }
);
}
}
export async function PUT(
req: Request,
{ params }: { params: { groupId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
const body = await req.json();
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!response.ok) {
throw new Error('Failed to update group');
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Update Group Error:', error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du groupe" },
{ status: 500 }
);
}
}
export async function PATCH(
req: Request,
{ params }: { params: { groupId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
const body = await req.json();
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
method: "PUT", // Keycloak doesn't support PATCH, so we use PUT
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!response.ok) {
throw new Error('Failed to update group');
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Update Group Error:', error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du groupe" },
{ status: 500 }
);
}
}
export async function DELETE(
req: Request,
{ params }: { params: { groupId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error('Failed to delete group');
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete Group Error:', error);
return NextResponse.json(
{ error: "Erreur lors de la suppression du groupe" },
{ status: 500 }
);
}
}

166
app/api/groups/route.ts Normal file
View File

@ -0,0 +1,166 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
async function getAdminToken() {
try {
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
// Log the response for debugging
console.log('Token Response:', {
status: tokenResponse.status,
ok: tokenResponse.ok,
data: data
});
if (!tokenResponse.ok || !data.access_token) {
// Log the error details
console.error('Token Error Details:', {
status: tokenResponse.status,
data: data
});
return null;
}
return data.access_token;
} catch (error) {
console.error('Token Error:', error);
return null;
}
}
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ message: "Non autorisé" }, { status: 401 });
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ message: "Erreur d'authentification" }, { status: 401 });
}
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
return NextResponse.json({ message: "Échec de la récupération des groupes" }, { status: response.status });
}
const groups = await response.json();
// Return empty array if no groups
if (!Array.isArray(groups)) {
return NextResponse.json([]);
}
const groupsWithCounts = await Promise.all(
groups.map(async (group: any) => {
try {
const countResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${group.id}/members/count`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
let count = 0;
if (countResponse.ok) {
count = await countResponse.json();
}
return {
id: group.id,
name: group.name,
path: group.path,
membersCount: count,
};
} catch (error) {
return {
id: group.id,
name: group.name,
path: group.path,
membersCount: 0,
};
}
})
);
return NextResponse.json(groupsWithCounts);
} catch (error) {
console.error('Groups API Error:', error);
return NextResponse.json({ message: "Une erreur est survenue" }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ message: "Non autorisé" }, { status: 401 });
}
const { name } = await req.json();
if (!name?.trim()) {
return NextResponse.json(
{ message: "Le nom du groupe est requis" },
{ status: 400 }
);
}
const token = await getAdminToken();
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
}
);
if (!response.ok) {
throw new Error('Échec de la création du groupe');
}
return NextResponse.json({
id: Date.now().toString(),
name,
path: `/${name}`,
membersCount: 0
});
} catch (error) {
console.error('Create Group Error:', error);
return NextResponse.json(
{ message: error instanceof Error ? error.message : "Une erreur est survenue" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,234 @@
import { NextRequest } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
interface StatusLabel {
id: string;
name: string;
statusType: string;
class: string;
}
interface Project {
id: string;
name: string;
labels: StatusLabel[];
}
// Cache for user IDs to avoid repeated lookups
const userCache = new Map<string, number>();
async function getLeantimeUserId(email: string): Promise<number | null> {
// Check cache first
if (userCache.has(email)) {
return userCache.get(email)!;
}
try {
console.log('Fetching Leantime user with token:', process.env.LEANTIME_TOKEN ? 'Token present' : 'Token missing');
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.Users.Users.getUserByEmail',
id: 1,
params: {
email: email
}
}),
});
if (!response.ok) {
console.error('Failed to fetch user from Leantime:', {
status: response.status,
statusText: response.statusText
});
throw new Error(`Failed to fetch user from Leantime: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Leantime user response:', data);
if (!data.result || data.result === false) {
console.log('User not found in Leantime');
return null;
}
// Cache the user ID
userCache.set(email, data.result.id);
// Clear cache after 5 minutes
setTimeout(() => userCache.delete(email), 5 * 60 * 1000);
return data.result.id;
} catch (error) {
console.error('Error getting Leantime user ID:', error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
console.log('Session:', session ? 'Present' : 'Missing');
if (!session) {
return NextResponse.json(
{ error: "Unauthorized", message: "No session found. Please sign in." },
{ status: 401 }
);
}
if (!session.user?.email) {
return NextResponse.json(
{ error: "Unauthorized", message: "No email found in session. Please sign in again." },
{ status: 401 }
);
}
console.log('User email:', session.user.email);
// Get Leantime user ID
const leantimeUserId = await getLeantimeUserId(session.user.email);
console.log('Leantime user ID:', leantimeUserId);
if (!leantimeUserId) {
return NextResponse.json(
{ error: "User not found", message: "Could not find user in Leantime. Please check your email." },
{ status: 404 }
);
}
// Get all tasks assigned to the user
console.log('Fetching tasks for user:', leantimeUserId);
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.Tickets.Tickets.getAll',
id: 1,
params: {
projectId: 0, // 0 means all projects
userId: leantimeUserId,
status: "all",
limit: 100
}
})
});
if (!response.ok) {
console.error('Failed to fetch tasks:', {
status: response.status,
statusText: response.statusText
});
throw new Error(`Failed to fetch tasks: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Tasks response:', data);
if (!data.result) {
return NextResponse.json({ projects: [] });
}
// Get project details to include project names
console.log('Fetching projects');
const projectsResponse = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.Projects.getAll',
id: 1
})
});
if (!projectsResponse.ok) {
console.error('Failed to fetch projects:', {
status: projectsResponse.status,
statusText: projectsResponse.statusText
});
throw new Error(`Failed to fetch projects: ${projectsResponse.status} ${projectsResponse.statusText}`);
}
const projectsData = await projectsResponse.json();
console.log('Projects response:', projectsData);
// Create a map of projects with their tasks grouped by status
const projectMap = new Map<string, Project>();
data.result.forEach((task: any) => {
const project = projectsData.result.find((p: any) => p.id === task.projectId);
const projectName = project ? project.name : `Project ${task.projectId}`;
const projectId = task.projectId.toString();
if (!projectMap.has(projectId)) {
projectMap.set(projectId, {
id: projectId,
name: projectName,
labels: []
});
}
const currentProject = projectMap.get(projectId)!;
// Check if this status label already exists for this project
const existingLabel = currentProject.labels.find(label => label.name === task.status);
if (!existingLabel) {
let statusType;
let statusClass;
// Convert numeric status to string and handle accordingly
const statusStr = task.status.toString();
switch (statusStr) {
case '1':
statusType = 'NEW';
statusClass = 'bg-blue-100 text-blue-800';
break;
case '2':
statusType = 'INPROGRESS';
statusClass = 'bg-yellow-100 text-yellow-800';
break;
case '3':
statusType = 'DONE';
statusClass = 'bg-green-100 text-green-800';
break;
default:
statusType = 'UNKNOWN';
statusClass = 'bg-gray-100 text-gray-800';
}
currentProject.labels.push({
id: `${projectId}-${task.status}`,
name: task.status,
statusType: statusType,
class: statusClass
});
}
});
// Convert the map to an array and sort projects by name
const projects = Array.from(projectMap.values()).sort((a, b) => a.name.localeCompare(b.name));
console.log('Final projects:', projects);
return NextResponse.json({ projects });
} catch (error) {
console.error('Error fetching status labels:', error);
return NextResponse.json(
{ error: "Failed to fetch status labels", message: error instanceof Error ? error.message : "Unknown error occurred" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
interface Task {
id: string;
headline: string;
projectName: string;
projectId: number;
status: number;
dateToFinish: string | null;
milestone: string | null;
details: string | null;
createdOn: string;
editedOn: string | null;
editorId: string;
editorFirstname: string;
editorLastname: string;
}
async function getLeantimeUserId(email: string): Promise<number | null> {
try {
if (!process.env.LEANTIME_TOKEN) {
console.error('LEANTIME_TOKEN is not set in environment variables');
return null;
}
console.log('Fetching Leantime users for email:', email);
console.log('API URL:', process.env.LEANTIME_API_URL);
console.log('Token length:', process.env.LEANTIME_TOKEN.length);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN
};
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.users.getAll',
id: 1
}),
});
const responseText = await response.text();
console.log('Raw Leantime response:', responseText);
if (!response.ok) {
console.error('Failed to fetch Leantime users:', {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
});
return null;
}
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
console.error('Failed to parse Leantime response:', e);
return null;
}
console.log('Leantime users response:', data);
if (!data.result || !Array.isArray(data.result)) {
console.error('Invalid response format from Leantime users API');
return null;
}
const users = data.result;
const user = users.find((u: any) => u.username === email);
if (user) {
console.log('Found Leantime user:', { id: user.id, username: user.username });
} else {
console.log('No Leantime user found for username:', email);
}
return user ? user.id : null;
} catch (error) {
console.error('Error fetching Leantime user ID:', error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log('Fetching tasks for user:', session.user.email);
const userId = await getLeantimeUserId(session.user.email);
if (!userId) {
console.error('User not found in Leantime');
return NextResponse.json({ error: "User not found in Leantime" }, { status: 404 });
}
console.log('Fetching tasks for Leantime user ID:', userId);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN!
};
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.tickets.getAll',
params: {
userId: userId,
status: "all"
},
id: 1
}),
});
const responseText = await response.text();
console.log('Tasks API response status:', response.status);
if (!response.ok) {
console.error('Failed to fetch tasks from Leantime:', {
status: response.status,
statusText: response.statusText
});
throw new Error('Failed to fetch tasks from Leantime');
}
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
console.error('Failed to parse tasks response');
throw new Error('Invalid response format from Leantime');
}
if (!data.result || !Array.isArray(data.result)) {
console.error('Invalid response format from Leantime tasks API');
throw new Error('Invalid response format from Leantime');
}
// Log only the number of tasks and their IDs
console.log('Received tasks count:', data.result.length);
console.log('Task IDs:', data.result.map((task: any) => task.id));
const tasks = data.result
.filter((task: any) => {
// Log raw task data for debugging
console.log('Raw task data:', {
id: task.id,
headline: task.headline,
status: task.status,
type: task.type,
dependingTicketId: task.dependingTicketId
});
// Filter out any task (main or subtask) that has status Done (5)
if (task.status === 5) {
console.log(`Filtering out Done task ${task.id} (type: ${task.type || 'main'}, status: ${task.status})`);
return false;
}
// Convert both to strings for comparison to handle any type mismatches
const taskEditorId = String(task.editorId).trim();
const currentUserId = String(userId).trim();
// Only show tasks where the user is the editor
const isUserEditor = taskEditorId === currentUserId;
console.log(`Task ${task.id}: status=${task.status}, type=${task.type || 'main'}, parentId=${task.dependingTicketId || 'none'}, isUserEditor=${isUserEditor}`);
return isUserEditor;
})
.map((task: any) => ({
id: task.id.toString(),
headline: task.headline,
projectName: task.projectName,
projectId: task.projectId,
status: task.status,
dateToFinish: task.dateToFinish || null,
milestone: task.type || null,
details: task.description || null,
createdOn: task.dateCreated,
editedOn: task.editedOn || null,
editorId: task.editorId,
editorFirstname: task.editorFirstname,
editorLastname: task.editorLastname,
type: task.type || null, // Added type field to identify subtasks
dependingTicketId: task.dependingTicketId || null // Added parent task reference
}));
console.log(`Found ${tasks.length} tasks assigned to user ${userId}`);
return NextResponse.json(tasks);
} catch (error) {
console.error('Error in tasks route:', error);
return NextResponse.json(
{ error: "Failed to fetch tasks" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,172 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import Imap from 'imap';
interface StoredCredentials {
email: string;
password: string;
host: string;
port: number;
}
function getStoredCredentials(): StoredCredentials | null {
const cookieStore = cookies();
const credentialsCookie = cookieStore.get('imap_credentials');
if (!credentialsCookie?.value) {
return null;
}
try {
const credentials = JSON.parse(credentialsCookie.value);
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
return null;
}
return credentials;
} catch (error) {
return null;
}
}
export async function POST(request: Request) {
try {
const { emailIds, action } = await request.json();
if (!emailIds || !Array.isArray(emailIds) || !action) {
return NextResponse.json(
{ error: 'Invalid request parameters' },
{ status: 400 }
);
}
// Get the current folder from the request URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
// Get stored credentials
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
return new Promise((resolve) => {
const imap = new Imap({
user: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
connTimeout: 30000
});
const timeout = setTimeout(() => {
console.error('IMAP connection timeout');
imap.end();
resolve(NextResponse.json({ error: 'Connection timeout' }));
}, 30000);
imap.once('error', (err: Error) => {
console.error('IMAP error:', err);
clearTimeout(timeout);
resolve(NextResponse.json({ error: 'IMAP connection error' }));
});
imap.once('ready', () => {
imap.openBox(folder, false, (err, box) => {
if (err) {
console.error(`Error opening box ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: `Failed to open folder ${folder}` }));
return;
}
// Convert string IDs to numbers
const numericIds = emailIds.map(id => parseInt(id, 10));
// Process each email
let processedCount = 0;
const totalEmails = numericIds.length;
const processNextEmail = (index: number) => {
if (index >= totalEmails) {
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ success: true }));
return;
}
const id = numericIds[index];
const fetch = imap.fetch(id.toString(), {
bodies: '',
struct: true
});
fetch.on('message', (msg) => {
msg.once('attributes', (attrs) => {
const uid = attrs.uid;
if (!uid) {
processedCount++;
processNextEmail(index + 1);
return;
}
switch (action) {
case 'delete':
imap.move(uid, 'Trash', (err) => {
if (err) console.error('Error moving to trash:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'mark-read':
imap.addFlags(uid, ['\\Seen'], (err) => {
if (err) console.error('Error marking as read:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'mark-unread':
imap.removeFlags(uid, ['\\Seen'], (err) => {
if (err) console.error('Error marking as unread:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
case 'archive':
imap.move(uid, 'Archive', (err) => {
if (err) console.error('Error moving to archive:', err);
processedCount++;
processNextEmail(index + 1);
});
break;
}
});
});
fetch.on('error', (err) => {
console.error('Error fetching email:', err);
processedCount++;
processNextEmail(index + 1);
});
};
processNextEmail(0);
});
});
imap.connect();
});
} catch (error) {
console.error('Error in bulk actions:', error);
return NextResponse.json(
{ error: 'Failed to perform bulk action' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import Imap from 'imap';
interface StoredCredentials {
user: string;
password: string;
host: string;
port: string;
}
export let storedCredentials: StoredCredentials | null = null;
export async function POST(request: Request) {
try {
const { email, password, host, port } = await request.json();
if (!email || !password || !host || !port) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Test IMAP connection
const imap = new Imap({
user: email,
password,
host,
port: parseInt(port),
tls: true,
tlsOptions: {
rejectUnauthorized: false,
servername: host
},
authTimeout: 10000,
connTimeout: 10000,
debug: console.log
});
return new Promise((resolve, reject) => {
imap.once('ready', () => {
imap.end();
// Store credentials
storedCredentials = { user: email, password, host, port };
resolve(NextResponse.json({ success: true }));
});
imap.once('error', (err: Error) => {
imap.end();
if (err.message.includes('Invalid login or password')) {
reject(new Error('Invalid login or password'));
} else {
reject(new Error(`IMAP connection error: ${err.message}`));
}
});
imap.connect();
});
} catch (error) {
console.error('Error in login handler:', error);
if (error instanceof Error) {
if (error.message.includes('Invalid login or password')) {
return NextResponse.json(
{ error: 'Invalid login or password', details: error.message },
{ status: 401 }
);
}
return NextResponse.json(
{ error: 'Failed to connect to email server', details: error.message },
{ status: 500 }
);
}
return NextResponse.json(
{ error: 'Unknown error occurred' },
{ status: 500 }
);
}
}
export async function GET() {
if (!storedCredentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 404 }
);
}
// Return credentials without password
const { password, ...safeCredentials } = storedCredentials;
return NextResponse.json(safeCredentials);
}

View File

@ -0,0 +1,155 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import Imap from 'imap';
interface StoredCredentials {
email: string;
password: string;
host: string;
port: number;
}
function getStoredCredentials(): StoredCredentials | null {
const cookieStore = cookies();
const credentialsCookie = cookieStore.get('imap_credentials');
if (!credentialsCookie?.value) {
return null;
}
try {
const credentials = JSON.parse(credentialsCookie.value);
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
return null;
}
return credentials;
} catch (error) {
return null;
}
}
export async function POST(request: Request) {
try {
const { emailId, isRead } = await request.json();
if (!emailId || typeof isRead !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid request parameters' },
{ status: 400 }
);
}
// Get the current folder from the request URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
// Get stored credentials
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
return new Promise((resolve) => {
const imap = new Imap({
user: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
connTimeout: 30000
});
const timeout = setTimeout(() => {
console.error('IMAP connection timeout');
imap.end();
resolve(NextResponse.json({ error: 'Connection timeout' }));
}, 30000);
imap.once('error', (err: Error) => {
console.error('IMAP error:', err);
clearTimeout(timeout);
resolve(NextResponse.json({ error: 'IMAP connection error' }));
});
imap.once('ready', () => {
imap.openBox(folder, false, (err, box) => {
if (err) {
console.error(`Error opening box ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: `Failed to open folder ${folder}` }));
return;
}
// Convert string ID to number
const numericId = parseInt(emailId, 10);
const fetch = imap.fetch(numericId.toString(), {
bodies: '',
struct: true
});
fetch.on('message', (msg) => {
msg.once('attributes', (attrs) => {
const uid = attrs.uid;
if (!uid) {
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: 'No UID found for email' }));
return;
}
if (isRead) {
imap.addFlags(uid, ['\\Seen'], (err) => {
if (err) {
console.error('Error marking as read:', err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: 'Failed to mark as read' }));
return;
}
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ success: true }));
});
} else {
imap.removeFlags(uid, ['\\Seen'], (err) => {
if (err) {
console.error('Error marking as unread:', err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: 'Failed to mark as unread' }));
return;
}
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ success: true }));
});
}
});
});
fetch.on('error', (err) => {
console.error('Error fetching email:', err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ error: 'Failed to fetch email' }));
});
});
});
imap.connect();
});
} catch (error) {
console.error('Error marking email as read:', error);
return NextResponse.json(
{ error: 'Failed to mark email as read' },
{ status: 500 }
);
}
}

362
app/api/mail/route.ts Normal file
View File

@ -0,0 +1,362 @@
import { NextResponse } from 'next/server';
import Imap from 'imap';
import nodemailer from 'nodemailer';
import { parseEmailHeaders, decodeEmailBody } from '@/lib/email-parser';
import { cookies } from 'next/headers';
interface StoredCredentials {
email: string;
password: string;
host: string;
port: number;
}
interface Email {
id: string;
from: string;
subject: string;
date: Date;
read: boolean;
starred: boolean;
body: string;
to?: string;
folder: string;
}
interface ImapBox {
messages: {
total: number;
};
}
interface ImapMessage {
on: (event: string, callback: (data: any) => void) => void;
once: (event: string, callback: (data: any) => void) => void;
attributes: {
uid: number;
flags: string[];
size: number;
};
body: {
[key: string]: {
on: (event: string, callback: (data: any) => void) => void;
};
};
}
interface ImapConfig {
user: string;
password: string;
host: string;
port: number;
tls: boolean;
authTimeout: number;
connTimeout: number;
debug?: (info: string) => void;
}
function getStoredCredentials(): StoredCredentials | null {
const cookieStore = cookies();
const credentialsCookie = cookieStore.get('imap_credentials');
console.log('Retrieved credentials cookie:', credentialsCookie ? 'Found' : 'Not found');
if (!credentialsCookie?.value) {
console.log('No credentials cookie found');
return null;
}
try {
const credentials = JSON.parse(credentialsCookie.value);
console.log('Parsed credentials:', {
...credentials,
password: '***'
});
// Validate required fields
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
console.error('Missing required credentials fields');
return null;
}
return {
email: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port
};
} catch (error) {
console.error('Error parsing credentials cookie:', error);
return null;
}
}
export async function GET(request: Request) {
try {
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
// Get pagination parameters from URL
const url = new URL(request.url);
const folder = url.searchParams.get('folder') || 'INBOX';
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '24');
const offset = (page - 1) * limit;
return new Promise((resolve) => {
const imap = new Imap({
user: credentials.email,
password: credentials.password,
host: credentials.host,
port: credentials.port,
tls: true,
tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000,
connTimeout: 30000
});
const timeout = setTimeout(() => {
console.error('IMAP connection timeout');
imap.end();
resolve(NextResponse.json({
emails: [],
error: 'Connection timeout'
}));
}, 30000);
imap.once('error', (err: Error) => {
console.error('IMAP error:', err);
clearTimeout(timeout);
resolve(NextResponse.json({
emails: [],
error: 'IMAP connection error'
}));
});
imap.once('ready', () => {
imap.getBoxes((err, boxes) => {
if (err) {
console.error('Error getting mailboxes:', err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ emails: [], error: 'Failed to get mailboxes' }));
return;
}
const availableMailboxes = Object.keys(boxes).filter(
box => !['Starred', 'Archives'].includes(box)
);
console.log('Available mailboxes:', availableMailboxes);
// Only process the requested folder
imap.openBox(folder, false, (err, box) => {
if (err) {
console.error(`Error opening box ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ emails: [], error: `Failed to open folder ${folder}` }));
return;
}
// Get the specified folder
const totalMessages = box.messages.total;
// Calculate the range of messages to fetch
const start = Math.max(1, totalMessages - offset - limit + 1);
const end = totalMessages - offset;
if (start > end) {
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({
emails: [],
folders: availableMailboxes,
mailUrl: process.env.NEXT_PUBLIC_IFRAME_MAIL_URL
}));
return;
}
// Fetch messages in the calculated range
imap.search(['ALL'], (err, results) => {
if (err) {
console.error(`Error searching in ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ emails: [], error: `Failed to search in ${folder}` }));
return;
}
if (!results || results.length === 0) {
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({
emails: [],
folders: availableMailboxes,
mailUrl: process.env.NEXT_PUBLIC_IFRAME_MAIL_URL
}));
return;
}
// Take only the most recent emails up to the limit
const recentResults = results.slice(-limit);
const emails: any[] = [];
const fetch = imap.fetch(recentResults, {
bodies: ['HEADER', 'TEXT'],
struct: true
});
fetch.on('message', (msg) => {
let header = '';
let text = '';
let messageId: number | null = null;
let messageFlags: string[] = [];
msg.once('attributes', (attrs) => {
messageId = attrs.uid;
messageFlags = attrs.flags || [];
});
msg.on('body', (stream, info) => {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString('utf8');
});
stream.on('end', () => {
if (info.which === 'HEADER') {
header = buffer;
} else if (info.which === 'TEXT') {
text = buffer;
}
});
});
msg.on('end', () => {
if (!messageId) {
console.error('No message ID found for email');
return;
}
const parsedHeader = Imap.parseHeader(header);
const email = {
id: messageId,
from: parsedHeader.from?.[0] || '',
to: parsedHeader.to?.[0] || '',
subject: parsedHeader.subject?.[0] || '(No subject)',
date: parsedHeader.date?.[0] || new Date().toISOString(),
body: text,
folder: folder,
flags: messageFlags,
read: messageFlags.includes('\\Seen'),
starred: messageFlags.includes('\\Flagged')
};
emails.push(email);
});
});
fetch.on('error', (err) => {
console.error(`Error fetching emails from ${folder}:`, err);
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({ emails: [], error: `Failed to fetch emails from ${folder}` }));
});
fetch.on('end', () => {
// Sort emails by date (most recent first)
emails.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
clearTimeout(timeout);
imap.end();
resolve(NextResponse.json({
emails,
folders: availableMailboxes,
mailUrl: process.env.NEXT_PUBLIC_IFRAME_MAIL_URL
}));
});
});
});
});
});
imap.connect();
});
} catch (error) {
console.error('Error in GET /api/mail:', error);
return NextResponse.json(
{ error: 'Failed to fetch emails' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
let body;
try {
body = await request.json();
} catch (error) {
return NextResponse.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
const { to, subject, body: emailBody, attachments } = body;
if (!to || !subject || !emailBody) {
return NextResponse.json(
{ error: 'Missing required fields: to, subject, or body' },
{ status: 400 }
);
}
const transporter = nodemailer.createTransport({
host: credentials.host,
port: credentials.port,
secure: true,
auth: {
user: credentials.email,
pass: credentials.password,
},
});
const mailOptions = {
from: credentials.email,
to,
subject,
text: emailBody,
attachments: attachments || [],
};
const info = await transporter.sendMail(mailOptions);
console.log('Email sent:', info.messageId);
return NextResponse.json({
success: true,
messageId: info.messageId,
message: 'Email sent successfully'
});
} catch (error) {
console.error('Error sending email:', error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Failed to send email',
details: error instanceof Error ? error.stack : undefined
},
{ status: 500 }
);
}
}

106
app/api/mail/send/route.ts Normal file
View File

@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { createTransport } from 'nodemailer';
import { cookies } from 'next/headers';
interface StoredCredentials {
email: string;
password: string;
host: string;
port: number;
}
// Maximum attachment size in bytes (10MB)
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
function getStoredCredentials(): StoredCredentials | null {
const cookieStore = cookies();
const credentialsCookie = cookieStore.get('imap_credentials');
if (!credentialsCookie?.value) {
return null;
}
try {
const credentials = JSON.parse(credentialsCookie.value);
if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) {
return null;
}
return credentials;
} catch (error) {
return null;
}
}
export async function POST(request: Request) {
try {
const credentials = getStoredCredentials();
if (!credentials) {
return NextResponse.json(
{ error: 'No stored credentials found' },
{ status: 401 }
);
}
const { to, cc, bcc, subject, body, attachments } = await request.json();
// Check attachment sizes
if (attachments?.length) {
const oversizedAttachments = attachments.filter((attachment: any) => {
// Calculate size from base64 content
const size = Math.ceil((attachment.content.length * 3) / 4);
return size > MAX_ATTACHMENT_SIZE;
});
if (oversizedAttachments.length > 0) {
return NextResponse.json(
{
error: 'Attachment size limit exceeded',
details: {
maxSize: MAX_ATTACHMENT_SIZE,
oversizedFiles: oversizedAttachments.map((a: any) => a.name)
}
},
{ status: 400 }
);
}
}
// Create a transporter using SMTP with the same credentials
// Use port 465 for SMTP (Infomaniak's SMTP port)
const transporter = createTransport({
host: credentials.host,
port: 465, // SMTP port for Infomaniak
secure: true, // Use TLS
auth: {
user: credentials.email,
pass: credentials.password,
},
});
// Prepare email options
const mailOptions = {
from: credentials.email,
to,
cc,
bcc,
subject,
text: body,
attachments: attachments?.map((attachment: any) => ({
filename: attachment.name,
content: attachment.content,
encoding: attachment.encoding,
})),
};
// Send the email
await transporter.sendMail(mailOptions);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error sending email:', error);
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import Imap from 'imap';
export async function POST(request: Request) {
try {
const { email, password, host, port } = await request.json();
if (!email || !password || !host || !port) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const imapConfig = {
user: email,
password,
host,
port: parseInt(port),
tls: true,
authTimeout: 10000,
connTimeout: 10000,
debug: (info: string) => console.log('IMAP Debug:', info)
};
console.log('Testing IMAP connection with config:', {
...imapConfig,
password: '***',
email
});
const imap = new Imap(imapConfig);
const connectPromise = new Promise((resolve, reject) => {
imap.once('ready', () => {
imap.end();
resolve(true);
});
imap.once('error', (err: Error) => {
imap.end();
reject(err);
});
imap.connect();
});
await connectPromise;
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error testing connection:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to connect to email server' },
{ status: 500 }
);
}
}

131
app/api/news/route.ts Normal file
View File

@ -0,0 +1,131 @@
import { NextResponse } from 'next/server';
// FastAPI server configuration
const API_URL = 'http://172.16.0.104:8000';
// Helper function to clean HTML content
function cleanHtmlContent(text: string): string {
if (!text) return '';
return text
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/&nbsp;/g, ' ') // Replace &nbsp; with space
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.trim();
}
// Helper function to format time
function formatDateTime(dateStr: string): { displayDate: string, timestamp: string } {
try {
const date = new Date(dateStr);
// Format like "17 avr." to match the Duties widget style
const day = date.getDate();
const month = date.toLocaleString('fr-FR', { month: 'short' })
.toLowerCase()
.replace('.', ''); // Remove the dot that comes with French locale
return {
displayDate: `${day} ${month}.`, // Add the dot back for consistent styling
timestamp: date.toLocaleString('fr-FR', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(',', ' à')
};
} catch (error) {
return { displayDate: 'N/A', timestamp: 'N/A' };
}
}
// Helper function to truncate text
function truncateText(text: string, maxLength: number): string {
if (!text) return '';
const cleaned = cleanHtmlContent(text);
if (cleaned.length <= maxLength) return cleaned;
const lastSpace = cleaned.lastIndexOf(' ', maxLength);
const truncated = cleaned.substring(0, lastSpace > 0 ? lastSpace : maxLength).trim();
return truncated.replace(/[.,!?]$/, '') + '...';
}
// Helper function to format category
function formatCategory(category: string): string | null {
if (!category) return null;
// Return null for all categories to remove the labels completely
return null;
}
// Helper function to format source
function formatSource(source: string): string {
if (!source) return '';
const sourceName = source
.replace(/^(https?:\/\/)?(www\.)?/i, '')
.split('.')[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, ' ')
.trim();
return sourceName.charAt(0).toUpperCase() + sourceName.slice(1);
}
interface NewsItem {
id: number;
title: string;
displayDate: string;
timestamp: string;
source: string;
description: string | null;
category: string | null;
url: string;
}
export async function GET() {
try {
console.log('Fetching news from FastAPI server...');
const response = await fetch(`${API_URL}/news?limit=12`, {
method: 'GET',
headers: {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const articles = await response.json();
const formattedNews: NewsItem[] = articles.map((article: any) => {
const { displayDate, timestamp } = formatDateTime(article.date);
return {
id: article.id,
title: truncateText(article.title, 100), // Increased length for better titles
description: article.description ? truncateText(article.description, 150) : null, // Increased length for better descriptions
displayDate,
timestamp,
source: formatSource(article.source),
category: formatCategory(article.category),
url: article.url || '#'
};
});
console.log(`Successfully fetched ${formattedNews.length} news articles`);
return NextResponse.json(formattedNews);
} catch (error) {
console.error('API error:', {
error: error instanceof Error ? error.message : 'Unknown error',
server: API_URL
});
return NextResponse.json(
{
error: 'Failed to fetch news',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,370 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
// Helper function to get user token using admin credentials
async function getUserToken(baseUrl: string) {
try {
// Step 1: Use admin token to authenticate
const adminHeaders = {
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
'Content-Type': 'application/json'
};
// Step 2: Create user token using admin credentials
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
method: 'POST',
headers: adminHeaders
});
if (!createTokenResponse.ok) {
console.error('Failed to create user token:', createTokenResponse.status);
return null;
}
const tokenData = await createTokenResponse.json();
return {
authToken: tokenData.data.authToken,
userId: tokenData.data.userId
};
} catch (error) {
console.error('Error getting user token:', error);
return null;
}
}
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
console.error('No valid session or email found');
return NextResponse.json({ messages: [] }, { status: 200 });
}
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (!baseUrl) {
console.error('Failed to get Rocket.Chat base URL');
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
console.log('Using Rocket.Chat base URL:', baseUrl);
// Step 1: Use admin token to authenticate
const adminHeaders = {
'X-Auth-Token': process.env.ROCKET_CHAT_TOKEN!,
'X-User-Id': process.env.ROCKET_CHAT_USER_ID!,
'Content-Type': 'application/json'
};
// Step 2: Get the current user's Rocket.Chat ID
const username = session.user.email.split('@')[0];
if (!username) {
console.error('No username found in session email');
return NextResponse.json({ messages: [] }, { status: 200 });
}
// Get all users to find the current user
const usersResponse = await fetch(`${baseUrl}/api/v1/users.list`, {
method: 'GET',
headers: adminHeaders
});
if (!usersResponse.ok) {
console.error('Failed to get users list:', usersResponse.status);
return NextResponse.json({ messages: [] }, { status: 200 });
}
const usersData = await usersResponse.json();
console.log('Users list response:', {
success: usersData.success,
count: usersData.count,
usersCount: usersData.users?.length
});
// Find the current user in the list
const currentUser = usersData.users.find((user: any) =>
user.username === username || user.emails?.some((email: any) => email.address === session.user.email)
);
if (!currentUser) {
console.error('User not found in users list');
return NextResponse.json({ messages: [] }, { status: 200 });
}
console.log('Found Rocket.Chat user:', {
username: currentUser.username,
id: currentUser._id
});
// Step 3: Create a token for the current user
const createTokenResponse = await fetch(`${baseUrl}/api/v1/users.createToken`, {
method: 'POST',
headers: adminHeaders,
body: JSON.stringify({
userId: currentUser._id
})
});
if (!createTokenResponse.ok) {
console.error('Failed to create user token:', createTokenResponse.status);
const errorText = await createTokenResponse.text();
console.error('Create token error details:', errorText);
return NextResponse.json({ messages: [] }, { status: 200 });
}
const tokenData = await createTokenResponse.json();
// Use the user's token for subsequent requests
const userHeaders = {
'X-Auth-Token': tokenData.data.authToken,
'X-User-Id': currentUser._id,
'Content-Type': 'application/json'
};
// Step 4: Get user's subscriptions using user token
const subscriptionsResponse = await fetch(`${baseUrl}/api/v1/subscriptions.get`, {
method: 'GET',
headers: userHeaders
});
if (!subscriptionsResponse.ok) {
console.error('Failed to get subscriptions:', subscriptionsResponse.status);
const errorText = await subscriptionsResponse.text();
console.error('Subscriptions error details:', errorText);
return NextResponse.json({ messages: [] }, { status: 200 });
}
const subscriptionsData = await subscriptionsResponse.json();
if (!subscriptionsData.success || !Array.isArray(subscriptionsData.update)) {
console.error('Invalid subscriptions response structure');
return NextResponse.json({ messages: [] }, { status: 200 });
}
// Filter subscriptions for the current user
const userSubscriptions = subscriptionsData.update.filter((sub: any) => {
// Only include rooms with unread messages or alerts
if (!(sub.unread > 0 || sub.alert)) {
return false;
}
// Include all types of rooms the user is subscribed to
return ['d', 'c', 'p'].includes(sub.t);
});
console.log('Filtered user subscriptions:', {
userId: currentUser._id,
username: currentUser.username,
totalSubscriptions: userSubscriptions.length,
subscriptionDetails: userSubscriptions.map((sub: any) => ({
type: sub.t,
name: sub.fname || sub.name,
rid: sub.rid,
alert: sub.alert,
unread: sub.unread,
userMentions: sub.userMentions
}))
});
const messages: any[] = [];
const processedRooms = new Set();
const latestMessagePerRoom: { [key: string]: any } = {};
// Step 5: Fetch messages using user token
for (const subscription of userSubscriptions) {
try {
// Determine the correct endpoint and parameters based on room type
let endpoint;
switch (subscription.t) {
case 'c':
endpoint = 'channels.messages';
break;
case 'p':
endpoint = 'groups.messages';
break;
case 'd':
endpoint = 'im.messages';
break;
default:
continue;
}
const queryParams = new URLSearchParams({
roomId: subscription.rid,
count: String(Math.max(subscription.unread, 5)) // Fetch at least the number of unread messages
});
const messagesResponse = await fetch(
`${baseUrl}/api/v1/${endpoint}?${queryParams}`, {
method: 'GET',
headers: userHeaders
}
);
if (!messagesResponse.ok) {
console.error(`Failed to get messages for room ${subscription.name}:`, messagesResponse.status);
continue;
}
const messageData = await messagesResponse.json();
console.log(`Messages for room ${subscription.fname || subscription.name}:`, {
success: messageData.success,
count: messageData.count,
hasMessages: messageData.messages?.length > 0
});
if (messageData.success && messageData.messages?.length > 0) {
// Filter out system messages and join notifications for channels
const validMessages = messageData.messages.filter((message: any) => {
// Skip messages sent by the current user
if (message.u._id === currentUser._id) {
return false;
}
// For channels, apply strict filtering
if (subscription.t === 'c') {
if (!message.msg || // No message text
message.t || // System message
!message.u || // No user info
message.msg.includes('has joined the channel') ||
message.msg.includes('has left the channel') ||
message.msg.includes('added') ||
message.msg.includes('removed')) {
return false;
}
}
return true;
});
// Only process the latest valid message from this room
if (validMessages.length > 0) {
// Get the latest message (they should already be sorted by timestamp)
const latestMessage = validMessages[0];
const messageUser = latestMessage.u || {};
const username = messageUser.username || 'unknown';
// Skip if this is our own message (double-check)
if (messageUser._id === currentUser._id) {
continue;
}
// Get proper display names
let roomDisplayName = subscription.fname || subscription.name;
let userDisplayName = messageUser.name || username;
// Handle call messages
let messageText = latestMessage.msg || '';
if (messageText.includes('started a call')) {
messageText = '📞 Call received';
}
// Format timestamp
const timestamp = new Date(latestMessage.ts);
const now = new Date();
let formattedTime = '';
if (isNaN(timestamp.getTime())) {
formattedTime = 'Invalid Date';
} else if (timestamp.toDateString() === now.toDateString()) {
formattedTime = timestamp.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
} else {
formattedTime = timestamp.toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short'
});
}
// Create initials for the sender
const initials = userDisplayName
.split(' ')
.map((n: string) => n[0])
.slice(0, 2)
.join('')
.toUpperCase();
const processedMessage = {
id: latestMessage._id,
text: messageText,
timestamp: formattedTime,
rawTimestamp: latestMessage.ts,
roomName: roomDisplayName,
roomType: subscription.t,
sender: {
_id: messageUser._id,
username: username,
name: userDisplayName,
initials: initials,
color: getAvatarColor(username)
},
isOwnMessage: messageUser._id === currentUser._id,
room: {
id: subscription.rid,
type: subscription.t,
name: roomDisplayName,
isChannel: subscription.t === 'c',
isPrivateGroup: subscription.t === 'p',
isDirect: subscription.t === 'd',
link: `${baseUrl}/${subscription.t === 'd' ? 'direct' : subscription.t === 'p' ? 'group' : 'channel'}/${subscription.name}`,
unread: subscription.unread,
alert: subscription.alert,
userMentions: subscription.userMentions
}
};
// Store this message if it's the latest for this room
if (!latestMessagePerRoom[subscription.rid] ||
new Date(latestMessage.ts).getTime() > new Date(latestMessagePerRoom[subscription.rid].rawTimestamp).getTime()) {
latestMessagePerRoom[subscription.rid] = processedMessage;
}
}
}
} catch (error) {
console.error(`Error fetching messages for room ${subscription.name}:`, error);
continue;
}
}
// Convert the latest messages object to an array and sort by timestamp
const sortedMessages = Object.values(latestMessagePerRoom)
.sort((a, b) => {
const dateA = new Date(a.rawTimestamp);
const dateB = new Date(b.rawTimestamp);
return dateB.getTime() - dateA.getTime();
})
.slice(0, 10);
return NextResponse.json({
messages: sortedMessages,
total: Object.keys(latestMessagePerRoom).length,
hasMore: Object.keys(latestMessagePerRoom).length > 10
}, { status: 200 });
} catch (error) {
console.error('Error in messages endpoint:', error);
return NextResponse.json({ messages: [], total: 0, hasMore: false }, { status: 200 });
}
}
// Helper function to generate consistent avatar colors
function getAvatarColor(username: string): string {
const colors = [
'#FF7452', // Coral
'#4CAF50', // Green
'#2196F3', // Blue
'#9C27B0', // Purple
'#FF9800', // Orange
'#00BCD4', // Cyan
'#795548', // Brown
'#607D8B' // Blue Grey
];
// Generate a consistent index based on username
const index = username
.split('')
.reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
return colors[index];
}

81
app/api/roles/route.ts Normal file
View File

@ -0,0 +1,81 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
async function getAdminToken() {
try {
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok || !data.access_token) {
console.error('Token Error:', data);
return null;
}
return data.access_token;
} catch (error) {
console.error('Token Error:', error);
return null;
}
}
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ error: "Erreur d'authentification" }, { status: 401 });
}
const response = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const errorData = await response.json();
console.error("Failed to fetch roles:", errorData);
return NextResponse.json({ error: "Erreur lors de la récupération des rôles" }, { status: response.status });
}
const roles = await response.json();
// Filter out only Keycloak system roles
const filteredRoles = roles.filter((role: any) =>
!role.name.startsWith('default-roles-') &&
!['offline_access', 'uma_authorization'].includes(role.name)
);
console.log("Available roles:", filteredRoles);
return NextResponse.json(filteredRoles);
} catch (error) {
console.error("Error fetching roles:", error);
return NextResponse.json(
{ error: "Une erreur est survenue" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,72 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
export async function PUT(
req: Request,
{ params }: { params: { userId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const { password, temporary = true } = await req.json();
// Get client credentials token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok) {
console.error("Failed to get token:", tokenData);
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
}
// Reset password with temporary flag
const passwordResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${params.userId}/reset-password`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: "password",
value: password,
temporary: temporary,
}),
}
);
if (!passwordResponse.ok) {
const errorData = await passwordResponse.json();
console.error("Failed to reset password:", errorData);
return NextResponse.json({ error: "Failed to reset password" }, { status: passwordResponse.status });
}
return NextResponse.json({
success: true,
temporary: temporary
});
} catch (error) {
console.error("Error in reset password:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getKeycloakAdminClient } from "@/lib/keycloak";
import { RoleRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
export async function GET(
request: Request,
{ params }: { params: { userId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = params;
const kcAdminClient = await getKeycloakAdminClient();
// Get all available roles
const availableRoles = await kcAdminClient.roles.find();
// Get user's current roles
const userRoles = await kcAdminClient.users.listRoleMappings({
id: userId,
});
return NextResponse.json({
availableRoles,
userRoles,
});
} catch (error) {
console.error("Error fetching roles:", error);
return NextResponse.json(
{ error: "Failed to fetch roles" },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: { userId: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId } = params;
const { roles } = await request.json();
const kcAdminClient = await getKeycloakAdminClient();
// Get all available roles
const availableRoles = await kcAdminClient.roles.find();
// Get current user roles
const currentRoles = await kcAdminClient.users.listRoleMappings({
id: userId,
});
// Find roles to add and remove
const rolesToAdd = roles.filter(
(role: string) => !currentRoles.realmMappings?.some((r: RoleRepresentation) => r.name === role)
);
const rolesToRemove = currentRoles.realmMappings?.filter(
(role: RoleRepresentation) => !roles.includes(role.name)
);
// Add new roles
for (const roleName of rolesToAdd) {
const role = availableRoles.find((r: RoleRepresentation) => r.name === roleName);
if (role) {
await kcAdminClient.users.addRealmRoleMappings({
id: userId,
roles: [role],
});
}
}
// Remove old roles
if (rolesToRemove && rolesToRemove.length > 0) {
await kcAdminClient.users.delRealmRoleMappings({
id: userId,
roles: rolesToRemove,
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating roles:", error);
return NextResponse.json(
{ error: "Failed to update roles" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,276 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
// Helper function to get Leantime user ID by email
async function getLeantimeUserId(email: string): Promise<number | null> {
try {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
console.error('Invalid email format');
return null;
}
// Get user by email using the proper method
const userResponse = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
method: 'leantime.rpc.Users.Users.getUserByEmail',
jsonrpc: '2.0',
id: 1,
params: {
email: email
}
})
});
const userData = await userResponse.json();
// Only log minimal information for debugging
console.log('Leantime user lookup response status:', userResponse.status);
if (!userResponse.ok || !userData.result) {
console.error('Failed to get Leantime user');
return null;
}
// The result should be the user object or false
if (userData.result === false) {
return null;
}
return userData.result.id;
} catch (error) {
console.error('Error getting Leantime user');
return null;
}
}
// Helper function to delete user from Leantime
async function deleteLeantimeUser(email: string, requestingUserId: string): Promise<{ success: boolean; error?: string }> {
try {
// First get the Leantime user ID
const leantimeUserId = await getLeantimeUserId(email);
if (!leantimeUserId) {
return {
success: false,
error: 'User not found in Leantime'
};
}
// Check if the requesting user has permission to delete this user
// This is a placeholder - implement proper permission check based on your requirements
if (!await hasDeletePermission(requestingUserId, leantimeUserId)) {
return {
success: false,
error: 'Unauthorized to delete this user'
};
}
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
method: 'leantime.rpc.Users.Users.deleteUser',
jsonrpc: '2.0',
id: 1,
params: {
id: leantimeUserId
}
})
});
const data = await response.json();
// Only log minimal information
console.log('Leantime delete response status:', response.status);
if (!response.ok || !data.result) {
return {
success: false,
error: 'Failed to delete user in Leantime'
};
}
return { success: true };
} catch (error) {
console.error('Error deleting Leantime user');
return {
success: false,
error: 'Error deleting user in Leantime'
};
}
}
// Placeholder for permission check - implement based on your requirements
async function hasDeletePermission(requestingUserId: string, targetUserId: number): Promise<boolean> {
// Implement proper permission check logic here
// For example:
// 1. Check if requesting user is admin
// 2. Check if requesting user is trying to delete themselves
// 3. Check if requesting user has the right role/permissions
return true;
}
export async function DELETE(
req: Request,
{ params }: { params: { userId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
// Get admin token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok || !tokenData.access_token) {
console.error("Failed to get admin token");
return NextResponse.json({ error: "Erreur d'authentification" }, { status: 401 });
}
// Get user details before deletion
const userResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${params.userId}`,
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
if (!userResponse.ok) {
console.error("Failed to get user details");
return NextResponse.json(
{ error: "Erreur lors de la récupération des détails de l'utilisateur" },
{ status: userResponse.status }
);
}
const userDetails = await userResponse.json();
console.log('Processing user deletion for ID:', params.userId);
// Delete user from Keycloak
const deleteResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${params.userId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
if (!deleteResponse.ok) {
console.error("Keycloak delete error");
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur" },
{ status: deleteResponse.status }
);
}
// Delete user from Leantime
const leantimeResult = await deleteLeantimeUser(userDetails.email, session.user.id);
if (!leantimeResult.success) {
console.error("Leantime user deletion failed");
// We don't return an error here since Keycloak user was deleted successfully
// We just log the error and continue
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting user");
return NextResponse.json(
{ error: "Erreur serveur" },
{ status: 500 }
);
}
}
export async function PUT(
req: Request,
{ params }: { params: { userId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const body = await req.json();
// Get client credentials token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok) {
console.error("Failed to get token:", tokenData);
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
}
// Update user
const updateResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${params.userId}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!updateResponse.ok) {
const errorData = await updateResponse.json();
console.error("Failed to update user:", errorData);
return NextResponse.json({ error: "Failed to update user" }, { status: updateResponse.status });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error in PUT user:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

572
app/api/users/route.ts Normal file
View File

@ -0,0 +1,572 @@
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 });
}
console.log("Session:", {
accessToken: session.accessToken?.substring(0, 20) + "...",
user: session.user,
});
try {
// Get client credentials token
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokenData = await tokenResponse.json();
console.log("Token response:", {
ok: tokenResponse.ok,
status: tokenResponse.status,
data: tokenData.access_token ? "Token received" : tokenData,
});
if (!tokenResponse.ok) {
console.error("Failed to get token:", tokenData);
return NextResponse.json([getCurrentUser(session)]);
}
// Get users list with brief=false to get full user details
const usersResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users?briefRepresentation=false`,
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
},
}
);
if (!usersResponse.ok) {
console.error("Failed to fetch users:", await usersResponse.text());
return NextResponse.json([getCurrentUser(session)]);
}
const users = await usersResponse.json();
console.log("Raw users data:", users.map((u: any) => ({
id: u.id,
username: u.username,
realm: u.realm,
serviceAccountClientId: u.serviceAccountClientId,
})));
// Filter out service accounts and users from other realms
const filteredUsers = users.filter((user: any) =>
!user.serviceAccountClientId && // Remove service accounts
(!user.realm || user.realm === process.env.KEYCLOAK_REALM) // Only users from our realm
);
console.log("Filtered users count:", filteredUsers.length);
// Fetch roles for each user
const usersWithRoles = await Promise.all(filteredUsers.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 ${tokenData.access_token}`,
'Content-Type': 'application/json',
},
}
);
let roles = [];
if (rolesResponse.ok) {
const rolesData = await rolesResponse.json();
roles = rolesData
.filter((role: any) =>
!role.name.startsWith('default-roles-') &&
!['offline_access', 'uma_authorization'].includes(role.name)
)
.map((role: any) => role.name);
console.log(`Roles for user ${user.username}:`, roles);
}
return {
id: user.id,
username: user.username,
firstName: user.firstName || '',
lastName: user.lastName || '',
email: user.email,
createdTimestamp: user.createdTimestamp,
enabled: user.enabled,
roles: roles,
};
} catch (error) {
console.error(`Error fetching roles for user ${user.id}:`, error);
return {
id: user.id,
username: user.username,
firstName: user.firstName || '',
lastName: user.lastName || '',
email: user.email,
createdTimestamp: user.createdTimestamp,
enabled: user.enabled,
roles: [],
};
}
}));
console.log("Final users data:", usersWithRoles.map(u => ({
username: u.username,
roles: u.roles,
})));
return NextResponse.json(usersWithRoles);
} catch (error) {
console.error("Error:", error);
return NextResponse.json([getCurrentUser(session)]);
}
}
// Helper function to get current user data
function getCurrentUser(session: any) {
return {
id: session.user.id,
username: session.user.username,
firstName: session.user.first_name,
lastName: session.user.last_name,
email: session.user.email,
createdTimestamp: Date.now(),
roles: session.user.role || [],
};
}
async function getAdminToken() {
try {
const tokenResponse = 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: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok || !data.access_token) {
console.error('Token Error:', data);
return null;
}
return data.access_token;
} catch (error) {
console.error('Token Error:', error);
return null;
}
}
// Validate username according to Keycloak requirements
function validateUsername(username: string): { isValid: boolean; error?: string } {
// Keycloak username requirements:
// - Only alphanumeric characters, dots (.), hyphens (-), and underscores (_)
// - Must start with a letter or number
// - Must be between 3 and 255 characters
const usernameRegex = /^[a-zA-Z0-9][a-zA-Z0-9._-]{2,254}$/;
if (!usernameRegex.test(username)) {
return {
isValid: false,
error: "Le nom d'utilisateur doit commencer par une lettre ou un chiffre, ne contenir que des lettres, chiffres, points, tirets et underscores, et faire entre 3 et 255 caractères"
};
}
return { isValid: true };
}
// Helper function to create user in Leantime
async function createLeantimeUser(userData: {
username: string;
firstName: string;
lastName: string;
email: string;
password: string;
}): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
method: 'leantime.rpc.Users.Users.addUser',
jsonrpc: '2.0',
id: 1,
params: {
values: {
'0': 0, // This will be set by Leantime
'1': userData.lastName,
'2': userData.firstName,
'3': '20', // Default role
'4': '', // profileId
'5': 'a', // status
'6': userData.email,
'7': 0, // twoFAEnabled
'8': 0, // clientId
'9': null, // clientName
'10': '', // jobTitle
'11': '', // jobLevel
'12': '', // department
'13': new Date().toISOString(), // modified
lastname: userData.lastName,
firstname: userData.firstName,
role: '20', // Default role
profileId: '',
status: 'a',
username: userData.email,
password: userData.password,
twoFAEnabled: 0,
clientId: 0,
clientName: null,
jobTitle: '',
jobLevel: '',
department: '',
modified: new Date().toISOString(),
createdOn: new Date().toISOString(),
source: 'keycloak',
notifications: 1,
settings: '{}'
}
}
})
});
const data = await response.json();
console.log('Leantime response:', data);
if (!response.ok || !data.result) {
console.error('Leantime user creation failed:', data);
return {
success: false,
error: data.error?.message || 'Failed to create user in Leantime'
};
}
return { success: true };
} catch (error) {
console.error('Error creating Leantime user:', error);
return {
success: false,
error: 'Error creating user in Leantime'
};
}
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const data = await req.json();
console.log("Creating user:", data);
// Validate username
const usernameValidation = validateUsername(data.username);
if (!usernameValidation.isValid) {
return NextResponse.json(
{ error: usernameValidation.error },
{ status: 400 }
);
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ error: "Erreur d'authentification" }, { status: 401 });
}
// First, get all available roles from Keycloak
const rolesResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!rolesResponse.ok) {
const errorData = await rolesResponse.json();
console.error("Failed to fetch roles:", errorData);
return NextResponse.json({ error: "Erreur lors de la récupération des rôles" }, { status: rolesResponse.status });
}
const availableRoles = await rolesResponse.json();
console.log("Available roles:", availableRoles);
// Verify that the requested roles exist
const requestedRoles = data.roles || [];
const validRoles = requestedRoles.filter((roleName: string) =>
availableRoles.some((r: any) => r.name === roleName)
);
if (validRoles.length === 0) {
return NextResponse.json(
{ error: "Aucun rôle valide n'a été spécifié" },
{ status: 400 }
);
}
// Create the user in Keycloak
const createResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
username: data.username,
enabled: true,
emailVerified: true,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
credentials: [
{
type: "password",
value: data.password,
temporary: false,
},
],
}),
}
);
console.log("Keycloak create response:", {
status: createResponse.status,
ok: createResponse.ok
});
if (!createResponse.ok) {
const errorData = await createResponse.json();
console.error("Keycloak error:", 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", details: errorData },
{ status: 400 }
);
}
// Get the created user
const userResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users?username=${data.username}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const users = await userResponse.json();
const user = users[0];
if (!user) {
return NextResponse.json(
{ error: "Utilisateur créé mais impossible de le récupérer" },
{ status: 500 }
);
}
// Add roles to the user
const roleObjects = validRoles.map((roleName: string) =>
availableRoles.find((r: any) => r.name === roleName)
);
const roleResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${user.id}/role-mappings/realm`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(roleObjects),
}
);
if (!roleResponse.ok) {
const errorData = await roleResponse.json();
console.error("Failed to add roles:", errorData);
return NextResponse.json(
{ error: "Erreur lors de l'ajout des rôles", details: errorData },
{ status: 500 }
);
}
// Create user in Leantime
const leantimeResult = await createLeantimeUser({
username: data.username,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
password: data.password,
});
if (!leantimeResult.success) {
console.error("Leantime user creation failed:", leantimeResult.error);
// We don't return an error here since Keycloak user was created successfully
// We just log the error and continue
}
return NextResponse.json({
success: true,
user: {
...user,
roles: validRoles,
},
});
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Erreur serveur", details: error },
{ status: 500 }
);
}
}
// Helper function to delete user from Leantime
async function deleteLeantimeUser(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN || '',
},
body: JSON.stringify({
method: 'leantime.rpc.Users.Users.deleteUser',
jsonrpc: '2.0',
id: 1,
params: {
email: email
}
})
});
const data = await response.json();
console.log('Leantime delete response:', data);
if (!response.ok || !data.result) {
console.error('Leantime user deletion failed:', data);
return {
success: false,
error: data.error?.message || 'Failed to delete user in Leantime'
};
}
return { success: true };
} catch (error) {
console.error('Error deleting Leantime user:', error);
return {
success: false,
error: 'Error deleting user in Leantime'
};
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const userId = searchParams.get('id');
const email = searchParams.get('email');
if (!userId || !email) {
return NextResponse.json(
{ error: "ID utilisateur et email requis" },
{ status: 400 }
);
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ error: "Erreur d'authentification" }, { status: 401 });
}
// Delete user from Keycloak
const deleteResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!deleteResponse.ok) {
const errorData = await deleteResponse.json();
console.error("Keycloak delete error:", errorData);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur", details: errorData },
{ status: deleteResponse.status }
);
}
// Delete user from Leantime
const leantimeResult = await deleteLeantimeUser(email);
if (!leantimeResult.success) {
console.error("Leantime user deletion failed:", leantimeResult.error);
// We don't return an error here since Keycloak user was deleted successfully
// We just log the error and continue
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Erreur serveur", details: error },
{ status: 500 }
);
}
}

23
app/calculation/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CALCULATION_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

116
app/calendar/page.tsx Normal file
View File

@ -0,0 +1,116 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { CalendarClient } from "@/components/calendar/calendar-client";
import { Metadata } from "next";
import { CalendarDays, Users, Bookmark, Clock } from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { add } from 'date-fns';
export const metadata: Metadata = {
title: "Enkun - Calendrier | Gestion d'événements professionnelle",
description: "Plateforme avancée pour la gestion de vos rendez-vous, réunions et événements professionnels",
keywords: "calendrier, rendez-vous, événements, gestion du temps, enkun",
};
interface Event {
id: string;
title: string;
description?: string | null;
start: Date;
end: Date;
location?: string | null;
isAllDay: boolean;
type?: string;
attendees?: { id: string; name: string }[];
}
interface Calendar {
id: string;
name: string;
color: string;
description?: string | null;
events: Event[];
}
export default async function CalendarPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/api/auth/signin");
}
const userId = session.user.username || session.user.email || '';
// Get all calendars for the user
let calendars = await prisma.calendar.findMany({
where: {
userId: session?.user?.id || '',
},
include: {
events: {
orderBy: {
start: 'asc'
}
}
}
});
// If no calendars exist, create default ones
if (calendars.length === 0) {
const defaultCalendars = [
{
name: "Default",
color: "#4F46E5",
description: "Your default calendar"
}
];
calendars = await Promise.all(
defaultCalendars.map(async (cal) => {
return prisma.calendar.create({
data: {
...cal,
userId: session?.user?.id || '',
},
include: {
events: true
}
});
})
);
}
const now = new Date();
const nextWeek = add(now, { days: 7 });
const upcomingEvents = calendars.flatMap(cal =>
cal.events.filter(event =>
new Date(event.start) >= now &&
new Date(event.start) <= nextWeek
)
).sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
// Calculate statistics
const totalEvents = calendars.flatMap(cal => cal.events).length;
const totalMeetingHours = calendars
.flatMap(cal => cal.events)
.reduce((total, event) => {
const start = new Date(event.start);
const end = new Date(event.end);
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
return total + (isNaN(hours) ? 0 : hours);
}, 0);
return (
<div className="container mx-auto py-10">
<CalendarClient
initialCalendars={calendars}
userId={session.user.id}
/>
</div>
);
}

23
app/chapter/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

129
app/components/flow.tsx Normal file
View File

@ -0,0 +1,129 @@
'use client';
import { useState, useEffect } from 'react';
interface Task {
id: string;
headline: string;
projectName: string;
projectId: number;
status: number;
dueDate: string | null;
milestone: string | null;
details: string | null;
createdOn: string;
editedOn: string | null;
assignedTo: number[];
}
export default function Flow() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getStatusLabel = (status: number): string => {
switch (status) {
case 1:
return 'New';
case 2:
return 'In Progress';
case 3:
return 'Done';
case 4:
return 'In Progress';
case 5:
return 'Done';
default:
return 'Unknown';
}
};
const getStatusColor = (status: number): string => {
switch (status) {
case 1:
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 2:
case 4:
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 3:
case 5:
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
}
};
useEffect(() => {
const fetchTasks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/leantime/tasks');
if (!response.ok) {
throw new Error('Failed to fetch tasks');
}
const data = await response.json();
if (data.tasks && Array.isArray(data.tasks)) {
// Sort tasks by creation date (oldest first)
const sortedTasks = data.tasks.sort((a: Task, b: Task) => {
const dateA = new Date(a.createdOn).getTime();
const dateB = new Date(b.createdOn).getTime();
return dateA - dateB;
});
setTasks(sortedTasks);
} else {
console.error('Invalid tasks data format:', data);
setError('Invalid tasks data format');
}
} catch (err) {
console.error('Error fetching tasks:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchTasks();
}, []);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
if (tasks.length === 0) {
return <div>No tasks found</div>;
}
return (
<div className="grid grid-cols-1 gap-4">
{tasks.map((task) => (
<div
key={task.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{task.headline}
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{task.projectName}
</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
task.status
)}`}
>
{getStatusLabel(task.status)}
</span>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { useEffect, useRef } from 'react';
interface ResponsiveIframeProps {
src: string;
className?: string;
allow?: string;
style?: React.CSSProperties;
}
export function ResponsiveIframe({ src, className = '', allow, style }: ResponsiveIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const calculateHeight = () => {
const pageY = (elem: HTMLElement): number => {
return elem.offsetParent ?
(elem.offsetTop + pageY(elem.offsetParent as HTMLElement)) :
elem.offsetTop;
};
const height = document.documentElement.clientHeight;
const iframeY = pageY(iframe);
const newHeight = Math.max(0, height - iframeY);
iframe.style.height = `${newHeight}px`;
};
const handleHashChange = () => {
if (window.location.hash && window.location.hash.length) {
const iframeURL = new URL(iframe.src);
iframeURL.hash = window.location.hash;
iframe.src = iframeURL.toString();
}
};
// Initial setup
calculateHeight();
handleHashChange();
// Event listeners
window.addEventListener('resize', calculateHeight);
window.addEventListener('hashchange', handleHashChange);
iframe.addEventListener('load', calculateHeight);
// Cleanup
return () => {
window.removeEventListener('resize', calculateHeight);
window.removeEventListener('hashchange', handleHashChange);
iframe.removeEventListener('load', calculateHeight);
};
}, []);
return (
<iframe
ref={iframeRef}
id="myFrame"
src={src}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
);
}

23
app/conference/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

30
app/contacts/page.tsx Normal file
View File

@ -0,0 +1,30 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_CONTACTS_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
className="relative"
style={{
marginTop: '-64px',
marginLeft: '-180px',
width: 'calc(100% + 500px)',
right: '-160px'
}}
/>
</div>
</main>
);
}

23
app/crm/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

24
app/design/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_ARTLAB_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

30
app/diary/page.tsx Normal file
View File

@ -0,0 +1,30 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_DIARY_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
className="relative"
style={{
marginTop: '-64px',
marginLeft: '-180px',
width: 'calc(100% + 500px)',
right: '-160px'
}}
/>
</div>
</main>
);
}

30
app/drive/page.tsx Normal file
View File

@ -0,0 +1,30 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
className="relative"
style={{
marginTop: '-64px',
marginLeft: '-180px',
width: 'calc(100% + 500px)',
right: '-160px'
}}
/>
</div>
</main>
);
}

23
app/email/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_MAIL_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

23
app/flow/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

23
app/gite/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_GITE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

76
app/globals.css Normal file
View File

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

24
app/groups/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { GroupsTable } from "@/components/groups/groups-table";
export const metadata = {
title: "Enkun - Groupes",
};
export default async function GroupsPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<div className='min-h-screen bg-black'>
<div className='container mx-auto py-10'>
<GroupsTable userRole={session.user.role || []} />
</div>
</div>
);
}

36
app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { headers } from "next/headers";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { Providers } from "@/components/providers";
import { LayoutWrapper } from "@/components/layout/layout-wrapper";
const inter = Inter({ subsets: ["latin"] });
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const headersList = headers();
const pathname = headersList.get("x-pathname") || "";
const isSignInPage = pathname === "/signin";
return (
<html lang="fr">
<body className={inter.className}>
<Providers>
<LayoutWrapper
isSignInPage={isSignInPage}
isAuthenticated={!!session}
>
{children}
</LayoutWrapper>
</Providers>
</body>
</html>
);
}

23
app/learn/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_LEARN_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

148
app/mail/login/page.tsx Normal file
View File

@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { setCookie } from 'cookies-next';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [host, setHost] = useState('mail.infomaniak.com');
const [port, setPort] = useState('993');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
// Test the connection first
const testResponse = await fetch('/api/mail/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
host,
port,
}),
});
const testData = await testResponse.json();
if (!testResponse.ok) {
throw new Error(testData.error || 'Failed to connect to email server');
}
// Store all credentials in a single cookie
const credentials = {
email,
password,
host,
port: parseInt(port),
};
console.log('Storing credentials in cookie:', {
...credentials,
password: '***'
});
// Store as a single cookie with proper options
setCookie('imap_credentials', JSON.stringify(credentials), {
maxAge: 60 * 60 * 24, // 1 day
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: false // Allow access from JavaScript
});
// Verify cookie was set
const stored = document.cookie.split(';').find(c => c.trim().startsWith('imap_credentials='));
console.log('Cookie verification:', stored ? 'Cookie found' : 'Cookie not found');
if (!stored) {
throw new Error('Failed to store credentials');
}
// Redirect to mail page
router.push('/mail');
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Email Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="host">IMAP Host</Label>
<Input
id="host"
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="port">IMAP Port</Label>
<Input
id="port"
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? 'Connecting...' : 'Connect'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

1879
app/mail/page.tsx Normal file

File diff suppressed because it is too large Load Diff

24
app/management/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ManagementTabs } from "@/components/management/management-tabs";
export const metadata = {
title: "Enkun",
};
export default async function ManagementPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<div className='min-h-screen bg-black'>
<div className='container mx-auto py-10'>
<ManagementTabs userRole={session.user.role || []} />
</div>
</div>
);
}

27
app/mediations/page.tsx Normal file
View File

@ -0,0 +1,27 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full pt-12">
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{
marginTop: '-64px'
}}
allowFullScreen
/>
</div>
</main>
);
}

23
app/mission-view/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

View File

@ -0,0 +1,13 @@
export default function NotificationsPage() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src="https://example.com/notifications"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)
}

14
app/observatory/page.tsx Normal file
View File

@ -0,0 +1,14 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ObservatoryFrame } from "@/components/observatory/observatory-frame";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return <ObservatoryFrame />;
}

42
app/page.tsx Normal file
View File

@ -0,0 +1,42 @@
"use client";
import { QuoteCard } from "@/components/quote-card";
import { Calendar } from "@/components/calendar";
import { News } from "@/components/news";
import { Duties } from "@/components/flow";
import { Email } from "@/components/email";
import { Parole } from "@/components/parole";
export default function Home() {
return (
<main className="h-screen overflow-auto">
<div className="container mx-auto p-4 mt-12">
{/* First row */}
<div className="grid grid-cols-12 gap-4 mb-4">
<div className="col-span-3">
<QuoteCard />
</div>
<div className="col-span-3">
<Calendar />
</div>
<div className="col-span-3">
<News />
</div>
<div className="col-span-3">
<Duties />
</div>
</div>
{/* Second row */}
<div className="grid grid-cols-12 gap-4">
<div className="col-span-6">
<Email />
</div>
<div className="col-span-6">
<Parole />
</div>
</div>
</div>
</main>
);
}

23
app/parole/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

23
app/radio/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_RADIO_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

23
app/showcase/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

7
app/signin/layout.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function SignInLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

31
app/signin/page.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Metadata } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { SignInForm } from "@/components/auth/signin-form";
export const metadata: Metadata = {
title: "Enkun - Connexion",
};
export default async function SignIn() {
const session = await getServerSession(authOptions);
if (session) {
redirect("/");
}
return (
<div
className="min-h-screen flex items-center justify-center"
style={{
backgroundImage: "url('/signin.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
<SignInForm />
</div>
);
}

23
app/the-message/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return (
<main className="w-full h-screen bg-black">
<div className="w-full h-full px-4 pt-12 pb-4">
<ResponsiveIframe
src={process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || ''}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</main>
);
}

14
app/timetracker/page.tsx Normal file
View File

@ -0,0 +1,14 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { TimeTrackerFrame } from "@/components/timetracker/timetracker-frame";
export default async function Page() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return <TimeTrackerFrame />;
}

24
app/users/page.tsx Normal file
View File

@ -0,0 +1,24 @@
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");
}
console.log("Page session:", session); // Debug log
return (
<div className='container mx-auto py-10'>
<UsersTable userRole={session.user.role || []} />
</div>
);
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,14 @@
"use client";
export function AnnouncementFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_ANNOUNCEMENT_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
export function AuthCheck({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (status === "unauthenticated" && pathname !== "/signin") {
router.push("/signin");
}
}, [status, router, pathname]);
if (status === "loading") {
return <div>Chargement...</div>;
}
if (status === "unauthenticated" && pathname !== "/signin") {
return null;
}
return <>{children}</>;
}

View File

@ -0,0 +1,32 @@
"use client";
import { signIn } from "next-auth/react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function LoginCard() {
return (
<Card className='w-[400px]'>
<CardHeader>
<CardTitle>Bienvenue sur Enkun</CardTitle>
<CardDescription>
Connectez-vous pour accéder à votre espace
</CardDescription>
</CardHeader>
<CardContent>
<Button
className='w-full'
onClick={() => signIn("keycloak", { callbackUrl: "/" })}
>
Se connecter avec Keycloak
</Button>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,18 @@
"use client";
import { signIn } from "next-auth/react";
export function SignInForm() {
return (
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">Bienvenue sur Enkun</h1>
<p className="text-white/80 mb-8">Connectez-vous pour accéder à votre espace</p>
<button
onClick={() => signIn("keycloak", { callbackUrl: "/" })}
className="px-8 py-3 bg-[#0F172A] text-white rounded hover:bg-[#1E293B] transition-colors"
>
Commit
</button>
</div>
);
}

View File

@ -0,0 +1,156 @@
"use client";
import { useState, useEffect } from "react";
const backgroundImages = [
"/background/Autumn birger-strahl-6YZgnYaPD5s-unsplash.jpeg",
"/background/Moneral tobias-reich-VltYe88rkt8-unsplash.jpeg",
"/background/aaron-burden-cGW1w-qLix8-unsplash.jpg",
"/background/aaron-burden-xtIYGB0KEqc-unsplash.jpg",
"/background/art credit library-of-congress-ULl31hxiehE-unsplash.jpeg",
"/background/art-institute-of-chicago-fayEVJ03T7M-unsplash.jpg",
"/background/art-institute-of-chicago-j-3IgXK3iJg-unsplash.jpg",
"/background/art-institute-of-chicago-ueWnHtoaplI-unsplash.jpg",
"/background/art-institute-of-chicago-yIgLfU6EEBw-unsplash.jpg",
"/background/birmingham-museums-trust-M9ryRhN4YSI-unsplash.jpg",
"/background/david-ramirez-LC7lLC9jDzw-unsplash.jpg",
"/background/europeana-HDIOpM_XXbI-unsplash.jpg",
"/background/gabor-juhasz-B1Zyw7sdm5w-unsplash.jpg",
"/background/gonzalo-mendiola-XXCrAQgQnVw-unsplash.jpg",
"/background/ian-keefe-OgcJIKRnRC8-unsplash.jpg",
"/background/japan credit dale-scogings-_SBsVi4kmkY-unsplash.jpeg",
"/background/japan credit david-edelstein-N4DbvTUDikw-unsplash.jpeg",
"/background/japan credit falco-negenman-K8MMfFifWcE-unsplash.jpeg",
"/background/japan credit galen-crout-0_xMuEbpFAQ-unsplash.jpeg",
"/background/japan credit gilly-cLnFkSji734-unsplash.jpeg",
"/background/japan credit matthew-buchanan-VVi59Xtsd8Y-unsplash.jpeg",
"/background/japan credit redd-f-Bxzrd0p6yOM-unsplash.jpeg",
"/background/japan credit redd-f-wPMvPMD9KBI-unsplash.jpeg",
"/background/japan credit sorasak-_UIN-pFfJ7c-unsplash.jpeg",
"/background/japan credittianshu-liu-SBK40fdKbAg-unsplash.jpeg",
"/background/japan.jpeg",
"/background/joel-holland-TRhGEGdw-YY-unsplash.jpg",
"/background/marko-blazevic-S7mAngnWV1A-unsplash.jpg",
"/background/museum-of-new-zealand-te-papa-tongarewa-h2qlQSm7N-0-unsplash.jpg",
"/background/redd-f-Lm5rkxzgiFQ-unsplash.jpg",
"/background/spencer-davis-ONVA6s03hg8-unsplash.jpg",
"/background/summer credit spencer-everett-DdVOCPTofFc-unsplash.jpeg",
"/background/summer.jpeg",
"/background/sylvain-mauroux-jYCUBAIUsk8-unsplash.jpg",
"/background/the-cleveland-museum-of-art-6uIO1CNv3Vc-unsplash.jpg",
"/background/the-cleveland-museum-of-art-Tl9uudd4DOE-unsplash.jpg",
"/background/the-cleveland-museum-of-art-WQOzF8TSnRQ-unsplash.jpg",
"/background/tingfeng-xia-WwKrhith4l4-unsplash.jpg",
"/background/vegetal credit yuya-murakami-VkcD1QxtY4A-unsplash.jpeg",
"/background/vegetal ryunosuke-kikuno-U8_eaHSUwdw-unsplash.jpeg"
];
export function useBackgroundImage() {
const [currentBackground, setCurrentBackground] = useState(backgroundImages[0]);
const changeBackground = () => {
const currentIndex = backgroundImages.indexOf(currentBackground);
const nextIndex = (currentIndex + 1) % backgroundImages.length;
setCurrentBackground(backgroundImages[nextIndex]);
};
useEffect(() => {
// Set initial random background
const randomIndex = Math.floor(Math.random() * backgroundImages.length);
setCurrentBackground(backgroundImages[randomIndex]);
}, []);
return { currentBackground, changeBackground };
}
export function BackgroundSwitcher({ children }: { children: React.ReactNode }) {
const [background, setBackground] = useState("");
const [imageError, setImageError] = useState(false);
// Function to preload an image
const preloadImage = (src: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => resolve(src);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
});
};
const getRandomBackground = async () => {
let attempts = 0;
const maxAttempts = backgroundImages.length;
while (attempts < maxAttempts) {
try {
const randomIndex = Math.floor(Math.random() * backgroundImages.length);
const newBackground = backgroundImages[randomIndex];
if (newBackground !== background) {
// Try to preload the image
await preloadImage(newBackground);
console.log("Successfully loaded:", newBackground);
return newBackground;
}
} catch (error) {
console.error("Failed to load image:", error);
}
attempts++;
}
// If all attempts fail, return the first image as fallback
return backgroundImages[0];
};
useEffect(() => {
const initBackground = async () => {
try {
const newBg = await getRandomBackground();
setBackground(newBg);
setImageError(false);
} catch (error) {
console.error("Error setting initial background:", error);
setImageError(true);
}
};
initBackground();
}, []);
const handleClick = async (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
try {
const newBg = await getRandomBackground();
console.log("Changing background to:", newBg);
setBackground(newBg);
setImageError(false);
} catch (error) {
console.error("Error changing background:", error);
setImageError(true);
}
}
};
return (
<div
className="min-h-screen relative"
onClick={handleClick}
>
{/* Background Image */}
<div
className="fixed inset-0 z-0 transition-opacity duration-500"
style={{
backgroundImage: `url(${background})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: imageError ? 0 : 0.3
}}
/>
{/* Content */}
<div className="relative z-10">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,195 @@
"use client";
import { useState, useEffect } from "react";
import { format, isToday, isTomorrow, addDays } from "date-fns";
import { fr } from "date-fns/locale";
import { CalendarIcon, ClockIcon, ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { useSession } from "next-auth/react";
type Event = {
id: string;
title: string;
start: string;
end: string;
isAllDay: boolean;
calendarId: string;
calendarName?: string;
calendarColor?: string;
};
export function CalendarWidget() {
const { data: session } = useSession();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Ne charger les événements que si l'utilisateur est connecté
if (!session) return;
const fetchUpcomingEvents = async () => {
try {
setLoading(true);
// Récupérer d'abord les calendriers de l'utilisateur
const calendarsRes = await fetch("/api/calendars");
if (!calendarsRes.ok) {
throw new Error("Impossible de charger les calendriers");
}
const calendars = await calendarsRes.json();
if (calendars.length === 0) {
setEvents([]);
setLoading(false);
return;
}
// Date actuelle et date dans 7 jours
const now = new Date();
// @ts-ignore
const nextWeek = addDays(now, 7);
// Récupérer les événements pour chaque calendrier
const allEventsPromises = calendars.map(async (calendar: any) => {
const eventsRes = await fetch(
`/api/calendars/${
calendar.id
}/events?start=${now.toISOString()}&end=${nextWeek.toISOString()}`
);
if (!eventsRes.ok) {
console.warn(
`Impossible de charger les événements du calendrier ${calendar.id}`
);
return [];
}
const events = await eventsRes.json();
// Ajouter les informations du calendrier à chaque événement
return events.map((event: any) => ({
...event,
calendarName: calendar.name,
calendarColor: calendar.color,
}));
});
// Attendre toutes les requêtes d'événements
const allEventsArrays = await Promise.all(allEventsPromises);
// Fusionner tous les événements en un seul tableau
const allEvents = allEventsArrays.flat();
// Trier par date de début
const sortedEvents = allEvents.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
// Limiter à 5 événements
setEvents(sortedEvents.slice(0, 5));
} catch (err) {
console.error("Erreur lors du chargement des événements:", err);
setError("Impossible de charger les événements à venir");
} finally {
setLoading(false);
}
};
fetchUpcomingEvents();
}, [session]);
// Formater la date d'un événement pour l'affichage
const formatEventDate = (date: string, isAllDay: boolean) => {
const eventDate = new Date(date);
let dateString = "";
// @ts-ignore
if (isToday(eventDate)) {
dateString = "Aujourd'hui";
// @ts-ignore
} else if (isTomorrow(eventDate)) {
dateString = "Demain";
} else {
// @ts-ignore
dateString = format(eventDate, "EEEE d MMMM", { locale: fr });
}
if (!isAllDay) {
// @ts-ignore
dateString += ` · ${format(eventDate, "HH:mm", { locale: fr })}`;
}
return dateString;
};
return (
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<CardTitle className='text-lg font-medium'>
Événements à venir
</CardTitle>
<Link href='/calendar' passHref>
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
<ChevronRight className='h-4 w-4' />
<span className='sr-only'>Voir le calendrier</span>
</Button>
</Link>
</CardHeader>
<CardContent className='pb-3'>
{loading ? (
<div className='flex items-center justify-center py-4'>
<div className='h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
<span className='ml-2 text-sm text-muted-foreground'>
Chargement...
</span>
</div>
) : error ? (
<p className='text-sm text-red-500'>{error}</p>
) : events.length === 0 ? (
<p className='text-sm text-muted-foreground py-2'>
Aucun événement à venir cette semaine
</p>
) : (
<div className='space-y-3'>
{events.map((event) => (
<div
key={event.id}
className='flex items-start space-x-3 rounded-md border border-muted p-2'
>
<div
className='h-3 w-3 flex-shrink-0 rounded-full mt-1'
style={{ backgroundColor: event.calendarColor || "#0082c9" }}
/>
<div className='flex-1 min-w-0'>
<h5
className='text-sm font-medium truncate'
title={event.title}
>
{event.title}
</h5>
<div className='flex items-center text-xs text-muted-foreground mt-1'>
<CalendarIcon className='h-3 w-3 mr-1' />
<span>{formatEventDate(event.start, event.isAllDay)}</span>
</div>
</div>
</div>
))}
<Link href='/calendar' passHref>
<Button
size='sm'
className='w-full transition-all ease-in-out duration-500 bg-muted text-black hover:text-white hover:bg-primary'
>
Voir tous les événements
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
);
}

172
components/calendar.tsx Normal file
View File

@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Calendar as CalendarIcon } from "lucide-react";
import { useRouter } from "next/navigation";
interface Event {
id: string;
title: string;
start: string;
end: string;
allDay: boolean;
calendar: string;
calendarColor: string;
}
export function Calendar() {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const fetchEvents = async () => {
setLoading(true);
try {
const response = await fetch('/api/calendars');
if (!response.ok) {
throw new Error('Failed to fetch events');
}
const calendarsData = await response.json();
console.log('Calendar Widget - Fetched calendars:', calendarsData);
// Get current date at the start of the day
const now = new Date();
now.setHours(0, 0, 0, 0);
// Extract and process events from all calendars
const allEvents = calendarsData.flatMap((calendar: any) =>
(calendar.events || []).map((event: any) => ({
id: event.id,
title: event.title,
start: event.start,
end: event.end,
allDay: event.isAllDay,
calendar: calendar.name,
calendarColor: calendar.color
}))
);
// Filter for upcoming events
const upcomingEvents = allEvents
.filter((event: any) => new Date(event.start) >= now)
.sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime())
.slice(0, 7);
console.log('Calendar Widget - Processed events:', upcomingEvents);
setEvents(upcomingEvents);
setError(null);
} catch (err) {
console.error('Error fetching events:', err);
setError('Failed to load events');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEvents();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short'
}).format(date);
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<CalendarIcon className="h-5 w-5 text-gray-600" />
Agenda
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchEvents()}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
</Button>
</CardHeader>
<CardContent className="p-3">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : error ? (
<div className="text-xs text-red-500 text-center py-3">{error}</div>
) : events.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-6">No upcoming events</div>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
{events.map((event) => (
<div
key={event.id}
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
>
<div className="flex gap-2">
<div
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
style={{
backgroundColor: `${event.calendarColor}10`,
borderColor: event.calendarColor
}}
>
<span
className="text-[10px] font-medium"
style={{ color: event.calendarColor }}
>
{formatDate(event.start)}
</span>
<span
className="text-[10px] font-bold mt-0.5"
style={{ color: event.calendarColor }}
>
{formatTime(event.start)}
</span>
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
{event.title}
</p>
{!event.allDay && (
<span className="text-[10px] text-gray-500 whitespace-nowrap">
{formatTime(event.start)} - {formatTime(event.end)}
</span>
)}
</div>
<div
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
style={{
backgroundColor: `${event.calendarColor}10`,
color: event.calendarColor
}}
>
<span className="truncate">{event.calendar}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Calendar } from "@prisma/client";
interface CalendarDialogProps {
open: boolean;
onClose: () => void;
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
}
export function CalendarDialog({ open, onClose, onSave }: CalendarDialogProps) {
const [name, setName] = useState("");
const [color, setColor] = useState("#0082c9");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
try {
await onSave({ name, color, description });
resetForm();
} catch (error) {
console.error("Erreur lors de la création du calendrier:", error);
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
setName("");
setColor("#0082c9");
setDescription("");
onClose();
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Créer un nouveau calendrier</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className='space-y-4 py-2'>
<div className='space-y-2'>
<Label htmlFor='calendar-name'>Nom</Label>
<Input
id='calendar-name'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Nom du calendrier'
required
/>
</div>
<div className='space-y-2'>
<Label htmlFor='calendar-color'>Couleur</Label>
<div className='flex items-center gap-4'>
<Input
id='calendar-color'
type='color'
value={color}
onChange={(e) => setColor(e.target.value)}
className='w-12 h-12 p-1 cursor-pointer'
/>
<span className='text-sm font-medium'>{color}</span>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='calendar-description'>
Description (optionnelle)
</Label>
<Textarea
id='calendar-description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='Description du calendrier'
rows={3}
/>
</div>
</div>
<DialogFooter className='mt-4'>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={isSubmitting}
>
Annuler
</Button>
<Button type='submit' disabled={!name || isSubmitting}>
{isSubmitting ? "Création..." : "Créer"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { format, isToday, isTomorrow, addDays } from "date-fns";
import { fr } from "date-fns/locale";
import { CalendarIcon, ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { useSession } from "next-auth/react";
type Event = {
id: string;
title: string;
start: Date;
end: Date;
isAllDay: boolean;
calendarId: string;
calendarName?: string;
calendarColor?: string;
};
export function CalendarWidget() {
const { data: session, status } = useSession();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
console.log("Calendar Widget - Session Status:", status);
console.log("Calendar Widget - Session Data:", session);
if (status === "loading") {
console.log("Calendar Widget - Session is loading");
return;
}
if (status !== "authenticated" || !session) {
console.log("Calendar Widget - Not authenticated, skipping fetch");
setLoading(false);
return;
}
const fetchUpcomingEvents = async () => {
try {
console.log("Calendar Widget - Starting to fetch events");
setLoading(true);
// Fetch calendars with events
console.log("Calendar Widget - Making API request to /api/calendars");
const response = await fetch('/api/calendars');
if (!response.ok) {
console.error("Calendar Widget - API response not OK:", response.status, response.statusText);
throw new Error("Impossible de charger les événements");
}
const calendarsData = await response.json();
console.log("Calendar Widget - Raw calendars data:", calendarsData);
if (!Array.isArray(calendarsData)) {
console.error("Calendar Widget - Calendars data is not an array:", calendarsData);
throw new Error("Format de données invalide");
}
// Get current date at the start of the day
const now = new Date();
now.setHours(0, 0, 0, 0);
// Extract all events and add calendar info
const allEvents = calendarsData.flatMap((calendar) => {
console.log("Calendar Widget - Processing calendar:", calendar.name, "Events:", calendar.events?.length || 0);
return (calendar.events || []).map((event) => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
return {
id: event.id,
title: event.title,
start: startDate,
end: endDate,
isAllDay: event.isAllDay,
calendarId: event.calendarId,
calendarColor: calendar.color,
calendarName: calendar.name
};
});
});
// Filter for upcoming events (today and future)
const upcomingEvents = allEvents
.filter(event => event.start >= now)
.sort((a, b) => a.start.getTime() - b.start.getTime())
.slice(0, 5);
console.log("Calendar Widget - Final upcoming events:", upcomingEvents);
setEvents(upcomingEvents);
setError(null);
} catch (err) {
console.error("Calendar Widget - Error in fetchUpcomingEvents:", err);
setError("Impossible de charger les événements à venir");
} finally {
setLoading(false);
}
};
// Initial fetch
fetchUpcomingEvents();
// Set up an interval to refresh events every 5 minutes
const intervalId = setInterval(fetchUpcomingEvents, 300000);
return () => clearInterval(intervalId);
}, [session, status]);
const formatEventDate = (date: Date, isAllDay: boolean) => {
let dateString = "";
if (isToday(date)) {
dateString = "Aujourd'hui";
} else if (isTomorrow(date)) {
dateString = "Demain";
} else {
dateString = format(date, "EEEE d MMMM", { locale: fr });
}
if (!isAllDay) {
dateString += ` · ${format(date, "HH:mm", { locale: fr })}`;
}
return dateString;
};
return (
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<CardTitle className='text-lg font-medium'>
Événements à venir
</CardTitle>
<Link href='/calendar' passHref>
<Button variant='ghost' size='sm' className='h-8 w-8 p-0'>
<ChevronRight className='h-4 w-4' />
<span className='sr-only'>Voir le calendrier</span>
</Button>
</Link>
</CardHeader>
<CardContent className='pb-3'>
{loading ? (
<div className='flex items-center justify-center py-4'>
<div className='h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
<span className='ml-2 text-sm text-muted-foreground'>
Chargement...
</span>
</div>
) : error ? (
<p className='text-sm text-red-500'>{error}</p>
) : events.length === 0 ? (
<p className='text-sm text-muted-foreground py-2'>
Aucun événement à venir cette semaine
</p>
) : (
<div className='space-y-3'>
{events.map((event) => (
<div
key={event.id}
className='flex items-start space-x-3 rounded-md border border-muted p-2'
>
<div
className='h-3 w-3 flex-shrink-0 rounded-full mt-1'
style={{ backgroundColor: event.calendarColor || "#0082c9" }}
/>
<div className='flex-1 min-w-0'>
<h5
className='text-sm font-medium truncate'
title={event.title}
>
{event.title}
</h5>
<div className='flex items-center text-xs text-muted-foreground mt-1'>
<CalendarIcon className='h-3 w-3 mr-1' />
<span>{formatEventDate(event.start, event.isAllDay)}</span>
</div>
</div>
</div>
))}
<Link href='/calendar' passHref>
<Button
size='sm'
className='w-full transition-all ease-in-out duration-500 bg-muted text-black hover:text-white hover:bg-primary'
>
Voir tous les événements
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,261 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { format, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Calendar as CalendarType } from "@prisma/client";
interface EventDialogProps {
open: boolean;
event?: any;
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string) => Promise<void>;
calendars: CalendarType[];
}
export function EventDialog({
open,
event,
onClose,
onSave,
onDelete,
calendars,
}: EventDialogProps) {
const [title, setTitle] = useState(event?.title || "");
const [description, setDescription] = useState(event?.description || "");
const [location, setLocation] = useState(event?.location || "");
const [start, setStart] = useState(event?.start || "");
const [end, setEnd] = useState(event?.end || "");
const [allDay, setAllDay] = useState(event?.allDay || false);
const [calendarId, setCalendarId] = useState(event?.calendarId || "");
const [confirmDelete, setConfirmDelete] = useState(false);
// Formater les dates pour l'affichage
const formatDate = (dateStr: string) => {
if (!dateStr) return "";
try {
// @ts-ignore
const date = parseISO(dateStr);
// @ts-ignore
return format(date, allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm", {
// @ts-ignore
locale: fr,
});
} catch (e) {
return dateStr;
}
};
// Gérer le changement de l'option "Toute la journée"
const handleAllDayChange = (checked: boolean) => {
setAllDay(checked);
// Ajuster les dates si nécessaire
if (checked && start) {
// @ts-ignore
const startDate = parseISO(start);
// @ts-ignore
setStart(format(startDate, "yyyy-MM-dd"));
if (end) {
// @ts-ignore
const endDate = parseISO(end);
// @ts-ignore
setEnd(format(endDate, "yyyy-MM-dd"));
}
}
};
// Enregistrer l'événement
const handleSave = () => {
onSave({
id: event?.id,
title,
description,
location,
start,
end,
calendarId,
isAllDay: allDay,
});
};
// Supprimer l'événement
const handleDelete = () => {
if (onDelete && event?.id) {
onDelete(event.id);
}
};
return (
<>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className='sm:max-w-[550px]'>
<DialogHeader>
<DialogTitle>
{event?.id ? "Modifier l'événement" : "Nouvel événement"}
</DialogTitle>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='title'>Titre *</Label>
<Input
id='title'
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder='Ajouter un titre'
required
/>
</div>
{/* Sélection du calendrier */}
<div className='grid gap-2'>
<Label htmlFor='calendar'>Calendrier *</Label>
<Select value={calendarId} onValueChange={setCalendarId} required>
<SelectTrigger>
<SelectValue placeholder='Sélectionner un calendrier' />
</SelectTrigger>
<SelectContent>
{calendars.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
<div className='flex items-center gap-2'>
<div
className='w-3 h-3 rounded-full'
style={{ backgroundColor: calendar.color }}
/>
<span>{calendar.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start-date'>Début *</Label>
<Input
type={allDay ? "date" : "datetime-local"}
id='start-date'
value={formatDate(start)}
onChange={(e) => setStart(e.target.value)}
required
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end-date'>Fin *</Label>
<Input
type={allDay ? "date" : "datetime-local"}
id='end-date'
value={formatDate(end)}
onChange={(e) => setEnd(e.target.value)}
required
/>
</div>
</div>
<div className='flex items-center gap-2'>
<Checkbox
id='all-day'
checked={allDay}
onCheckedChange={handleAllDayChange}
/>
<Label htmlFor='all-day'>Toute la journée</Label>
</div>
<div className='grid gap-2'>
<Label htmlFor='location'>Lieu (optionnel)</Label>
<Input
id='location'
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder='Ajouter un lieu'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='description'>Description (optionnel)</Label>
<Textarea
id='description'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='Ajouter une description'
rows={3}
/>
</div>
</div>
<DialogFooter>
{event?.id && onDelete && (
<Button
variant='destructive'
onClick={() => setConfirmDelete(true)}
type='button'
>
Supprimer
</Button>
)}
<Button variant='outline' onClick={onClose} type='button'>
Annuler
</Button>
<Button
onClick={handleSave}
disabled={!title || !start || !end || !calendarId}
type='button'
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer l'événement</AlertDialogTitle>
<AlertDialogDescription>
Êtes-vous sûr de vouloir supprimer cet événement ? Cette action
est irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
export function ConferenceFrame() {
const [error, setError] = useState(false);
return (
<div className="w-full h-[calc(100vh-8rem)]">
{error ? (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-800">Unable to load Conference</h2>
<p className="text-gray-600 mt-2">Please check your connection or try again later</p>
<button
onClick={() => setError(false)}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Retry
</button>
</div>
</div>
) : (
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
onError={() => setError(true)}
/>
)}
</div>
);
}

213
components/email.tsx Normal file
View File

@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Mail } from "lucide-react";
import { useSession, signIn } from "next-auth/react";
import { formatDistance } from 'date-fns/formatDistance';
import { fr } from 'date-fns/locale/fr';
import { useRouter } from "next/navigation";
interface Email {
id: string;
subject: string;
from: string;
fromName?: string;
date: string;
read: boolean;
starred: boolean;
folder: string;
}
interface EmailResponse {
emails: Email[];
mailUrl: string;
error?: string;
}
export function Email() {
const [emails, setEmails] = useState<Email[]>([]);
const [mailUrl, setMailUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const { status } = useSession();
const router = useRouter();
const fetchEmails = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try {
console.log('Starting fetch request...');
const response = await fetch('/api/mail');
console.log('Response status:', response.status);
if (!response.ok) {
if (response.status === 401) {
signIn();
return;
}
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch emails');
}
const data = await response.json();
console.log('Parsed API Response:', data);
if (data.error) {
throw new Error(data.error);
}
const validatedEmails = data.emails.map((email: any) => ({
id: email.id || Date.now().toString(),
subject: email.subject || '(No subject)',
from: email.from || '',
fromName: email.fromName || email.from?.split('@')[0] || 'Unknown',
date: email.date || new Date().toISOString(),
read: !!email.read,
starred: !!email.starred,
folder: email.folder || 'INBOX'
}));
console.log('Processed emails:', validatedEmails);
setEmails(validatedEmails);
setMailUrl(data.mailUrl || 'https://espace.slm-lab.net/apps/mail/');
setError(null);
} catch (err) {
console.error('Fetch error:', err);
setError(err instanceof Error ? err.message : 'Error fetching emails');
setEmails([]);
} finally {
setLoading(false);
setRefreshing(false);
}
};
// Helper functions to parse email addresses
const extractSenderName = (from: string): string => {
if (!from) return 'Unknown';
const match = from.match(/^([^<]+)?/);
if (match && match[1]) {
return match[1].trim().replace(/"/g, '');
}
return from;
};
const extractEmailAddress = (from: string): string => {
if (!from) return '';
const match = from.match(/<([^>]+)>/);
if (match && match[1]) {
return match[1];
}
return from;
};
// Initial fetch
useEffect(() => {
if (status === 'authenticated') {
fetchEmails();
}
}, [status]);
// Auto-refresh every 5 minutes
useEffect(() => {
if (status !== 'authenticated') return;
const interval = setInterval(() => {
fetchEmails(true);
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [status]);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
locale: fr
});
} catch (err) {
console.error('Error formatting date:', err);
return dateString;
}
};
if (status === 'loading' || loading) {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-600" />
<span>Mail</span>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-x-4 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-600" />
<span>Mail</span>
</div>
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchEmails(true)}
disabled={refreshing}
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-3">
{error ? (
<p className="text-center text-red-500">{error}</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{emails.length === 0 ? (
<p className="text-center text-gray-500">
{loading ? 'Loading emails...' : 'No unread emails'}
</p>
) : (
emails.map((email) => (
<div
key={email.id}
className="p-2 hover:bg-gray-50/50 rounded-lg transition-colors cursor-pointer"
onClick={() => router.push('/mail')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600 truncate max-w-[60%]" title={email.fromName || email.from}>
{email.fromName || email.from}
</span>
<div className="flex items-center space-x-2">
{!email.read && <span className="w-1.5 h-1.5 bg-blue-600 rounded-full"></span>}
<span className="text-xs text-gray-500">{formatDate(email.date)}</span>
</div>
</div>
<h3 className="text-sm font-semibold text-gray-800 line-clamp-2" title={email.subject}>
{email.subject}
</h3>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);
}

10
components/emails.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, MessageSquare } from "lucide-react";
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
Emails non lus
</CardTitle>
</CardHeader>

276
components/flow.tsx Normal file
View File

@ -0,0 +1,276 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Share2, Folder } from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface Task {
id: number;
headline: string;
description: string;
dateToFinish: string | null;
projectId: number;
projectName: string;
status: number;
editorId?: string;
editorFirstname?: string;
editorLastname?: string;
authorFirstname: string;
authorLastname: string;
milestoneHeadline?: string;
editTo?: string;
editFrom?: string;
type?: string;
dependingTicketId?: number | null;
}
interface ProjectSummary {
name: string;
tasks: {
status: number;
count: number;
}[];
}
interface TaskWithDate extends Task {
validDate?: Date;
}
export function Duties() {
const [tasks, setTasks] = useState<TaskWithDate[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const getStatusLabel = (status: number): string => {
switch (status) {
case 1: return 'New';
case 2: return 'Blocked';
case 3: return 'In Progress';
case 4: return 'Waiting for Approval';
case 5: return 'Done';
default: return 'Unknown';
}
};
const getStatusColor = (status: number): string => {
switch (status) {
case 1: return 'bg-blue-500'; // New - blue
case 2: return 'bg-red-500'; // Blocked - red
case 3: return 'bg-yellow-500'; // In Progress - yellow
case 4: return 'bg-purple-500'; // Waiting for Approval - purple
case 5: return 'bg-gray-500'; // Done - gray
default: return 'bg-gray-300';
}
};
const formatDate = (dateStr: string): string => {
if (!dateStr || dateStr === '0000-00-00 00:00:00') return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return '';
}
};
const getValidDate = (task: Task): string | null => {
if (task.dateToFinish && task.dateToFinish !== '0000-00-00 00:00:00') {
return task.dateToFinish;
}
return null;
};
const fetchTasks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/leantime/tasks');
if (!response.ok) {
throw new Error('Failed to fetch tasks');
}
const data = await response.json();
console.log('Raw API response:', data);
if (!Array.isArray(data)) {
console.warn('No tasks found in response', data as unknown);
setTasks([]);
return;
}
// Filter out tasks with status Done (5) and sort by dateToFinish
const sortedTasks = data
.filter((task: Task) => {
// Filter out any task (main or subtask) that has status Done (5)
const isNotDone = task.status !== 5;
if (!isNotDone) {
console.log(`Filtering out Done task ${task.id} (type: ${task.type || 'main'}, status: ${task.status})`);
} else {
console.log(`Keeping task ${task.id}: status=${task.status} (${getStatusLabel(task.status)}), type=${task.type || 'main'}`);
}
return isNotDone;
})
.sort((a: Task, b: Task) => {
// First sort by dateToFinish (oldest first)
const dateA = getValidDate(a);
const dateB = getValidDate(b);
// If both dates are valid, compare them
if (dateA && dateB) {
const timeA = new Date(dateA).getTime();
const timeB = new Date(dateB).getTime();
if (timeA !== timeB) {
return timeA - timeB;
}
}
// If only one date is valid, put the task with a date first
if (dateA) return -1;
if (dateB) return 1;
// If dates are equal or neither has a date, sort by status (4 before others)
if (a.status === 4 && b.status !== 4) return -1;
if (b.status === 4 && a.status !== 4) return 1;
// If status is also equal, maintain original order
return 0;
});
console.log('Sorted and filtered tasks:', sortedTasks.map(t => ({
id: t.id,
date: t.dateToFinish,
status: t.status,
type: t.type || 'main'
})));
setTasks(sortedTasks.slice(0, 7));
} catch (error) {
console.error('Error fetching tasks:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
// Update the TaskDate component to handle dates better
const TaskDate = ({ task }: { task: TaskWithDate }) => {
const dateStr = task.dateToFinish;
if (!dateStr || dateStr === '0000-00-00 00:00:00') {
return (
<div className="flex flex-col items-center">
<span className="text-[10px] text-gray-600 font-medium">NO</span>
<span className="text-sm text-gray-700 font-bold">DATE</span>
</div>
);
}
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
throw new Error('Invalid date');
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const isPastDue = date < today;
const month = date.toLocaleString('fr-FR', { month: 'short' }).toUpperCase();
const day = date.getDate();
const year = date.getFullYear();
return (
<div className="flex flex-col items-center">
<div className="flex flex-col items-center">
<span className={`text-[10px] font-medium uppercase ${isPastDue ? 'text-red-600' : 'text-blue-600'}`}>
{month}
</span>
<span className={`text-sm font-bold ${isPastDue ? 'text-red-700' : 'text-blue-700'}`}>
{day}
</span>
</div>
<span className={`text-[8px] font-medium ${isPastDue ? 'text-red-500' : 'text-blue-500'}`}>
{year}
</span>
</div>
);
} catch (error) {
console.error('Error formatting date for task', task.id, error);
return (
<div className="flex flex-col items-center">
<span className="text-[10px] text-gray-600 font-medium">ERR</span>
<span className="text-sm text-gray-700 font-bold">DATE</span>
</div>
);
}
};
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<Share2 className="h-5 w-5 text-gray-600" />
Duties
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchTasks()}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
</Button>
</CardHeader>
<CardContent className="p-3">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : error ? (
<div className="text-xs text-red-500 text-center py-3">{error}</div>
) : tasks.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-6">No tasks with due dates found</div>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
{tasks.map((task) => (
<div
key={task.id}
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
>
<div className="flex gap-2">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-blue-50 flex flex-col items-center justify-center border border-blue-100">
<TaskDate task={task} />
</div>
<div className="flex-1 min-w-0 space-y-1">
<a
href={`https://agilite.slm-lab.net/tickets/showTicket/${task.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium block text-sm line-clamp-2"
>
{task.headline}
</a>
<div className="flex items-center text-gray-500 text-[10px] bg-gray-50 px-1.5 py-0.5 rounded-md">
<Folder className="h-2.5 w-2.5 mr-1 opacity-70" />
<span className="truncate">{task.projectName}</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function FlowFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src="https://agilite.slm-lab.net/oidc/login"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

24
components/footer.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
export function Footer() {
return (
<footer className='w-full p-4 bg-black text-white/80'>
<div className='flex space-x-4 text-sm'>
<Link href='/support' className='hover:text-white'>
Support
</Link>
<Link href='/help' className='hover:text-white'>
Centre d'aide
</Link>
<Link href='/privacy' className='hover:text-white'>
Confidentialité
</Link>
<Link href='/tos' className='hover:text-white'>
Conditions d'utilisation
</Link>
</div>
</footer>
);
}

View File

@ -0,0 +1,541 @@
"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 { Input } from "@/components/ui/input";
import { Plus, MoreHorizontal, Trash, Edit, Users } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
interface Group {
id: string;
name: string;
path: string;
membersCount: number;
}
interface User {
id: string;
username: string;
email: string;
lastName: string;
firstName: string;
}
interface ApiError {
message: string;
}
interface GroupsTableProps {
userRole?: string[];
}
export function GroupsTable({ userRole = [] }: GroupsTableProps) {
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [newGroupDialog, setNewGroupDialog] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [modifyGroupDialog, setModifyGroupDialog] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [modifiedGroupName, setModifiedGroupName] = useState("");
const [manageMembersDialog, setManageMembersDialog] = useState(false);
const [groupMembers, setGroupMembers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
useEffect(() => {
fetchGroups();
}, []);
const fetchGroups = async () => {
try {
setLoading(true);
const response = await fetch("/api/groups");
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Erreur lors de la récupération des groupes");
}
const groupsWithCounts = await Promise.all(
(Array.isArray(data) ? data : []).map(async (group) => {
try {
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
if (membersResponse.ok) {
const members = await membersResponse.json();
return {
...group,
membersCount: Array.isArray(members) ? members.length : 0
};
}
return group;
} catch (error) {
console.error(`Error fetching members for group ${group.id}:`, error);
return group;
}
})
);
setGroups(groupsWithCounts);
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleCreateGroup = async () => {
try {
if (!newGroupName.trim()) {
throw new Error("Le nom du groupe est requis");
}
const response = await fetch("/api/groups", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: newGroupName }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Erreur lors de la création du groupe");
}
setGroups(prev => [...prev, data]);
setNewGroupDialog(false);
setNewGroupName("");
toast({
title: "Succès",
description: "Le groupe a été créé avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
const handleDeleteGroup = async (groupId: string) => {
try {
const response = await fetch(`/api/groups/${groupId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Erreur lors de la suppression du groupe");
}
setGroups(prev => prev.filter(group => group.id !== groupId));
toast({
title: "Succès",
description: "Le groupe a été supprimé avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
const handleModifyGroup = async (groupId: string) => {
try {
const group = groups.find(g => g.id === groupId);
if (!group) return;
setSelectedGroup(group);
setModifiedGroupName(group.name);
setModifyGroupDialog(true);
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
const handleUpdateGroup = async () => {
try {
if (!selectedGroup || !modifiedGroupName.trim()) {
throw new Error("Le nom du groupe est requis");
}
const response = await fetch(`/api/groups/${selectedGroup.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: modifiedGroupName }),
});
if (!response.ok) {
throw new Error("Erreur lors de la modification du groupe");
}
setGroups(prev => prev.map(group =>
group.id === selectedGroup.id
? { ...group, name: modifiedGroupName }
: group
));
setModifyGroupDialog(false);
setSelectedGroup(null);
setModifiedGroupName("");
toast({
title: "Succès",
description: "Le groupe a été modifié avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
const handleManageMembers = async (groupId: string) => {
const group = groups.find(g => g.id === groupId);
if (!group) return;
setSelectedGroup(group);
try {
const membersResponse = await fetch(`/api/groups/${groupId}/members`);
if (!membersResponse.ok) throw new Error("Failed to fetch group members");
const members = await membersResponse.json();
setGroupMembers(members);
setGroups(prev => prev.map(g =>
g.id === groupId
? { ...g, membersCount: members.length }
: g
));
const usersResponse = await fetch("/api/users");
if (!usersResponse.ok) throw new Error("Failed to fetch users");
const users = await usersResponse.json();
setAvailableUsers(users.filter((user: User) => !members.some((m: User) => m.id === user.id)));
setManageMembersDialog(true);
} catch (error) {
toast({
title: "Erreur",
description: "Erreur lors de la récupération des membres",
variant: "destructive",
});
}
};
const handleAddMember = async (userId: string) => {
if (!selectedGroup) return;
try {
const response = await fetch(`/api/groups/${selectedGroup.id}/members`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId }),
});
if (!response.ok) {
throw new Error("Failed to add member");
}
const updatedMember = availableUsers.find(u => u.id === userId);
if (updatedMember) {
setGroupMembers(prev => [...prev, updatedMember]);
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
}
setGroups(prev => prev.map(group =>
group.id === selectedGroup.id
? { ...group, membersCount: group.membersCount + 1 }
: group
));
toast({
title: "Success",
description: "Member added successfully",
});
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroup) return;
try {
const response = await fetch(`/api/groups/${selectedGroup.id}/members/${userId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to remove member");
}
const removedMember = groupMembers.find(u => u.id === userId);
if (removedMember) {
setGroupMembers(prev => prev.filter(u => u.id !== userId));
setAvailableUsers(prev => [...prev, removedMember]);
}
setGroups(prev => prev.map(group =>
group.id === selectedGroup.id
? { ...group, membersCount: Math.max(0, group.membersCount - 1) }
: group
));
toast({
title: "Success",
description: "Member removed successfully",
});
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
}
};
const filteredGroups = groups.filter(group =>
group.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className='flex flex-col space-y-4'>
<div className='flex justify-between items-center mb-6'>
<h1 className='text-2xl font-semibold text-white'>Gestion des groupes</h1>
<div className="flex items-center space-x-4">
<div className="relative w-64">
<Input
type="text"
placeholder="Rechercher un groupe..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/20 border-0 text-white placeholder:text-gray-400"
/>
</div>
<Dialog open={newGroupDialog} onOpenChange={setNewGroupDialog}>
<DialogTrigger asChild>
<Button variant="outline" className="bg-blue-600 text-white hover:bg-blue-700 border-0">
<Plus className="mr-2 h-4 w-4" />
Nouveau groupe
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Créer un nouveau groupe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du groupe</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="Entrez le nom du groupe"
/>
</div>
<Button onClick={handleCreateGroup} className="w-full">
Créer le groupe
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-black/20 border-0">
<TableHead className="text-gray-400">Nom du groupe</TableHead>
<TableHead className="text-gray-400">Chemin</TableHead>
<TableHead className="text-gray-400">Nombre de membres</TableHead>
<TableHead className="text-gray-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGroups.map((group) => (
<TableRow
key={group.id}
className="border-0 bg-black/10 hover:bg-black/20"
>
<TableCell className="text-white">{group.name}</TableCell>
<TableCell className="text-white">{group.path}</TableCell>
<TableCell className="text-white">{group.membersCount}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 text-white hover:bg-black/20">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-black/90 border-gray-700">
<DropdownMenuLabel className="text-gray-400">Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleModifyGroup(group.id)}
className="text-white hover:bg-black/50"
>
<Edit className="mr-2 h-4 w-4" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleManageMembers(group.id)}
className="text-white hover:bg-black/50"
>
<Users className="mr-2 h-4 w-4" />
Gérer les membres
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
className="text-red-400 hover:bg-black/50"
onClick={() => handleDeleteGroup(group.id)}
>
<Trash className="mr-2 h-4 w-4" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={modifyGroupDialog} onOpenChange={setModifyGroupDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le groupe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du groupe</Label>
<Input
id="name"
value={modifiedGroupName}
onChange={(e) => setModifiedGroupName(e.target.value)}
placeholder="Entrez le nouveau nom du groupe"
/>
</div>
<Button
onClick={handleUpdateGroup}
className="w-full"
>
Modifier
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={manageMembersDialog} onOpenChange={setManageMembersDialog}>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gérer les membres du groupe {selectedGroup?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Current Members</Label>
<div className="max-h-[200px] overflow-y-auto border rounded-md p-2">
{groupMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">No members</p>
) : (
<div className="space-y-2">
{groupMembers.map((member) => (
<div key={member.id} className="flex items-center justify-between p-2 hover:bg-muted rounded-md">
<div>
<p className="font-medium">{member.lastName} {member.firstName}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveMember(member.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label>Add Member</Label>
<div className="max-h-[200px] overflow-y-auto border rounded-md p-2">
{availableUsers.length === 0 ? (
<p className="text-sm text-muted-foreground">No users available</p>
) : (
<div className="space-y-2">
{availableUsers
.sort((a, b) => (a.lastName + a.firstName).localeCompare(b.lastName + b.firstName))
.map((user) => (
<div key={user.id} className="flex items-center justify-between p-2 hover:bg-muted rounded-md">
<div>
<p className="font-medium">{user.lastName} {user.firstName}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleAddMember(user.id)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { MainNav } from "@/components/main-nav";
import { Footer } from "@/components/footer";
import { AuthCheck } from "@/components/auth/auth-check";
import { Toaster } from "@/components/ui/toaster";
import { useBackgroundImage } from "@/components/background-switcher";
interface LayoutWrapperProps {
children: React.ReactNode;
isSignInPage: boolean;
isAuthenticated: boolean;
}
export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: LayoutWrapperProps) {
const { currentBackground, changeBackground } = useBackgroundImage();
return (
<AuthCheck>
{!isSignInPage && isAuthenticated && <MainNav />}
<div
className={isSignInPage ? "" : "min-h-screen"}
style={
!isSignInPage ? {
backgroundImage: `url('${currentBackground}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
cursor: 'pointer',
transition: 'background-image 0.5s ease-in-out'
} : {}
}
onClick={!isSignInPage ? changeBackground : undefined}
>
<main>{children}</main>
</div>
{!isSignInPage && isAuthenticated && <Footer />}
<Toaster />
</AuthCheck>
);
}

355
components/main-nav.tsx Normal file
View File

@ -0,0 +1,355 @@
"use client";
import { useState } from "react";
import {
Calendar,
MessageSquare,
BotIcon as Robot,
Bell,
Users,
LogOut,
UserCog,
Clock,
PenLine,
Video,
Radio as RadioIcon,
Megaphone,
Heart,
Target,
Mail,
Telescope,
Lightbulb,
Circle,
Menu,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Sidebar } from "./sidebar";
import { useSession, signIn, signOut } from "next-auth/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
const requestNotificationPermission = async () => {
try {
const permission = await Notification.requestPermission();
return permission === "granted";
} catch (error) {
console.error("Error requesting notification permission:", error);
return false;
}
};
export function MainNav() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { data: session, status } = useSession();
const [userStatus, setUserStatus] = useState<'online' | 'busy' | 'away'>('online');
console.log("Session:", session);
console.log("Status:", status);
// Updated function to get user initials
const getUserInitials = () => {
if (session?.user?.name) {
// Split the full name and get initials
const names = session.user.name.split(' ');
if (names.length >= 2) {
return `${names[0][0]}${names[names.length - 1][0]}`.toUpperCase();
}
// If only one name, use first two letters
return names[0].slice(0, 2).toUpperCase();
}
return "?";
};
// Function to get display name
const getDisplayName = () => {
return session?.user?.name || "User";
};
// Function to get user role
const getUserRole = () => {
if (session?.user?.role) {
if (Array.isArray(session.user.role)) {
// Filter out technical roles and format remaining ones
return session.user.role
.filter(role =>
!['offline_access', 'uma_authorization', 'default-roles-cercle'].includes(role)
)
.map(role => {
// Transform role names
switch(role) {
case 'ROLE_Mentors':
return 'Mentor';
case 'ROLE_apprentice':
return 'Apprentice';
case 'ROLE_Admin':
return 'Admin';
default:
return role.replace('ROLE_', '');
}
})
.join(', ');
}
return session.user.role;
}
return "";
};
// Function to check if user has a specific role
const hasRole = (requiredRoles: string[]) => {
if (!session?.user?.role) return false;
const userRoles = Array.isArray(session.user.role) ? session.user.role : [session.user.role];
// Add console.log to debug roles
console.log('User roles:', userRoles);
console.log('Required roles:', requiredRoles);
return userRoles.some(role => {
// Remove ROLE_ prefix if it exists
const cleanRole = role.replace('ROLE_', '');
return requiredRoles.includes(cleanRole) || cleanRole === 'Admin';
});
};
// Status configurations
const statusConfig = {
online: {
color: 'text-green-500',
label: 'Online',
notifications: true
},
busy: {
color: 'text-orange-500',
label: 'Busy',
notifications: false
},
away: {
color: 'text-gray-500',
label: 'Away',
notifications: false
},
};
// Handle status change
const handleStatusChange = async (newStatus: 'online' | 'busy' | 'away') => {
setUserStatus(newStatus);
if (newStatus !== 'online') {
// If status is busy or away, check and request notification permission if needed
const hasPermission = await requestNotificationPermission();
if (hasPermission) {
// Disable notifications
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.pushManager.getSubscription()?.then(subscription => {
if (subscription) {
subscription.unsubscribe();
}
});
}
}
} else {
// Re-enable notifications if going back online
requestNotificationPermission();
}
};
// Base menu items (available for everyone)
const baseMenuItems = [
{
title: "HealthView",
icon: Heart,
href: '/health-view',
},
{
title: "MissionView",
icon: Target,
href: '/mission-view',
},
];
// Role-specific menu items
const roleSpecificItems = [
{
title: "ShowCase",
icon: Lightbulb,
href: '/showcase',
requiredRoles: ["Expression"],
},
{
title: "UsersView",
icon: UserCog,
href: '/management',
requiredRoles: ["Admin", "Entrepreneurship"],
},
{
title: "TheMessage",
icon: Mail,
href: '/the-message',
requiredRoles: ["Mediation", "Expression"],
},
];
// Get visible menu items based on user roles
const visibleMenuItems = [
...baseMenuItems,
...roleSpecificItems.filter(item => hasRole(item.requiredRoles))
];
// Format current date and time
const now = new Date();
const formattedDate = format(now, "d MMMM yyyy", { locale: fr });
const formattedTime = format(now, "HH:mm");
return (
<>
<div className="fixed top-0 left-0 right-0 z-50 bg-black">
<div className="flex items-center justify-between px-4 py-1">
{/* Left side */}
<div className="flex items-center space-x-4">
<button
onClick={() => setIsSidebarOpen(true)}
className="text-white/80 hover:text-white"
>
<Menu className="w-5 h-5" />
</button>
<Link href='/'>
<Image
src='/Neahv2 logo W.png'
alt='Neah Logo'
width={40}
height={13}
className='text-white'
/>
</Link>
<Link href='/timetracker' className='text-white/80 hover:text-white'>
<Clock className='w-5 h-5' />
<span className="sr-only">TimeTracker</span>
</Link>
<Link href='/calendar' className='text-white/80 hover:text-white'>
<Calendar className='w-5 h-5' />
</Link>
<Link href='/notes' className='text-white/80 hover:text-white'>
<PenLine className='w-5 h-5' />
<span className="sr-only">Notes</span>
</Link>
<Link href='/ai-assistant' className='text-white/80 hover:text-white'>
<Robot className='w-5 h-5' />
<span className="sr-only">Alma</span>
</Link>
<Link href='/conference' className='text-white/80 hover:text-white'>
<Video className='w-5 h-5' />
<span className="sr-only">Conference</span>
</Link>
<Link href='/radio' className='text-white/80 hover:text-white'>
<RadioIcon className='w-5 h-5' />
<span className="sr-only">Radio</span>
</Link>
<Link href='/announcement' className='text-white/80 hover:text-white'>
<Megaphone className='w-5 h-5' />
<span className="sr-only">Announcement</span>
</Link>
</div>
{/* Right side */}
<div className="flex items-center space-x-8">
{/* Date and Time with smaller text */}
<div className="text-white/80 text-sm">
<span className="mr-2">{formattedDate}</span>
<span>{formattedTime}</span>
</div>
<Link
href='/notifications'
className='text-white/80 hover:text-white'
>
<Bell className='w-5 h-5' />
</Link>
{status === "authenticated" && session?.user ? (
<DropdownMenu>
<DropdownMenuTrigger className="outline-none">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white cursor-pointer hover:bg-blue-700 transition-colors">
{getUserInitials()}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-black/90 border-gray-700">
<DropdownMenuLabel className="text-white/80">
<div className="flex items-center justify-between">
<span>{getDisplayName()}</span>
<DropdownMenu>
<DropdownMenuTrigger className="outline-none">
<div className="flex items-center space-x-1 text-sm">
<Circle className={`h-3 w-3 ${statusConfig[userStatus].color}`} />
<span className="text-gray-400">{statusConfig[userStatus].label}</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-black/90 border-gray-700">
<DropdownMenuItem
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={() => handleStatusChange('online')}
>
<Circle className="h-3 w-3 text-green-500 mr-2" />
<span>Online</span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={() => handleStatusChange('busy')}
>
<Circle className="h-3 w-3 text-orange-500 mr-2" />
<span>Busy</span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={() => handleStatusChange('away')}
>
<Circle className="h-3 w-3 text-gray-500 mr-2" />
<span>Away</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-gray-700" />
{visibleMenuItems.map((item) => (
<DropdownMenuItem
key={item.title}
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={() => window.location.href = item.href}
>
<item.icon className="mr-2 h-4 w-4" />
<span>{item.title}</span>
</DropdownMenuItem>
))}
<DropdownMenuItem
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={() => signOut()}
>
<LogOut className="mr-2 h-4 w-4" />
<span>Déconnexion</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className='cursor-pointer text-white/80 hover:text-white'>
<span onClick={() => signIn("keycloak", { callbackUrl: "/" })}>
Login
</span>
</div>
)}
</div>
</div>
</div>
<Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
</>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GroupsTable } from "@/components/groups/groups-table";
import { UsersTable } from "@/components/users/users-table";
interface ManagementTabsProps {
userRole?: string[];
}
export function ManagementTabs({ userRole = [] }: ManagementTabsProps) {
return (
<div className="flex flex-col mt-8">
<Tabs defaultValue="users">
<div className="flex justify-between items-center mb-8">
<TabsList className="bg-black/20 border-0">
<TabsTrigger
value="users"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-gray-400"
>
Utilisateurs
</TabsTrigger>
<TabsTrigger
value="groups"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-gray-400"
>
Groupes
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="users">
<UsersTable userRole={userRole} />
</TabsContent>
<TabsContent value="groups">
<GroupsTable userRole={userRole} />
</TabsContent>
</Tabs>
</div>
);
}

14
components/messages.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function Messages() {
return (
<Card className='transition-transform duration-500 ease-in-out transform hover:scale-105'>
<CardHeader>
<CardTitle>Messages - Non Lu</CardTitle>
</CardHeader>
<CardContent className='p-6 text-center text-gray-500'>
Aucun nouveau messages
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function MessagesFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src="https://parole.slm-lab.net/channel/City"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function MissionsBoardFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_MISSIONSBOARD_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function MissionsFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src="https://page.slm-lab.net"
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

15
components/navbar.tsx Normal file
View File

@ -0,0 +1,15 @@
import { CalendarButton } from "@/components/navbar/calendar-button";
// ... existing imports ...
export function Navbar() {
return (
<header className="...">
<div className="flex items-center gap-2">
{/* ... existing buttons ... */}
<CalendarButton />
{/* ... other buttons ... */}
</div>
</header>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function CalendarButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href="/calendar">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Ouvrir le calendrier"
>
<CalendarIcon className="h-5 w-5" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Calendrier</p>
</TooltipContent>
</Tooltip>
);
}

137
components/news.tsx Normal file
View File

@ -0,0 +1,137 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Telescope } from "lucide-react";
import { useSession } from "next-auth/react";
import { formatDistance } from 'date-fns';
import { fr } from 'date-fns/locale';
interface NewsItem {
id: number;
title: string;
date: string;
source: string;
description: string | null;
category: string | null;
url: string;
}
export function News() {
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const { status } = useSession();
const fetchNews = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try {
const response = await fetch('/api/news');
if (!response.ok) {
throw new Error('Failed to fetch news');
}
const data = await response.json();
setNews(data);
setError(null);
} catch (err) {
setError('Failed to fetch news');
console.error('Error fetching news:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
if (status === 'authenticated') {
fetchNews();
}
}, [status]);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
locale: fr
});
} catch (err) {
console.error('Error formatting date:', err);
return dateString;
}
};
if (status === 'loading' || loading) {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<Telescope className="h-5 w-5 text-gray-600" />
News
</CardTitle>
</CardHeader>
<CardContent className="p-3">
<div className="flex items-center justify-center py-6">
<RefreshCw className="h-4 w-4 animate-spin text-gray-400" />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<Telescope className="h-5 w-5 text-gray-600" />
News
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => fetchNews(true)}
disabled={refreshing}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
</Button>
</CardHeader>
<CardContent className="p-3">
{error ? (
<div className="text-xs text-red-500 text-center py-3">{error}</div>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
{news.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-6">No news available</div>
) : (
news.map((item) => (
<div
key={item.id}
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
onClick={() => window.open(item.url, '_blank')}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">{formatDate(item.date)}</span>
</div>
<h3 className="text-sm font-medium text-gray-800 line-clamp-2" title={item.title}>
{item.title}
</h3>
<p className="text-xs text-gray-500 line-clamp-2" title={item.description}>
{item.description}
</p>
</div>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function NotesFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_NOTES_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

View File

@ -0,0 +1,14 @@
"use client";
export function ObservatoryFrame() {
return (
<div className="w-full h-[calc(100vh-8rem)]">
<iframe
src={process.env.NEXT_PUBLIC_IFRAME_OBSERVATORY_URL}
className="w-full h-full border-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}

209
components/parole.tsx Normal file
View File

@ -0,0 +1,209 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, MessageSquare } from "lucide-react";
import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { signIn, useSession } from "next-auth/react";
interface Message {
id: string;
text: string;
timestamp: string;
rawTimestamp: string;
roomName: string;
roomType: string;
sender: {
_id: string;
username: string;
name: string;
initials: string;
color: string;
};
isOwnMessage: boolean;
room: {
id: string;
type: string;
name: string;
isChannel: boolean;
isPrivateGroup: boolean;
isDirect: boolean;
link: string;
};
}
export function Parole() {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const router = useRouter();
const { data: session, status } = useSession();
const fetchMessages = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
}
const response = await fetch('/api/rocket-chat/messages', {
cache: 'no-store',
next: { revalidate: 0 },
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch messages');
}
const data = await response.json();
if (Array.isArray(data.messages)) {
setMessages(data.messages);
} else {
console.warn('Unexpected data format:', data);
setMessages([]);
}
setError(null);
} catch (err) {
console.error('Error fetching messages:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch messages';
setError(errorMessage);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
if (status === 'authenticated') {
fetchMessages();
// Set up polling every 30 seconds
const interval = setInterval(() => fetchMessages(), 30000);
return () => clearInterval(interval);
}
}, [status]);
if (status === 'loading') {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
Parole
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<p className="text-center text-gray-500">Loading...</p>
</CardContent>
</Card>
);
}
if (status === 'unauthenticated' || (error && error.includes('Session expired'))) {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
Parole
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="text-center">
<p className="text-gray-500 mb-4">Please sign in to view messages</p>
<Button
onClick={(e) => {
e.stopPropagation();
signIn('keycloak');
}}
variant="default"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Sign In
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card
className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full cursor-pointer w-full"
onClick={() => router.push('/parole')}
>
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-gray-600" />
Parole
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
fetchMessages(true);
}}
disabled={refreshing}
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-4">
{loading && <p className="text-center text-gray-500">Loading messages...</p>}
{error && (
<div className="text-center">
<p className="text-red-500">Error: {error}</p>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
fetchMessages(true);
}}
className="mt-2"
>
Try Again
</Button>
</div>
)}
{!loading && !error && (
<div className="space-y-4 max-h-[300px] overflow-y-auto">
{messages.length === 0 ? (
<p className="text-center text-gray-500">No messages found</p>
) : (
messages.map((message) => (
<div key={message.id} className="flex items-start space-x-3 hover:bg-gray-50/50 p-3 rounded-lg transition-colors">
<Avatar className="h-8 w-8" style={{ backgroundColor: message.sender.color }}>
<AvatarImage src={`https://ui-avatars.com/api/?name=${encodeURIComponent(message.sender.name)}&background=random`} />
<AvatarFallback>{message.sender.initials}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between space-x-2">
<p className="text-sm font-semibold text-gray-800 truncate max-w-[70%]">{message.sender.name}</p>
<span className="text-xs font-medium text-gray-500 flex-shrink-0">{message.timestamp}</span>
</div>
<p className="text-sm text-gray-600 whitespace-pre-wrap line-clamp-2 mt-1">{message.text}</p>
{message.roomName && (
<div className="flex items-center mt-2">
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
message.room.isChannel ? 'bg-blue-50 text-blue-700' :
message.room.isPrivateGroup ? 'bg-purple-50 text-purple-700' :
'bg-green-50 text-green-700'
}`}>
{message.room.isChannel ? '#' : message.room.isPrivateGroup ? '🔒' : '💬'} {message.roomName}
</span>
</div>
)}
</div>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More