Initial commit
This commit is contained in:
commit
550e5d7ed2
72
.env
Normal file
72
.env
Normal 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
31
Dockerfile
Normal 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
31
app/[section]/page.tsx
Normal 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
24
app/ai-assistant/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
32
app/api/auth/[...nextauth]/route.ts
Normal file
32
app/api/auth/[...nextauth]/route.ts
Normal 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
166
app/api/calendar/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
270
app/api/calendars/[id]/events/[eventId]/route.ts
Normal file
270
app/api/calendars/[id]/events/[eventId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/api/calendars/[id]/events/route.ts
Normal file
171
app/api/calendars/[id]/events/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/api/calendars/[id]/route.ts
Normal file
179
app/api/calendars/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/calendars/[id]/share/route.ts
Normal file
53
app/api/calendars/[id]/share/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/calendars/default/route.ts
Normal file
57
app/api/calendars/default/route.ts
Normal 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
101
app/api/calendars/route.ts
Normal 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
59
app/api/emails/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/events/[id]/route.ts
Normal file
51
app/api/events/[id]/route.ts
Normal 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
136
app/api/events/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/groups/[groupId]/members/route.ts
Normal file
121
app/api/groups/[groupId]/members/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/api/groups/[groupId]/route.ts
Normal file
177
app/api/groups/[groupId]/route.ts
Normal 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
166
app/api/groups/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/api/leantime/status-labels/route.ts
Normal file
234
app/api/leantime/status-labels/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
app/api/leantime/tasks/route.ts
Normal file
206
app/api/leantime/tasks/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/api/mail/bulk-actions/route.ts
Normal file
172
app/api/mail/bulk-actions/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/api/mail/login/route.ts
Normal file
91
app/api/mail/login/route.ts
Normal 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);
|
||||||
|
}
|
||||||
155
app/api/mail/mark-read/route.ts
Normal file
155
app/api/mail/mark-read/route.ts
Normal 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
362
app/api/mail/route.ts
Normal 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
106
app/api/mail/send/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/mail/test-connection/route.ts
Normal file
56
app/api/mail/test-connection/route.ts
Normal 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
131
app/api/news/route.ts
Normal 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(/ /g, ' ') // Replace 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
370
app/api/rocket-chat/messages/route.ts
Normal file
370
app/api/rocket-chat/messages/route.ts
Normal 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
81
app/api/roles/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/users/[userId]/password/route.ts
Normal file
72
app/api/users/[userId]/password/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/api/users/[userId]/roles/route.ts
Normal file
98
app/api/users/[userId]/roles/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
276
app/api/users/[userId]/route.ts
Normal file
276
app/api/users/[userId]/route.ts
Normal 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
572
app/api/users/route.ts
Normal 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
23
app/calculation/page.tsx
Normal 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
116
app/calendar/page.tsx
Normal 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
23
app/chapter/page.tsx
Normal 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
129
app/components/flow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
app/components/responsive-iframe.tsx
Normal file
73
app/components/responsive-iframe.tsx
Normal 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
23
app/conference/page.tsx
Normal 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
30
app/contacts/page.tsx
Normal 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
23
app/crm/page.tsx
Normal 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
24
app/design/page.tsx
Normal 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
30
app/diary/page.tsx
Normal 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
30
app/drive/page.tsx
Normal 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
23
app/email/page.tsx
Normal 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
23
app/flow/page.tsx
Normal 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
23
app/gite/page.tsx
Normal 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
76
app/globals.css
Normal 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
24
app/groups/page.tsx
Normal 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
36
app/layout.tsx
Normal 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
23
app/learn/page.tsx
Normal 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
148
app/mail/login/page.tsx
Normal 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
1879
app/mail/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
24
app/management/page.tsx
Normal file
24
app/management/page.tsx
Normal 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
27
app/mediations/page.tsx
Normal 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
23
app/mission-view/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/notifications/page.tsx
Normal file
13
app/notifications/page.tsx
Normal 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
14
app/observatory/page.tsx
Normal 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
42
app/page.tsx
Normal 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
23
app/parole/page.tsx
Normal 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
23
app/radio/page.tsx
Normal 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
23
app/showcase/page.tsx
Normal 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
7
app/signin/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function SignInLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
31
app/signin/page.tsx
Normal file
31
app/signin/page.tsx
Normal 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
23
app/the-message/page.tsx
Normal 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
14
app/timetracker/page.tsx
Normal 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
24
app/users/page.tsx
Normal 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
21
components.json
Normal 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"
|
||||||
|
}
|
||||||
14
components/announcement/announcement-frame.tsx
Normal file
14
components/announcement/announcement-frame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/auth/auth-check.tsx
Normal file
27
components/auth/auth-check.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
32
components/auth/login-card.tsx
Normal file
32
components/auth/login-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/auth/signin-form.tsx
Normal file
18
components/auth/signin-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
components/background-switcher.tsx
Normal file
156
components/background-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
components/calendar-widget.tsx
Normal file
195
components/calendar-widget.tsx
Normal 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
172
components/calendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1370
components/calendar/calendar-client.tsx
Normal file
1370
components/calendar/calendar-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
114
components/calendar/calendar-dialog.tsx
Normal file
114
components/calendar/calendar-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
components/calendar/calendar-widget.tsx
Normal file
198
components/calendar/calendar-widget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
components/calendar/event-dialog.tsx
Normal file
261
components/calendar/event-dialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/conference/conference-frame.tsx
Normal file
34
components/conference/conference-frame.tsx
Normal 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
213
components/email.tsx
Normal 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
10
components/emails.tsx
Normal 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
276
components/flow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/flow/flow-frame.tsx
Normal file
14
components/flow/flow-frame.tsx
Normal 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
24
components/footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
541
components/groups/groups-table.tsx
Normal file
541
components/groups/groups-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
components/layout/layout-wrapper.tsx
Normal file
42
components/layout/layout-wrapper.tsx
Normal 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
355
components/main-nav.tsx
Normal 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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
components/management/management-tabs.tsx
Normal file
42
components/management/management-tabs.tsx
Normal 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
14
components/messages.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/messages/messages-frame.tsx
Normal file
14
components/messages/messages-frame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/missions-board/missions-board-frame.tsx
Normal file
14
components/missions-board/missions-board-frame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/missions/missions-frame.tsx
Normal file
14
components/missions/missions-frame.tsx
Normal 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
15
components/navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/navbar/calendar-button.tsx
Normal file
32
components/navbar/calendar-button.tsx
Normal 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
137
components/news.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/notes/notes-frame.tsx
Normal file
14
components/notes/notes-frame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/observatory/observatory-frame.tsx
Normal file
14
components/observatory/observatory-frame.tsx
Normal 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
209
components/parole.tsx
Normal 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
Loading…
Reference in New Issue
Block a user