Refactor GraphQL API routes and remove unused components; enhance error handling and context management

This commit is contained in:
Alexandre Bove 2025-07-30 12:29:13 +02:00
parent f1f1b45c91
commit 7d0a6e5a74
16 changed files with 66 additions and 314 deletions

View File

@ -1,3 +1,5 @@
import { yoga } from "@/graphql";
export { yoga as GET, yoga as POST, yoga as OPTIONS };
export const GET = (request: Request) => yoga.handleRequest(request, { request, db: {} });
export const POST = (request: Request) => yoga.handleRequest(request, { request, db: {} });
export const OPTIONS = (request: Request) => yoga.handleRequest(request, { request, db: {} });

View File

@ -1,114 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function CallbackPage() {
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
const searchParams = useSearchParams();
useEffect(() => {
const handleCallback = async () => {
try {
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
if (error) {
setStatus("error");
setMessage(`Erreur d'authentification: ${error}`);
return;
}
if (!code || !state) {
setStatus("error");
setMessage("Paramètres de callback manquants");
return;
}
// Décoder le state pour récupérer les informations du type d'authentification
const stateData = JSON.parse(decodeURIComponent(state));
const { type, connector } = stateData;
// Ici vous pouvez traiter le code d'autorisation pour obtenir les tokens
// et créer ou connecter l'utilisateur avec votre API
setStatus("success");
setMessage(
`Authentification ${type === "login" ? "connexion" : "inscription"} réussie avec le connecteur ${connector}`
);
// Rediriger vers la page appropriée après un délai
setTimeout(() => {
if (type === "login") {
window.location.href = "/profile";
} else {
window.location.href = "/profile";
}
}, 2000);
} catch (err) {
console.error("Erreur lors du traitement du callback:", err);
setStatus("error");
setMessage("Erreur lors du traitement de l'authentification");
}
};
handleCallback();
}, [searchParams]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div className="rounded-lg bg-white p-8 text-center shadow-lg">
{status === "loading" && (
<>
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Authentification en cours...</h2>
<p className="mt-2 text-gray-600">Veuillez patienter pendant que nous traitons votre authentification.</p>
</>
)}
{status === "success" && (
<>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold text-green-900">Authentification réussie !</h2>
<p className="mt-2 text-gray-600">{message}</p>
<p className="mt-2 text-sm text-gray-500">Redirection en cours...</p>
</>
)}
{status === "error" && (
<>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold text-red-900">Erreur d&apos;authentification</h2>
<p className="mt-2 text-gray-600">{message}</p>
<div className="mt-6 space-y-3">
<a
href="/login"
className="inline-flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Retour à la connexion
</a>
<a
href="/signup"
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Retour à l&apos;inscription
</a>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -68,19 +68,6 @@ const CREATE_CONNECTOR_MUTATION = gql`
}
`;
const UPDATE_CONNECTOR_MUTATION = gql`
mutation UpdateConnector($input: UpdateConnectorInput!) {
updateConnector(input: $input) {
success
message
connector {
id
enabled
}
}
}
`;
const DELETE_CONNECTOR_MUTATION = gql`
mutation DeleteConnector($id: ID!) {
deleteConnector(id: $id) {

View File

@ -23,7 +23,7 @@ export default function RolesPage() {
// Fetch roles
useEffect(() => {
client
.request(gql`
.request<{ roles: Role[] }>(gql`
{
roles {
id
@ -37,7 +37,7 @@ export default function RolesPage() {
}
}
`)
.then((data: any) => {
.then(data => {
setRoles(data.roles);
setLoading(false);
})

View File

@ -70,11 +70,6 @@ export default function PermissionsManager({
}
}
`;
console.log("[DEBUG] createResourceScope called", {
resourceId: selectedResource,
name: form.name,
description: form.description,
});
const variables = { resourceId: selectedResource, name: form.name, description: form.description || undefined };
const data: { createResourceScope: Permission } = await client.request(mutation, variables);
setScopes([...scopes, data.createResourceScope]);

View File

@ -1,6 +1,5 @@
import setAuth from "@/graphql/context/features/auth";
import initGuard from "@/graphql/context/features/guard";
import { patContextCache } from "@/graphql/context/features/patCache";
import { GraphQLContext } from "@/graphql/context/types";
import { YogaInitialContext } from "graphql-yoga";
@ -13,7 +12,6 @@ export async function context({ request }: YogaInitialContext): Promise<GraphQLC
request,
...auth,
guard,
patCache: patContextCache,
};
return context;

View File

@ -1,5 +1,4 @@
import { AuthTypes } from "@/graphql/context";
import { patContextCache } from "@/graphql/context/features/patCache";
import { listResources, Resource } from "@/graphql/features/Resource/resourceService";
import { getUserRoles, Role } from "@/graphql/features/Role/roleService";
import { validatePATWithLogtoAPI } from "@/graphql/features/User/resolver";
@ -69,7 +68,7 @@ const setAuth = async (
if (token.includes(".") && token.split(".").length === 3) {
// C'est un JWT
const decoded = decodeJWT(token);
userId = decoded?.sub as string | undefined;
userId = decoded?.sub;
if (userId) {
user = {
id: userId,
@ -79,27 +78,23 @@ const setAuth = async (
isActive: typeof decoded?.isActive === "boolean" ? decoded.isActive : undefined,
};
}
// Log JWT
console.error("[setAuth] JWT decoded:", decoded);
} else if (token.startsWith("pat_")) {
// PAT : tenter de récupérer l'userId via le cache
userId = patContextCache.getUserId(token) || undefined;
if (!userId) {
// Si non trouvé dans le cache, tente une validation via Logto
try {
userId = await validatePATWithLogtoAPI(token, { patCache: patContextCache, db: {}, request: req });
if (userId) {
patContextCache.register(token, userId, 24);
}
} catch (e) {
console.error("[setAuth] PAT non reconnu via Logto:", token, e);
try {
let requestObj: Request;
if (req instanceof Request) {
requestObj = req;
} else {
// Create a minimal Request object if possible, or throw an error
throw new Error("PAT validation requires a Request object.");
}
}
console.error("[setAuth] PAT lookup:", { token, userId });
if (userId) {
user = { id: userId };
} else {
console.error("[setAuth] PAT non reconnu dans le cache ni via Logto, token:", token);
userId = (await validatePATWithLogtoAPI(token, { db: {}, request: requestObj })) || undefined;
if (userId) {
user = { id: userId };
} else {
console.error("[setAuth] PAT non reconnu via Logto", { token });
}
} catch (e) {
console.error("[setAuth] Erreur lors de la validation du PAT via Logto:", token, e);
}
}
@ -112,7 +107,6 @@ const setAuth = async (
accessToken: token,
request: req as Request,
db: {},
patCache: patContextCache,
};
roles = await getUserRoles(userId, minimalContext);
permissions = roles.flatMap(r =>

View File

@ -1,82 +0,0 @@
// Cache pour les associations PAT -> userId intégré au contexte GraphQL
export interface PATCacheEntry {
userId: string;
expiry: number;
}
class PATContextCache {
private cache: Map<string, PATCacheEntry> = new Map();
// Enregistrer une association PAT -> userId
register(pat: string, userId: string, ttlHours: number = 24): void {
this.cache.set(pat, {
userId: userId,
expiry: Date.now() + ttlHours * 60 * 60 * 1000,
});
}
// Récupérer l'userId depuis un PAT
getUserId(pat: string): string | null {
const entry = this.cache.get(pat);
if (entry && Date.now() < entry.expiry) {
return entry.userId;
}
// Supprimer l'entrée expirée
if (entry) {
this.cache.delete(pat);
}
return null;
}
// Supprimer une entrée du cache
remove(pat: string): void {
this.cache.delete(pat);
}
// Nettoyer les entrées expirées
cleanup(): void {
const now = Date.now();
for (const [pat, entry] of this.cache.entries()) {
if (now >= entry.expiry) {
this.cache.delete(pat);
}
}
}
// Obtenir les statistiques du cache
getStats(): { size: number; entries: number } {
this.cleanup(); // Nettoyer avant de retourner les stats
return {
size: this.cache.size,
entries: this.cache.size,
};
}
// Vérifier si un PAT existe dans le cache
has(pat: string): boolean {
const entry = this.cache.get(pat);
if (entry && Date.now() < entry.expiry) {
return true;
}
// Supprimer l'entrée expirée
if (entry) {
this.cache.delete(pat);
}
return false;
}
}
// Instance singleton du cache PAT
export const patContextCache = new PATContextCache();
// Nettoyer le cache toutes les heures
setInterval(
() => {
patContextCache.cleanup();
},
60 * 60 * 1000
); // 1 heure

View File

@ -1,11 +1,9 @@
import type { Resource } from "@/graphql/features/Resource/resourceService";
import type { Role } from "@/graphql/features/Role/roleService";
export interface DB {
// Define your database types here
}
export type DB = object;
export interface Guard {}
export type Guard = object;
export interface GuardFunction {
(auth?: AuthTypes): Guard;
@ -50,5 +48,4 @@ export interface GraphQLContext {
accessToken?: string;
personalAccessToken?: string;
userId?: string;
patCache: PATCache; // Cache PAT intégré au contexte
}

View File

@ -192,12 +192,6 @@ export const organizationResolvers = {
context: GraphQLContext
): Promise<OrganizationsListResponse> {
try {
// DEBUG: log du contexte d'authentification
console.log("[myOrganizations] context.user:", context.user);
console.log("[myOrganizations] context.userId:", context.userId);
if (context.request && typeof context.request.headers?.get === "function") {
console.log("[myOrganizations] Authorization header:", context.request.headers.get("Authorization"));
}
const userId = context.user?.id || context.userId;
if (!userId) {
return {

View File

@ -6,16 +6,14 @@ import { resourceScopeResolvers } from "./scopeResolver";
// Fusionne les Query et Mutation des deux resolvers
export const Resource = {
typeDefs: [typeDefs, resourceScopeTypeDefs],
resolvers: [
{
Query: {
...resourceResolvers.Query,
...resourceScopeResolvers.Query,
},
Mutation: {
...resourceResolvers.Mutation,
...resourceScopeResolvers.Mutation,
},
resolvers: {
Query: {
...resourceResolvers.Query,
...resourceScopeResolvers.Query,
},
],
Mutation: {
...resourceResolvers.Mutation,
...resourceScopeResolvers.Mutation,
},
},
};

View File

@ -8,8 +8,9 @@ function getAccessTokenFromContext(context: GraphQLContext): string | null {
return context.accessToken || null;
}
export async function listResources(context: GraphQLContext): Promise<Resource[]> {
const token = getAccessTokenFromContext(context) || (await getLogtoAccessToken());
export async function listResources(_context: GraphQLContext): Promise<Resource[]> {
// Toujours utiliser le token M2M pour les endpoints admin
const token = await getLogtoAccessToken();
try {
const response = await axios.get(`${process.env.LOGTO_ENDPOINT}/api/resources`, {
headers: { Authorization: `Bearer ${token}` },
@ -18,10 +19,13 @@ export async function listResources(context: GraphQLContext): Promise<Resource[]
...resource,
scopes: Array.isArray(resource.scopes) ? resource.scopes : [],
}));
} catch {
throw new GraphQLError("Erreur lors de la récupération des ressources", {
extensions: { code: "RESOURCES_FETCH_FAILED" },
});
} catch (err) {
if (axios.isAxiosError(err)) {
console.error(`[listResources] Axios error:`, err.response?.data || err.message);
} else {
console.error(`[listResources] Unknown error:`, err);
}
return [];
}
}

View File

@ -24,7 +24,8 @@ async function fetchRolePermissions(
description: scope.description,
}))
: [];
} catch {
} catch (err) {
console.error(`[fetchRolePermissions] error for role ${roleId}:`, err);
return [];
}
}
@ -50,23 +51,31 @@ export async function listRoles(context: GraphQLContext): Promise<Role[]> {
}
}
export async function getUserRoles(userId: string, context: GraphQLContext): Promise<Role[]> {
const token = getAccessTokenFromContext(context) || (await getLogtoAccessToken());
export async function getUserRoles(userId: string, _context: GraphQLContext): Promise<Role[]> {
// Toujours utiliser le token M2M pour les endpoints admin
const token = await getLogtoAccessToken();
try {
const response = await axios.get(`${process.env.LOGTO_ENDPOINT}/api/users/${userId}/roles`, {
headers: { Authorization: `Bearer ${token}` },
});
const roles = response.data as Role[];
const roles = Array.isArray(response.data) ? (response.data as Role[]) : [];
if (!roles.length) {
console.warn(`[getUserRoles] Aucun rôle trouvé pour l'utilisateur ${userId}`);
return [];
}
return await Promise.all(
roles.map(async role => ({
...role,
permissions: await fetchRolePermissions(role.id, token),
}))
);
} catch {
throw new GraphQLError("Erreur lors de la récupération des rôles de l'utilisateur", {
extensions: { code: "USER_ROLES_FETCH_FAILED" },
});
} catch (err) {
if (axios.isAxiosError(err)) {
console.error(`[getUserRoles] Axios error:`, err.response?.data || err.message);
} else {
console.error(`[getUserRoles] Unknown error:`, err);
}
return [];
}
}

View File

@ -1,4 +1,3 @@
import { patContextCache } from "@/graphql/context/features/patCache";
import axios from "axios";
import { GraphQLError } from "graphql";
import {
@ -191,16 +190,14 @@ async function getUserByEmail(email: string): Promise<LogtoUser | null> {
}
}
// Cache pour les associations PAT -> userId pour éviter les recherches répétées
// Note: Maintenant géré via le contexte GraphQL (context.patCache)
async function getUserByPersonalAccessToken(pat: string, context: GraphQLContext): Promise<LogtoUser | null> {
// Récupérer l'userId directement depuis le cache du contexte
let userId = context.patCache.getUserId(pat);
let userId = context.userId;
// Si pas trouvé dans le cache, essayer de valider le PAT avec l'API Logto
if (!userId) {
userId = await validatePATWithLogtoAPI(pat, context);
const validatedUserId = await validatePATWithLogtoAPI(pat, context);
userId = validatedUserId === null ? undefined : validatedUserId;
if (!userId) {
throw new GraphQLError("Token d'accès personnel invalide ou non reconnu", {
@ -219,8 +216,6 @@ async function getUserByPersonalAccessToken(pat: string, context: GraphQLContext
return response.data;
} catch (error) {
console.error("Erreur lors de la récupération de l'utilisateur par PAT:", error);
// Si l'utilisateur n'existe plus, nettoyer le cache
context.patCache.remove(pat);
throw new GraphQLError("Utilisateur associé au token non trouvé", {
extensions: { code: "PAT_USER_NOT_FOUND" },
});
@ -228,7 +223,7 @@ async function getUserByPersonalAccessToken(pat: string, context: GraphQLContext
}
// Fonction pour valider un PAT avec l'API Logto et le remettre en cache
async function validatePATWithLogtoAPI(pat: string, context: GraphQLContext): Promise<string | null> {
async function validatePATWithLogtoAPI(pat: string, _context: GraphQLContext): Promise<string | null> {
const m2mToken = await getLogtoAccessToken();
try {
@ -260,8 +255,6 @@ async function validatePATWithLogtoAPI(pat: string, context: GraphQLContext): Pr
);
if (matchingToken) {
// Remettre en cache pour éviter de refaire cette recherche
context.patCache.register(pat, user.id, 24); // 24 heures
return user.id;
}
} catch {
@ -278,7 +271,7 @@ async function validatePATWithLogtoAPI(pat: string, context: GraphQLContext): Pr
}
async function createPersonalAccessToken(
userId: string,
context: GraphQLContext
_context: GraphQLContext
): Promise<{ token: string; expiresIn: number; userId: string }> {
const m2mToken = await getLogtoAccessToken();
const expiresAtTimestamp = Date.now() + 365 * 24 * 60 * 60 * 1000; // 1 an en millisecondes
@ -302,9 +295,6 @@ async function createPersonalAccessToken(
const { value: token, expiresAt } = response.data;
const expiresIn = Math.floor((expiresAt - Date.now()) / 1000); // Convertir en secondes
// Enregistrer l'association PAT -> userId dans le cache du contexte
context.patCache.register(token, userId, 24); // 24 heures
return { token, expiresIn, userId };
} catch (error) {
console.error("Erreur lors de la création du PAT:", error);
@ -347,9 +337,6 @@ async function createPersonalAccessToken(
console.warn("PAT existant trouvé et encore valide, réutilisation...");
const expiresIn = Math.floor((expiresAt - now) / 1000);
// Enregistrer l'association PAT -> userId dans le cache du contexte
context.patCache.register(existingPat.value, userId, 24);
return {
token: existingPat.value,
expiresIn,
@ -383,9 +370,6 @@ async function createPersonalAccessToken(
const { value: token, expiresAt: newExpiresAt } = recreateResponse.data;
const expiresIn = Math.floor((newExpiresAt - Date.now()) / 1000);
// Enregistrer l'association PAT -> userId dans le cache du contexte
context.patCache.register(token, userId, 24);
return { token, expiresIn, userId };
}
}
@ -529,7 +513,6 @@ async function authenticateUserFallback(email: string, password: string): Promis
export const resolvers = {
Query: {
async me(_: unknown, __: unknown, context: GraphQLContext) {
// Priorité 1: Utilisation du Personal Access Token (PAT)
if (context.personalAccessToken) {
const user = await getUserByPersonalAccessToken(context.personalAccessToken, context);
if (user) {
@ -583,16 +566,6 @@ export const resolvers = {
});
}
},
// Query de debug pour les statistiques du cache PAT
async patCacheStats(_: unknown, __: unknown, context: GraphQLContext) {
const stats = context.patCache.getStats();
return {
cacheSize: stats.size,
totalEntries: stats.entries,
timestamp: new Date().toISOString(),
};
},
},
Mutation: {
@ -623,7 +596,6 @@ export const resolvers = {
if (tokenData) {
accessToken = tokenData.token;
tokenExpiry = tokenData.expiresIn;
patContextCache.register(accessToken, user.id, 24);
}
} catch {
// On continue même si la création du token échoue
@ -707,7 +679,6 @@ export const resolvers = {
if (tokenData) {
accessToken = tokenData.token;
tokenExpiry = tokenData.expiresIn;
context.patCache.register(accessToken, user.id, 24); // Enregistre dans le cache du contexte
context.personalAccessToken = accessToken;
context.userId = user.id;
context.user = user;
@ -768,7 +739,6 @@ export const resolvers = {
} catch (error) {
console.error("Erreur lors de la suppression du PAT dans Logto:", error);
}
context.patCache.remove(context.personalAccessToken);
}
return {
@ -785,4 +755,4 @@ export const resolvers = {
},
};
export { getLogtoAccessToken };
export { getLogtoAccessToken, validatePATWithLogtoAPI };

View File

@ -14,7 +14,7 @@ export const yoga = createYoga<GraphQLContext>({
plugins: [
// eslint-disable-next-line react-hooks/rules-of-hooks
useCSRFPrevention({
requestHeaders: ["x-graphql-yoga-csrf"], // default
requestHeaders: ["x-graphql-yoga-csrf"],
}),
depthLimitPlugin,
introspectionPlugin,