2025-07-28 15:37:21 +02:00

758 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { GraphQLClient, gql } from "graphql-request";
import { useEffect, useState } from "react";
const graphQLClient = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql");
const GET_ORGANIZATIONS_QUERY = gql`
query GetOrganizations($page: Int, $pageSize: Int) {
organizations(page: $page, pageSize: $pageSize) {
success
message
totalCount
organizations {
id
name
description
customData
isMfaRequired
createdAt
}
}
}
`;
const GET_MY_ORGANIZATIONS_QUERY = gql`
query GetMyOrganizations($page: Int, $pageSize: Int) {
myOrganizations(page: $page, pageSize: $pageSize) {
success
message
totalCount
organizations {
id
name
description
customData
isMfaRequired
createdAt
}
}
}
`;
const CREATE_ORGANIZATION_MUTATION = gql`
mutation CreateOrganization($input: CreateOrganizationInput!) {
createOrganization(input: $input) {
success
message
organization {
id
name
description
customData
isMfaRequired
createdAt
}
}
}
`;
const GET_ORGANIZATION_USERS_QUERY = gql`
query GetOrganizationUsers($organizationId: ID!, $page: Int, $pageSize: Int) {
organizationUsers(organizationId: $organizationId, page: $page, pageSize: $pageSize) {
success
message
totalCount
users {
id
username
primaryEmail
name
avatar
organizationRoles {
id
name
description
type
}
}
}
}
`;
const GET_ORGANIZATION_ROLES_QUERY = gql`
query GetOrganizationRoles($organizationId: ID!, $page: Int, $pageSize: Int) {
organizationRoles(organizationId: $organizationId, page: $page, pageSize: $pageSize) {
success
message
totalCount
roles {
id
name
description
type
}
}
}
`;
const TEST_ORGANIZATIONS_API_QUERY = gql`
query TestOrganizationsAPI {
testOrganizationsAPI {
available
message
}
}
`;
interface Organization {
id: string;
name: string;
description?: string;
customData?: Record<string, unknown>;
isMfaRequired?: boolean;
createdAt: string;
}
interface OrganizationUser {
id: string;
username?: string;
primaryEmail?: string;
name?: string;
avatar?: string;
organizationRoles: Array<{
id: string;
name: string;
description?: string;
type: string;
}>;
}
interface OrganizationRole {
id: string;
name: string;
description?: string;
type: string;
}
interface OrganizationsResponse {
organizations: {
success: boolean;
message?: string;
totalCount?: number;
organizations?: Organization[];
};
}
interface MyOrganizationsResponse {
myOrganizations: {
success: boolean;
message?: string;
totalCount?: number;
organizations?: Organization[];
};
}
interface CreateOrganizationResponse {
createOrganization: {
success: boolean;
message?: string;
organization?: Organization;
};
}
interface OrganizationUsersResponse {
organizationUsers: {
success: boolean;
message?: string;
totalCount?: number;
users?: OrganizationUser[];
};
}
interface OrganizationRolesResponse {
organizationRoles: {
success: boolean;
message?: string;
totalCount?: number;
roles?: OrganizationRole[];
};
}
interface TestOrganizationsAPIResponse {
testOrganizationsAPI: {
available: boolean;
message: string;
};
}
export default function OrganizationsTestPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [myOrganizations, setMyOrganizations] = useState<Organization[]>([]);
const [selectedOrganization, setSelectedOrganization] = useState<Organization | null>(null);
const [organizationUsers, setOrganizationUsers] = useState<OrganizationUser[]>([]);
const [organizationRoles, setOrganizationRoles] = useState<OrganizationRole[]>([]);
const [loading, setLoading] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"all" | "mine" | "details">("all");
// Form data for creating organization
const [createFormData, setCreateFormData] = useState({
name: "",
description: "",
isMfaRequired: false,
});
const [accessToken, setAccessToken] = useState<string | null>(null);
const fetchAllOrganizations = async () => {
setLoading(true);
setError(null);
try {
const response = await graphQLClient.request<OrganizationsResponse>(GET_ORGANIZATIONS_QUERY, {
page: 1,
pageSize: 20,
});
if (response.organizations.success) {
const orgs = response.organizations.organizations || [];
setOrganizations(orgs);
} else {
setError(response.organizations.message || "Erreur lors du chargement des organisations");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
};
useEffect(() => {
// Récupérer le token depuis localStorage
const token = localStorage.getItem("accessToken");
setAccessToken(token);
// Auto-loading des organisations au démarrage de la page
const loadOrganizations = async () => {
setLoading(true);
setError(null);
try {
const response = await graphQLClient.request<OrganizationsResponse>(GET_ORGANIZATIONS_QUERY, {
page: 1,
pageSize: 20,
});
if (response.organizations.success) {
const orgs = response.organizations.organizations || [];
setOrganizations(orgs);
} else {
setError(response.organizations.message || "Erreur lors du chargement des organisations");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
};
loadOrganizations();
}, []);
const testOrganizationsAPI = async () => {
setLoading(true);
setError(null);
try {
const response = await graphQLClient.request<TestOrganizationsAPIResponse>(TEST_ORGANIZATIONS_API_QUERY);
if (response.testOrganizationsAPI.available) {
// Si l'API est disponible, on peut essayer de charger les organisations
setError(null);
// On ne recharge pas automatiquement, on laisse l'utilisateur choisir
alert(
`${response.testOrganizationsAPI.message}\n\nVous pouvez maintenant utiliser les fonctionnalités d'organisations.`
);
} else {
setError(response.testOrganizationsAPI.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue lors du test");
} finally {
setLoading(false);
}
};
const fetchMyOrganizations = async (token: string) => {
setLoading(true);
setError(null);
try {
const clientWithAuth = new GraphQLClient(process.env.NEXT_PUBLIC_APP_URL + "/api/graphql", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const response = await clientWithAuth.request<MyOrganizationsResponse>(GET_MY_ORGANIZATIONS_QUERY, {
page: 1,
pageSize: 20,
});
if (response.myOrganizations.success) {
setMyOrganizations(response.myOrganizations.organizations || []);
} else {
setError(response.myOrganizations.message || "Erreur lors du chargement de vos organisations");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
};
const fetchOrganizationDetails = async (organization: Organization) => {
setSelectedOrganization(organization);
setActiveTab("details");
setLoading(true);
setError(null);
try {
// Récupérer les utilisateurs
const usersResponse = await graphQLClient.request<OrganizationUsersResponse>(GET_ORGANIZATION_USERS_QUERY, {
organizationId: organization.id,
page: 1,
pageSize: 20,
});
if (usersResponse.organizationUsers.success) {
setOrganizationUsers(usersResponse.organizationUsers.users || []);
}
// Récupérer les rôles
const rolesResponse = await graphQLClient.request<OrganizationRolesResponse>(GET_ORGANIZATION_ROLES_QUERY, {
organizationId: organization.id,
page: 1,
pageSize: 20,
});
if (rolesResponse.organizationRoles.success) {
setOrganizationRoles(rolesResponse.organizationRoles.roles || []);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
};
const handleCreateOrganization = async (e: React.FormEvent) => {
e.preventDefault();
// Validation simple côté client
if (!createFormData.name.trim()) {
setError("Le nom de l'organisation est requis");
return;
}
setCreateLoading(true);
setError(null);
try {
const response = await graphQLClient.request<CreateOrganizationResponse>(CREATE_ORGANIZATION_MUTATION, {
input: {
name: createFormData.name,
description: createFormData.description || undefined,
isMfaRequired: createFormData.isMfaRequired,
},
});
if (response.createOrganization.success) {
alert("Organisation créée avec succès!");
setCreateFormData({ name: "", description: "", isMfaRequired: false });
// Recharger la liste si on est sur l'onglet "all"
if (activeTab === "all") {
await fetchAllOrganizations();
}
} else {
setError(response.createOrganization.message || "Erreur lors de la création");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setCreateLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-bold text-gray-900">🏢 Test des Organisations Logto</h1>
{/* Info Section */}
<div className="mb-6 rounded-md border border-blue-200 bg-blue-50 p-4">
<h3 className="text-sm font-medium text-blue-800"> À propos des organisations Logto</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
Les organisations permettent de gérer des environnements multi-tenant (B2B/SaaS). Cette fonctionnalité
peut nécessiter :
</p>
<ul className="mt-2 ml-4 list-disc space-y-1">
<li>Un plan Logto qui inclut les organisations</li>
<li>L&apos;activation dans votre console Logto</li>
<li>La configuration d&apos;un template d&apos;organisation</li>
</ul>
<p className="mt-2">
<a
href="https://docs.logto.io/docs/recipes/organizations/"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
📚 Documentation Logto sur les organisations
</a>
</p>
</div>
</div>
{/* Navigation Tabs */}
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => {
setActiveTab("all");
fetchAllOrganizations();
}}
className={`border-b-2 px-1 py-2 text-sm font-medium ${
activeTab === "all"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
disabled={loading}
>
Toutes les organisations
</button>
<button
onClick={() => {
setActiveTab("mine");
if (accessToken) {
fetchMyOrganizations(accessToken);
}
}}
className={`border-b-2 px-1 py-2 text-sm font-medium ${
activeTab === "mine"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
disabled={!accessToken || loading}
>
Mes organisations
{!accessToken && " (Connexion requise)"}
</button>
{selectedOrganization && (
<button
onClick={() => setActiveTab("details")}
className={`border-b-2 px-1 py-2 text-sm font-medium ${
activeTab === "details"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
>
Détails: {selectedOrganization.name}
</button>
)}
</nav>
</div>
{/* API Test Section */}
<div className="mb-8 rounded-md border border-gray-200 bg-gray-50 p-4">
<h3 className="mb-4 text-lg font-medium text-gray-800">🔬 Test de l&apos;API des organisations</h3>
<div className="flex items-center gap-4">
<button
onClick={testOrganizationsAPI}
disabled={loading}
className="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "⏳ Test..." : "Tester l'API des organisations"}
</button>
<p className="text-sm text-gray-600">
Cliquez pour vérifier si l&apos;API des organisations est disponible sur votre instance Logto.
</p>
</div>
</div>
{/* Create Organization Form */}
<div className="mb-8 rounded-md border border-blue-200 bg-blue-50 p-4">
<h3 className="mb-4 text-lg font-medium text-blue-800">Créer une nouvelle organisation</h3>
<form onSubmit={handleCreateOrganization} className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nom (requis)
</label>
<input
type="text"
id="name"
value={createFormData.name}
onChange={e => setCreateFormData(prev => ({ ...prev, name: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder="Nom de l'organisation"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<input
type="text"
id="description"
value={createFormData.description}
onChange={e => setCreateFormData(prev => ({ ...prev, description: e.target.value }))}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder="Description de l'organisation"
/>
</div>
<div className="flex items-end">
<label className="flex items-center">
<input
type="checkbox"
checked={createFormData.isMfaRequired}
onChange={e => setCreateFormData(prev => ({ ...prev, isMfaRequired: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">MFA requis</span>
</label>
<button
type="submit"
disabled={createLoading}
className="ml-4 rounded-md 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 disabled:cursor-not-allowed disabled:opacity-50"
>
{createLoading ? "⏳ Création..." : "Créer"}
</button>
</div>
</form>
</div>
{/* Error Display */}
{error && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 p-4">
<h3 className="text-sm font-medium text-red-800">Erreur</h3>
<p className="mt-2 text-sm text-red-700">{error}</p>
{error.includes("n'est pas disponible") && (
<div className="mt-4 rounded border border-yellow-200 bg-yellow-50 p-3">
<h4 className="text-sm font-medium text-yellow-800">💡 Comment activer les organisations</h4>
<div className="mt-2 text-sm text-yellow-700">
<p>Les organisations Logto peuvent nécessiter :</p>
<ul className="mt-1 ml-4 list-disc space-y-1">
<li>Un plan Logto qui inclut cette fonctionnalité</li>
<li>L&apos;activation des organisations dans votre console Logto</li>
<li>La configuration d&apos;un template d&apos;organisation</li>
</ul>
<p className="mt-2">
Consultez la{" "}
<a
href="https://docs.logto.io/docs/recipes/organizations/"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
documentation Logto sur les organisations
</a>{" "}
pour plus d&apos;informations.
</p>
</div>
</div>
)}
</div>
)}
{/* Loading */}
{loading && (
<div className="mb-6 text-center">
<p className="text-gray-600">Chargement...</p>
</div>
)}
{/* Content based on active tab */}
{activeTab === "all" && (
<div>
<h2 className="mb-4 text-xl font-semibold text-gray-900">
Toutes les organisations ({organizations.length})
</h2>
{organizations.length === 0 ? (
<p className="text-gray-600">Aucune organisation trouvée.</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{organizations.map(org => (
<div
key={org.id}
className="cursor-pointer rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
onClick={() => fetchOrganizationDetails(org)}
>
<h3 className="font-medium text-gray-900">{org.name}</h3>
{org.description && <p className="mt-1 text-sm text-gray-600">{org.description}</p>}
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
<span>ID: {org.id.substring(0, 8)}...</span>
{org.isMfaRequired && (
<span className="rounded bg-yellow-100 px-2 py-1 text-yellow-800">MFA</span>
)}
</div>
<p className="mt-1 text-xs text-gray-500">
Créée: {new Date(parseInt(org.createdAt)).toLocaleDateString()}
</p>
</div>
))}
</div>
)}
</div>
)}
{activeTab === "mine" && (
<div>
<h2 className="mb-4 text-xl font-semibold text-gray-900">Mes organisations ({myOrganizations.length})</h2>
{!accessToken ? (
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
<p className="text-sm text-yellow-700">
Veuillez vous connecter pour voir vos organisations.{" "}
<a href="/login" className="underline hover:no-underline">
Se connecter
</a>
</p>
</div>
) : myOrganizations.length === 0 ? (
<p className="text-gray-600">Vous n&apos;êtes membre d&apos;aucune organisation.</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{myOrganizations.map(org => (
<div
key={org.id}
className="cursor-pointer rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
onClick={() => fetchOrganizationDetails(org)}
>
<h3 className="font-medium text-gray-900">{org.name}</h3>
{org.description && <p className="mt-1 text-sm text-gray-600">{org.description}</p>}
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
<span>ID: {org.id.substring(0, 8)}...</span>
{org.isMfaRequired && (
<span className="rounded bg-yellow-100 px-2 py-1 text-yellow-800">MFA</span>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === "details" && selectedOrganization && (
<div>
<h2 className="mb-4 text-xl font-semibold text-gray-900">Détails: {selectedOrganization.name}</h2>
<div className="grid gap-6 md:grid-cols-2">
{/* Organization Users */}
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="mb-3 font-medium text-gray-900">Utilisateurs ({organizationUsers.length})</h3>
{organizationUsers.length === 0 ? (
<p className="text-sm text-gray-600">Aucun utilisateur.</p>
) : (
<div className="space-y-2">
{organizationUsers.map(user => (
<div key={user.id} className="rounded bg-gray-50 p-3">
<p className="text-sm font-medium">{user.name || user.username || "Utilisateur"}</p>
{user.primaryEmail && <p className="text-xs text-gray-600">{user.primaryEmail}</p>}
{user.organizationRoles.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{user.organizationRoles.map(role => (
<span key={role.id} className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
{role.name}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Organization Roles */}
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="mb-3 font-medium text-gray-900">Rôles ({organizationRoles.length})</h3>
{organizationRoles.length === 0 ? (
<p className="text-sm text-gray-600">Aucun rôle défini.</p>
) : (
<div className="space-y-2">
{organizationRoles.map(role => (
<div key={role.id} className="rounded bg-gray-50 p-3">
<p className="text-sm font-medium">{role.name}</p>
{role.description && <p className="text-xs text-gray-600">{role.description}</p>}
<span className="mt-1 inline-block rounded bg-gray-200 px-2 py-1 text-xs text-gray-700">
{role.type}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Organization Info */}
<div className="mt-6 rounded-lg border border-gray-200 p-4">
<h3 className="mb-3 font-medium text-gray-900">Informations</h3>
<dl className="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
<div>
<dt className="font-medium text-gray-700">ID:</dt>
<dd className="text-gray-600">{selectedOrganization.id}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Nom:</dt>
<dd className="text-gray-600">{selectedOrganization.name}</dd>
</div>
{selectedOrganization.description && (
<div className="md:col-span-2">
<dt className="font-medium text-gray-700">Description:</dt>
<dd className="text-gray-600">{selectedOrganization.description}</dd>
</div>
)}
<div>
<dt className="font-medium text-gray-700">MFA requis:</dt>
<dd className="text-gray-600">{selectedOrganization.isMfaRequired ? "Oui" : "Non"}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Créée le:</dt>
<dd className="text-gray-600">
{new Date(parseInt(selectedOrganization.createdAt)).toLocaleString()}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Navigation Links */}
<div className="mt-8 rounded-md border border-blue-200 bg-blue-50 p-4">
<h3 className="text-sm font-medium text-blue-800">Navigation</h3>
<div className="mt-2 space-y-1">
<div>
<a href="/login" className="text-sm text-blue-600 underline hover:text-blue-500">
🔐 Se connecter
</a>
</div>
<div>
<a href="/profile" className="text-sm text-blue-600 underline hover:text-blue-500">
👤 Profil utilisateur
</a>
</div>
<div>
<a href="/test-users" className="text-sm text-blue-600 underline hover:text-blue-500">
👥 Liste des utilisateurs
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}