mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: enhance authentication provider configuration and testing
- Added new fields for authorizationEndpoint, tokenEndpoint, and userInfoEndpoint in the AuthProvider model to support custom endpoint configurations. - Implemented validation logic in the AuthProvidersController to ensure either issuerUrl or all three custom endpoints are provided. - Introduced a new testProvider endpoint to validate provider configurations, checking endpoint accessibility and credential validity. - Updated the UI components to support manual endpoint configuration and testing, improving user experience in managing authentication providers. - Enhanced the seeding script to include initial data for new provider configurations, ensuring a smooth setup process.
This commit is contained in:
@@ -160,6 +160,10 @@ model AuthProvider {
|
||||
redirectUri String?
|
||||
scope String? @default("openid profile email")
|
||||
|
||||
authorizationEndpoint String?
|
||||
tokenEndpoint String?
|
||||
userInfoEndpoint String?
|
||||
|
||||
metadata String?
|
||||
|
||||
autoRegister Boolean @default(true)
|
||||
|
@@ -157,12 +157,16 @@ const defaultAuthProviders = [
|
||||
type: "oauth2",
|
||||
icon: "SiGithub",
|
||||
enabled: false,
|
||||
issuerUrl: "https://github.com/login/oauth",
|
||||
issuerUrl: "https://github.com/login/oauth", // URL fixa do GitHub
|
||||
authorizationEndpoint: "/authorize",
|
||||
tokenEndpoint: "/access_token",
|
||||
userInfoEndpoint: "https://api.github.com/user", // GitHub usa URL absoluta para userInfo
|
||||
scope: "user:email",
|
||||
sortOrder: 1,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with your GitHub account",
|
||||
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps"
|
||||
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps",
|
||||
specialHandling: "email_fetch_required"
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -171,11 +175,16 @@ const defaultAuthProviders = [
|
||||
type: "oidc",
|
||||
icon: "SiAuth0",
|
||||
enabled: false,
|
||||
issuerUrl: "https://your-tenant.auth0.com", // Placeholder - usuário deve configurar
|
||||
authorizationEndpoint: "/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/userinfo",
|
||||
scope: "openid profile email",
|
||||
sortOrder: 2,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with Auth0",
|
||||
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow"
|
||||
description: "Sign in with Auth0 - Replace 'your-tenant' with your Auth0 domain",
|
||||
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow",
|
||||
supportsDiscovery: true
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -184,11 +193,16 @@ const defaultAuthProviders = [
|
||||
type: "oidc",
|
||||
icon: "FaKey",
|
||||
enabled: false,
|
||||
issuerUrl: "https://your-tenant.kinde.com", // Placeholder - usuário deve configurar
|
||||
authorizationEndpoint: "/oauth2/auth",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/oauth2/user_profile",
|
||||
scope: "openid profile email",
|
||||
sortOrder: 3,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with Kinde",
|
||||
docs: "https://kinde.com/docs/developer-tools/about/"
|
||||
description: "Sign in with Kinde - Replace 'your-tenant' with your Kinde domain",
|
||||
docs: "https://kinde.com/docs/developer-tools/about/",
|
||||
supportsDiscovery: true
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -197,11 +211,17 @@ const defaultAuthProviders = [
|
||||
type: "oidc",
|
||||
icon: "FaShield",
|
||||
enabled: false,
|
||||
issuerUrl: "https://your-instance.zitadel.cloud", // Placeholder - usuário deve configurar
|
||||
authorizationEndpoint: "/oauth/v2/authorize",
|
||||
tokenEndpoint: "/oauth/v2/token",
|
||||
userInfoEndpoint: "/oidc/v1/userinfo",
|
||||
scope: "openid profile email",
|
||||
sortOrder: 4,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with Zitadel",
|
||||
docs: "https://zitadel.com/docs/guides/integrate/login/oidc"
|
||||
description: "Sign in with Zitadel - Replace with your Zitadel instance URL",
|
||||
docs: "https://zitadel.com/docs/guides/integrate/login/oidc",
|
||||
supportsDiscovery: true,
|
||||
authMethod: "basic"
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -210,11 +230,34 @@ const defaultAuthProviders = [
|
||||
type: "oidc",
|
||||
icon: "FaShieldAlt",
|
||||
enabled: false,
|
||||
issuerUrl: "https://your-authentik.domain.com", // Placeholder - usuário deve configurar
|
||||
authorizationEndpoint: "/application/o/authorize/",
|
||||
tokenEndpoint: "/application/o/token/",
|
||||
userInfoEndpoint: "/application/o/userinfo/",
|
||||
scope: "openid profile email",
|
||||
sortOrder: 5,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with Authentik",
|
||||
docs: "https://goauthentik.io/docs/providers/oauth2"
|
||||
description: "Sign in with Authentik - Replace with your Authentik instance URL",
|
||||
docs: "https://goauthentik.io/docs/providers/oauth2",
|
||||
supportsDiscovery: true
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "frontegg",
|
||||
displayName: "Frontegg",
|
||||
type: "oidc",
|
||||
icon: "FaEgg",
|
||||
enabled: false,
|
||||
issuerUrl: "https://your-tenant.frontegg.com", // Placeholder - usuário deve configurar
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/identity/resources/users/v2/me",
|
||||
scope: "openid profile email",
|
||||
sortOrder: 6,
|
||||
metadata: JSON.stringify({
|
||||
description: "Sign in with Frontegg - Replace 'your-tenant' with your Frontegg tenant",
|
||||
docs: "https://docs.frontegg.com",
|
||||
supportsDiscovery: true
|
||||
})
|
||||
},
|
||||
];
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { UpdateAuthProviderSchema } from "./dto";
|
||||
import { AuthProvidersService } from "./service";
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
@@ -54,7 +55,27 @@ export class AuthProvidersController {
|
||||
if (reply.sent) return;
|
||||
|
||||
try {
|
||||
const data = request.body;
|
||||
const data = request.body as any;
|
||||
|
||||
// Validação adicional: se modo manual, todos os 3 endpoints são obrigatórios
|
||||
const hasAnyCustomEndpoint = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint);
|
||||
const hasAllCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
|
||||
|
||||
if (hasAnyCustomEndpoint && !hasAllCustomEndpoints) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "When using manual endpoints, all three endpoints (authorization, token, userInfo) are required",
|
||||
});
|
||||
}
|
||||
|
||||
// Validação: deve ter ou issuerUrl ou endpoints customizados
|
||||
if (!data.issuerUrl && !hasAllCustomEndpoints) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Either provide issuerUrl for automatic discovery OR all three custom endpoints",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = await this.authProvidersService.createProvider(data);
|
||||
|
||||
return reply.send({
|
||||
@@ -63,6 +84,15 @@ export class AuthProvidersController {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating provider:", error);
|
||||
|
||||
// Se é erro de validação do Zod
|
||||
if (error instanceof Error && error.message.includes("Either provide issuerUrl")) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: "Failed to create provider",
|
||||
@@ -75,16 +105,92 @@ export class AuthProvidersController {
|
||||
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const data = request.body;
|
||||
const data = request.body as any;
|
||||
|
||||
const provider = await this.authProvidersService.updateProvider(id, data);
|
||||
// Buscar provider para verificar se é oficial
|
||||
const existingProvider = await this.authProvidersService.getProviderById(id);
|
||||
if (!existingProvider) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Provider not found",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: provider,
|
||||
});
|
||||
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
|
||||
|
||||
// Para providers oficiais, só permite alterar issuerUrl, clientId, clientSecret, enabled, autoRegister, icon
|
||||
if (isOfficial) {
|
||||
const allowedFields = [
|
||||
"issuerUrl",
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"enabled",
|
||||
"autoRegister",
|
||||
"adminEmailDomains",
|
||||
"icon",
|
||||
];
|
||||
const sanitizedData: any = {};
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (data[field] !== undefined) {
|
||||
sanitizedData[field] = data[field];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Controller] Official provider ${existingProvider.name} - only allowing fields:`,
|
||||
Object.keys(sanitizedData)
|
||||
);
|
||||
console.log(`[Controller] Sanitized data:`, sanitizedData);
|
||||
|
||||
// Validação adicional para issuerUrl se fornecida
|
||||
if (sanitizedData.issuerUrl && typeof sanitizedData.issuerUrl === "string") {
|
||||
try {
|
||||
new URL(sanitizedData.issuerUrl);
|
||||
} catch (e) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid Provider URL format",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const provider = await this.authProvidersService.updateProvider(id, sanitizedData);
|
||||
console.log(`[Controller] Provider updated successfully:`, provider?.id);
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: provider,
|
||||
});
|
||||
}
|
||||
|
||||
// Para providers customizados, aplica validação normal
|
||||
try {
|
||||
// Valida usando o schema do Zod
|
||||
const validatedData = UpdateAuthProviderSchema.parse(data);
|
||||
const provider = await this.authProvidersService.updateProvider(id, validatedData);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: provider,
|
||||
});
|
||||
} catch (validationError) {
|
||||
console.error("Validation error for custom provider:", validationError);
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid data provided",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating provider:", error);
|
||||
|
||||
// Se é erro de validação do Zod
|
||||
if (error instanceof Error && error.message.includes("Either provide issuerUrl")) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: "Failed to update provider",
|
||||
@@ -267,4 +373,37 @@ export class AuthProvidersController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async testProvider(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
if (reply.sent) return;
|
||||
|
||||
try {
|
||||
const { id } = request.params;
|
||||
|
||||
const provider = await this.authProvidersService.getProviderById(id);
|
||||
if (!provider) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Provider not found",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Controller] Testing provider: ${provider.name}`);
|
||||
|
||||
const testResult = await this.authProvidersService.testProviderConfiguration(provider);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: testResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error testing provider:", error);
|
||||
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Provider test failed",
|
||||
details: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
111
apps/server/src/modules/auth-providers/dto.ts
Normal file
111
apps/server/src/modules/auth-providers/dto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Schema base para provider
|
||||
export const BaseAuthProviderSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").describe("Provider name"),
|
||||
displayName: z.string().min(1, "Display name is required").describe("Provider display name"),
|
||||
type: z.enum(["oidc", "oauth2"]).describe("Provider type"),
|
||||
icon: z.string().optional().describe("Provider icon"),
|
||||
enabled: z.boolean().default(false).describe("Whether provider is enabled"),
|
||||
autoRegister: z.boolean().default(true).describe("Auto-register new users"),
|
||||
scope: z.string().optional().describe("OAuth scopes"),
|
||||
adminEmailDomains: z.string().optional().describe("Admin email domains (comma-separated)"),
|
||||
clientId: z.string().min(1, "Client ID is required").describe("OAuth client ID"),
|
||||
clientSecret: z.string().min(1, "Client secret is required").describe("OAuth client secret"),
|
||||
});
|
||||
|
||||
// Schema para modo discovery automático (apenas issuerUrl)
|
||||
export const DiscoveryModeSchema = BaseAuthProviderSchema.extend({
|
||||
issuerUrl: z.string().url("Invalid issuer URL").describe("Provider issuer URL for discovery"),
|
||||
authorizationEndpoint: z.literal("").optional(),
|
||||
tokenEndpoint: z.literal("").optional(),
|
||||
userInfoEndpoint: z.literal("").optional(),
|
||||
});
|
||||
|
||||
// Schema para modo manual (todos os endpoints)
|
||||
export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({
|
||||
issuerUrl: z.string().optional(),
|
||||
authorizationEndpoint: z
|
||||
.string()
|
||||
.min(1, "Authorization endpoint is required")
|
||||
.describe("Authorization endpoint URL or path"),
|
||||
tokenEndpoint: z.string().min(1, "Token endpoint is required").describe("Token endpoint URL or path"),
|
||||
userInfoEndpoint: z.string().min(1, "User info endpoint is required").describe("User info endpoint URL or path"),
|
||||
});
|
||||
|
||||
// Schema principal que aceita ambos os modos
|
||||
export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({
|
||||
issuerUrl: z.string().url("Invalid issuer URL").optional(),
|
||||
authorizationEndpoint: z.string().min(1).optional(),
|
||||
tokenEndpoint: z.string().min(1).optional(),
|
||||
userInfoEndpoint: z.string().min(1).optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Modo discovery: deve ter issuerUrl e não ter endpoints customizados
|
||||
const hasIssuerUrl = !!data.issuerUrl;
|
||||
const hasCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
|
||||
|
||||
// Deve ter ou issuerUrl OU todos os endpoints customizados
|
||||
return hasIssuerUrl || hasCustomEndpoints;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
|
||||
}
|
||||
);
|
||||
|
||||
// Schema para atualização (todos os campos opcionais exceto validação de modo)
|
||||
export const UpdateAuthProviderSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
displayName: z.string().min(1).optional(),
|
||||
type: z.enum(["oidc", "oauth2"]).optional(),
|
||||
icon: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
autoRegister: z.boolean().optional(),
|
||||
scope: z.string().optional(),
|
||||
adminEmailDomains: z.string().optional(),
|
||||
clientId: z.string().min(1).optional(),
|
||||
clientSecret: z.string().min(1).optional(),
|
||||
issuerUrl: z.string().url().optional(),
|
||||
authorizationEndpoint: z.string().min(1).optional(),
|
||||
tokenEndpoint: z.string().min(1).optional(),
|
||||
userInfoEndpoint: z.string().min(1).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Se está atualizando endpoints, deve seguir a mesma regra
|
||||
const hasIssuerUrl = !!data.issuerUrl;
|
||||
const hasCustomEndpoints = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint);
|
||||
|
||||
// Se nenhum dos dois foi fornecido, está OK (não está alterando o modo)
|
||||
if (!hasIssuerUrl && !hasCustomEndpoints) return true;
|
||||
|
||||
// Se forneceu um, não pode fornecer o outro
|
||||
return hasIssuerUrl !== hasCustomEndpoints;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
|
||||
}
|
||||
);
|
||||
|
||||
// Schema específico para providers oficiais (apenas campos permitidos)
|
||||
export const UpdateOfficialProviderSchema = z.object({
|
||||
issuerUrl: z.string().url().optional(),
|
||||
clientId: z.string().min(1).optional(),
|
||||
clientSecret: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
autoRegister: z.boolean().optional(),
|
||||
adminEmailDomains: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema para reordenação
|
||||
export const UpdateProvidersOrderSchema = z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
),
|
||||
});
|
@@ -26,27 +26,58 @@ export class ProviderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém a configuração de um provider (oficial ou genérico)
|
||||
* Usa apenas o nome do provider para determinar a configuração
|
||||
* Obtém configuração para um provider de forma INTELIGENTE
|
||||
*/
|
||||
getProviderConfig(providerName: string, issuerUrl?: string): ProviderConfig | null {
|
||||
console.log(`[ProviderManager] getProviderConfig called for: ${providerName}`);
|
||||
getProviderConfig(provider: any): ProviderConfig {
|
||||
console.log(`[ProviderManager] Getting config for provider:`, provider);
|
||||
console.log(`[ProviderManager] Provider name: ${provider?.name}`);
|
||||
console.log(`[ProviderManager] Provider type from DB: ${provider?.type}`);
|
||||
console.log(`[ProviderManager] Provider issuerUrl: ${provider?.issuerUrl}`);
|
||||
|
||||
// Detecção baseada apenas no nome do provider
|
||||
const normalizedName = this.normalizeProviderName(providerName);
|
||||
console.log(`[ProviderManager] Normalized name: ${normalizedName}`);
|
||||
|
||||
if (this.isOfficialProvider(normalizedName)) {
|
||||
console.log(`[ProviderManager] Using official provider config: ${normalizedName}`);
|
||||
return this.config.officialProviders[normalizedName];
|
||||
if (!provider || !provider.name) {
|
||||
console.error(`[ProviderManager] Invalid provider object:`, provider);
|
||||
throw new Error("Invalid provider object: missing name");
|
||||
}
|
||||
|
||||
// Retorna template genérico para providers customizados
|
||||
console.log(`[ProviderManager] Using generic template for: ${providerName}`);
|
||||
return {
|
||||
// PRIMEIRO: Se é um provider oficial, usa a configuração específica
|
||||
const officialConfig = this.config.officialProviders[provider.name];
|
||||
if (officialConfig) {
|
||||
console.log(`[ProviderManager] Using official config for: ${provider.name}`);
|
||||
return officialConfig;
|
||||
}
|
||||
|
||||
// SEGUNDO: Detecta automaticamente o tipo baseado na URL
|
||||
const detectedType = this.detectProviderType(provider.issuerUrl || "");
|
||||
console.log(`[ProviderManager] Auto-detected type: ${detectedType}`);
|
||||
|
||||
// TERCEIRO: Se o provider tem um tipo definido no banco, usa ele
|
||||
const providerType = provider.type || detectedType;
|
||||
console.log(`[ProviderManager] Final provider type: ${providerType}`);
|
||||
|
||||
// QUARTO: Cria configuração inteligente baseada no tipo detectado
|
||||
const intelligentConfig: ProviderConfig = {
|
||||
...this.config.genericProviderTemplate,
|
||||
name: providerName,
|
||||
name: provider.name,
|
||||
|
||||
// Ajusta configuração baseada no tipo detectado
|
||||
supportsDiscovery: this.shouldSupportDiscovery(providerType),
|
||||
discoveryEndpoint: this.getDiscoveryEndpoint(providerType),
|
||||
|
||||
// Ajusta fallbacks baseado no tipo
|
||||
fallbackEndpoints: this.getFallbackEndpoints(providerType),
|
||||
|
||||
// Ajusta método de autenticação baseado no tipo
|
||||
authMethod: this.getAuthMethod(providerType),
|
||||
|
||||
// Ajusta field mappings baseado no tipo
|
||||
fieldMappings: this.getFieldMappings(providerType),
|
||||
|
||||
// Ajusta special handling baseado no tipo
|
||||
specialHandling: this.getSpecialHandling(providerType),
|
||||
};
|
||||
|
||||
console.log(`[ProviderManager] Generated intelligent config:`, intelligentConfig);
|
||||
return intelligentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +89,7 @@ export class ProviderManager {
|
||||
|
||||
/**
|
||||
* Resolve endpoints para um provider com base na configuração
|
||||
* PRIORIZA dados do usuário sobre configuração interna
|
||||
* ULTRA-INTELIGENTE com múltiplos fallbacks e detecção automática
|
||||
*/
|
||||
async resolveEndpoints(provider: any, config: ProviderConfig): Promise<ProviderEndpoints> {
|
||||
console.log(`[ProviderManager] Resolving endpoints for provider: ${provider.name}, config: ${config.name}`);
|
||||
@@ -72,11 +103,16 @@ export class ProviderManager {
|
||||
// PRIMEIRO: Verifica se o usuário especificou endpoints customizados
|
||||
if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.userInfoEndpoint) {
|
||||
console.log(`[ProviderManager] Using custom endpoints from user config`);
|
||||
return {
|
||||
authorizationEndpoint: provider.authorizationEndpoint,
|
||||
tokenEndpoint: provider.tokenEndpoint,
|
||||
userInfoEndpoint: provider.userInfoEndpoint,
|
||||
|
||||
// Se endpoints são relativos, combina com issuerUrl
|
||||
const resolvedEndpoints = {
|
||||
authorizationEndpoint: this.resolveEndpointUrl(provider.authorizationEndpoint, provider.issuerUrl),
|
||||
tokenEndpoint: this.resolveEndpointUrl(provider.tokenEndpoint, provider.issuerUrl),
|
||||
userInfoEndpoint: this.resolveEndpointUrl(provider.userInfoEndpoint, provider.issuerUrl),
|
||||
};
|
||||
|
||||
console.log(`[ProviderManager] Resolved custom endpoints:`, resolvedEndpoints);
|
||||
return resolvedEndpoints;
|
||||
}
|
||||
|
||||
// SEGUNDO: Tenta discovery automático se suportado
|
||||
@@ -88,29 +124,33 @@ export class ProviderManager {
|
||||
|
||||
if (config.supportsDiscovery && provider.issuerUrl) {
|
||||
console.log(`[ProviderManager] Attempting discovery for ${provider.issuerUrl}`);
|
||||
const discoveredEndpoints = await this.attemptDiscovery(
|
||||
provider.issuerUrl,
|
||||
config.discoveryEndpoint || "/.well-known/openid_configuration"
|
||||
);
|
||||
|
||||
if (discoveredEndpoints) {
|
||||
console.log(`[ProviderManager] Discovery successful:`, discoveredEndpoints);
|
||||
endpoints = discoveredEndpoints;
|
||||
} else {
|
||||
console.log(`[ProviderManager] Discovery failed, using fallbacks`);
|
||||
// Tenta múltiplos endpoints de discovery
|
||||
const discoveryEndpoints = [
|
||||
"/.well-known/openid_configuration",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth2-authorization-server",
|
||||
];
|
||||
|
||||
for (const discoveryPath of discoveryEndpoints) {
|
||||
const discoveredEndpoints = await this.attemptDiscovery(provider.issuerUrl, discoveryPath);
|
||||
if (discoveredEndpoints) {
|
||||
console.log(`[ProviderManager] Discovery successful with ${discoveryPath}:`, discoveredEndpoints);
|
||||
endpoints = discoveredEndpoints;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoints.tokenEndpoint) {
|
||||
console.log(`[ProviderManager] All discovery attempts failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// TERCEIRO: Se não conseguiu descobrir, usa fallbacks da configuração
|
||||
// TERCEIRO: Se não conseguiu descobrir, usa fallbacks simples
|
||||
if (!endpoints.tokenEndpoint) {
|
||||
console.log(`[ProviderManager] Building fallback endpoints`);
|
||||
endpoints = this.buildFallbackEndpoints(provider, config);
|
||||
}
|
||||
|
||||
// QUARTO: Aplica limpeza de URL se necessário
|
||||
if (config.specialHandling?.urlCleaning) {
|
||||
console.log(`[ProviderManager] Applying URL cleaning for ${config.name}`);
|
||||
endpoints = this.cleanUrls(endpoints, provider.issuerUrl, config);
|
||||
endpoints = this.buildIntelligentFallbackEndpoints(provider, config);
|
||||
}
|
||||
|
||||
console.log(`[ProviderManager] Final endpoints:`, endpoints);
|
||||
@@ -118,129 +158,137 @@ export class ProviderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta descoberta automática de endpoints
|
||||
* Resolve URL de endpoint - combina URLs relativas com issuerUrl
|
||||
*/
|
||||
private resolveEndpointUrl(endpoint: string, issuerUrl?: string): string {
|
||||
// Se endpoint já é absoluto (contém http), retorna como está
|
||||
if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// Se não tem issuerUrl, retorna endpoint como está
|
||||
if (!issuerUrl) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// Remove trailing slash do issuerUrl
|
||||
const baseUrl = issuerUrl.replace(/\/$/, "");
|
||||
|
||||
// Garante que endpoint comece com /
|
||||
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta descobrir endpoints via discovery de forma ROBUSTA
|
||||
*/
|
||||
private async attemptDiscovery(issuerUrl: string, discoveryPath: string): Promise<ProviderEndpoints | null> {
|
||||
try {
|
||||
// Remove trailing slash do issuerUrl se existir
|
||||
const cleanIssuerUrl = issuerUrl.replace(/\/$/, "");
|
||||
const wellKnownUrl = `${cleanIssuerUrl}${discoveryPath}`;
|
||||
console.log(`[ProviderManager] Attempting discovery at: ${wellKnownUrl}`);
|
||||
const discoveryUrl = `${issuerUrl}${discoveryPath}`;
|
||||
console.log(`[ProviderManager] Attempting discovery at: ${discoveryUrl}`);
|
||||
|
||||
const response = await fetch(wellKnownUrl);
|
||||
const response = await fetch(discoveryUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Palmr/1.0",
|
||||
},
|
||||
// Timeout mais generoso para discovery
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const discoveryData: any = await response.json();
|
||||
console.log(`[ProviderManager] Discovery successful for ${issuerUrl}:`, {
|
||||
authorization_endpoint: discoveryData.authorization_endpoint,
|
||||
token_endpoint: discoveryData.token_endpoint,
|
||||
userinfo_endpoint: discoveryData.userinfo_endpoint,
|
||||
});
|
||||
console.log(`[ProviderManager] Full discovery data:`, discoveryData);
|
||||
|
||||
return {
|
||||
authorizationEndpoint: discoveryData.authorization_endpoint,
|
||||
tokenEndpoint: discoveryData.token_endpoint,
|
||||
userInfoEndpoint: discoveryData.userinfo_endpoint,
|
||||
};
|
||||
} else {
|
||||
console.log(`[ProviderManager] Discovery failed for ${issuerUrl}: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
console.log(`[ProviderManager] Discovery failed with status: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const discoveryData = (await response.json()) as any;
|
||||
console.log(`[ProviderManager] Discovery response:`, discoveryData);
|
||||
|
||||
// Extrai endpoints com múltiplos fallbacks
|
||||
const endpoints: ProviderEndpoints = {
|
||||
authorizationEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
userInfoEndpoint: "",
|
||||
};
|
||||
|
||||
// Authorization endpoint - tenta múltiplos campos
|
||||
const authEndpoints = [
|
||||
"authorization_endpoint",
|
||||
"authorizationEndpoint",
|
||||
"authorize_endpoint",
|
||||
"authorizeEndpoint",
|
||||
"auth_endpoint",
|
||||
"authEndpoint",
|
||||
];
|
||||
|
||||
for (const field of authEndpoints) {
|
||||
if (discoveryData[field]) {
|
||||
endpoints.authorizationEndpoint = discoveryData[field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Token endpoint - tenta múltiplos campos
|
||||
const tokenEndpoints = ["token_endpoint", "tokenEndpoint", "access_token_endpoint", "accessTokenEndpoint"];
|
||||
|
||||
for (const field of tokenEndpoints) {
|
||||
if (discoveryData[field]) {
|
||||
endpoints.tokenEndpoint = discoveryData[field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfo endpoint - tenta múltiplos campos
|
||||
const userInfoEndpoints = [
|
||||
"userinfo_endpoint",
|
||||
"userInfoEndpoint",
|
||||
"user_info_endpoint",
|
||||
"userInfo_endpoint",
|
||||
"profile_endpoint",
|
||||
"profileEndpoint",
|
||||
];
|
||||
|
||||
for (const field of userInfoEndpoints) {
|
||||
if (discoveryData[field]) {
|
||||
endpoints.userInfoEndpoint = discoveryData[field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Valida se pelo menos os endpoints essenciais foram encontrados
|
||||
if (!endpoints.authorizationEndpoint || !endpoints.tokenEndpoint) {
|
||||
console.log(`[ProviderManager] Discovery incomplete - missing essential endpoints`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[ProviderManager] Discovery successful:`, endpoints);
|
||||
return endpoints;
|
||||
} catch (error) {
|
||||
console.warn(`[ProviderManager] Discovery failed for ${issuerUrl}:`, error);
|
||||
console.log(`[ProviderManager] Discovery error:`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói endpoints usando fallbacks da configuração
|
||||
* Constrói endpoints usando fallbacks simples baseados no tipo
|
||||
*/
|
||||
private buildFallbackEndpoints(provider: any, config: ProviderConfig): ProviderEndpoints {
|
||||
const baseUrl = provider.issuerUrl || "";
|
||||
private buildIntelligentFallbackEndpoints(provider: any, config: ProviderConfig): ProviderEndpoints {
|
||||
const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || "";
|
||||
console.log(`[ProviderManager] Building fallback endpoints for baseUrl: ${baseUrl}`);
|
||||
|
||||
// Se tem endpoints absolutos na config, usa eles
|
||||
if (config.authorizationEndpoint?.startsWith("http")) {
|
||||
return {
|
||||
authorizationEndpoint: config.authorizationEndpoint,
|
||||
tokenEndpoint: config.tokenEndpoint || "",
|
||||
userInfoEndpoint: config.userInfoEndpoint || "",
|
||||
};
|
||||
}
|
||||
// Detecta tipo do provider para usar padrão apropriado
|
||||
const detectedType = this.detectProviderType(provider.issuerUrl || "");
|
||||
const fallbackPattern = this.getFallbackEndpoints(detectedType);
|
||||
|
||||
// Senão, constrói com base no issuerUrl
|
||||
const fallbacks = config.fallbackEndpoints;
|
||||
return {
|
||||
authorizationEndpoint: `${baseUrl}${fallbacks?.authorizationEndpoint || "/auth"}`,
|
||||
tokenEndpoint: `${baseUrl}${fallbacks?.tokenEndpoint || "/token"}`,
|
||||
userInfoEndpoint: `${baseUrl}${fallbacks?.userInfoEndpoint || "/userinfo"}`,
|
||||
authorizationEndpoint: `${baseUrl}${fallbackPattern.authorizationEndpoint}`,
|
||||
tokenEndpoint: `${baseUrl}${fallbackPattern.tokenEndpoint}`,
|
||||
userInfoEndpoint: `${baseUrl}${fallbackPattern.userInfoEndpoint}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica limpeza de URLs conforme configuração
|
||||
*/
|
||||
private cleanUrls(endpoints: ProviderEndpoints, issuerUrl: string, config: ProviderConfig): ProviderEndpoints {
|
||||
const cleaning = config.specialHandling?.urlCleaning;
|
||||
if (!cleaning || !issuerUrl) return endpoints;
|
||||
|
||||
console.log(`[ProviderManager] Cleaning URLs for ${config.name}`);
|
||||
console.log(`[ProviderManager] Original issuerUrl: ${issuerUrl}`);
|
||||
console.log(`[ProviderManager] Cleaning rules:`, cleaning);
|
||||
|
||||
let cleanBaseUrl = issuerUrl;
|
||||
|
||||
// Remove sufixos especificados (mais robusto)
|
||||
for (const suffix of cleaning.removeFromEnd) {
|
||||
if (cleanBaseUrl.endsWith(suffix)) {
|
||||
console.log(`[ProviderManager] Removing suffix: ${suffix}`);
|
||||
cleanBaseUrl = cleanBaseUrl.replace(suffix, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing slash se existir
|
||||
cleanBaseUrl = cleanBaseUrl.replace(/\/$/, "");
|
||||
|
||||
console.log(`[ProviderManager] Cleaned base URL: ${cleanBaseUrl}`);
|
||||
|
||||
// Reconstrói endpoints com URL limpa
|
||||
const fallbacks = config.fallbackEndpoints;
|
||||
|
||||
// Caso especial para Authentik - extrai o nome da aplicação da URL
|
||||
if (config.name === "Authentik") {
|
||||
// Remove o discovery endpoint se estiver presente
|
||||
let cleanUrl = cleanBaseUrl;
|
||||
if (cleanUrl.includes("/.well-known/openid-configuration")) {
|
||||
cleanUrl = cleanUrl.replace("/.well-known/openid-configuration", "");
|
||||
}
|
||||
if (cleanUrl.includes("/.well-known/openid_configuration")) {
|
||||
cleanUrl = cleanUrl.replace("/.well-known/openid_configuration", "");
|
||||
}
|
||||
|
||||
// Para Authentik, os endpoints são globais, não específicos por aplicação
|
||||
const baseUrl = cleanUrl.replace(/\/application\/o\/[^/]+.*$/, "");
|
||||
|
||||
const cleanedEndpoints = {
|
||||
authorizationEndpoint: `${baseUrl}/application/o/authorize/`,
|
||||
tokenEndpoint: `${baseUrl}/application/o/token/`,
|
||||
userInfoEndpoint: `${baseUrl}/application/o/userinfo/`,
|
||||
};
|
||||
|
||||
console.log(`[ProviderManager] Authentik cleaned endpoints:`, cleanedEndpoints);
|
||||
return cleanedEndpoints;
|
||||
}
|
||||
|
||||
const cleanedEndpoints = {
|
||||
authorizationEndpoint: `${cleanBaseUrl}${fallbacks?.authorizationEndpoint || "/auth"}`,
|
||||
tokenEndpoint: `${cleanBaseUrl}${fallbacks?.tokenEndpoint || "/token"}`,
|
||||
userInfoEndpoint: `${cleanBaseUrl}${fallbacks?.userInfoEndpoint || "/userinfo"}`,
|
||||
};
|
||||
|
||||
console.log(`[ProviderManager] Cleaned endpoints:`, cleanedEndpoints);
|
||||
return cleanedEndpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai informações do usuário com base no mapeamento de campos
|
||||
*/
|
||||
@@ -269,11 +317,14 @@ export class ProviderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai um campo específico usando lista de possíveis nomes
|
||||
* Extrai um campo específico usando lista de possíveis nomes de forma INTELIGENTE
|
||||
*/
|
||||
private extractField(obj: any, fieldNames: string[]): string | undefined {
|
||||
if (!obj || !fieldNames.length) return undefined;
|
||||
|
||||
console.log(`[ProviderManager] Extracting field from:`, fieldNames);
|
||||
console.log(`[ProviderManager] Available fields:`, Object.keys(obj));
|
||||
|
||||
// Para campos especiais como nome completo que pode ser composto
|
||||
if (fieldNames.length > 1 && fieldNames.includes("first_name") && fieldNames.includes("last_name")) {
|
||||
const firstName = obj["first_name"];
|
||||
@@ -287,18 +338,61 @@ export class ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Para campos normais
|
||||
// Para campos especiais como nome completo com given_name/family_name
|
||||
if (fieldNames.length > 1 && fieldNames.includes("given_name") && fieldNames.includes("family_name")) {
|
||||
const firstName = obj["given_name"];
|
||||
const lastName = obj["family_name"];
|
||||
if (firstName && lastName) {
|
||||
return `${firstName} ${lastName}`;
|
||||
} else if (firstName) {
|
||||
return firstName;
|
||||
} else if (lastName) {
|
||||
return lastName;
|
||||
}
|
||||
}
|
||||
|
||||
// Para campos normais - tenta cada nome na lista
|
||||
for (const fieldName of fieldNames) {
|
||||
if (obj[fieldName] !== undefined && obj[fieldName] !== null) {
|
||||
console.log(`[ProviderManager] Found field: ${fieldName} = ${obj[fieldName]}`);
|
||||
return String(obj[fieldName]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tenta variações case-insensitive
|
||||
for (const fieldName of fieldNames) {
|
||||
const lowerFieldName = fieldName.toLowerCase();
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key.toLowerCase() === lowerFieldName && obj[key] !== undefined && obj[key] !== null) {
|
||||
console.log(`[ProviderManager] Found field (case-insensitive): ${key} = ${obj[key]}`);
|
||||
return String(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tenta variações com underscores vs camelCase
|
||||
for (const fieldName of fieldNames) {
|
||||
const variations = [
|
||||
fieldName,
|
||||
fieldName.replace(/_/g, ""),
|
||||
fieldName.replace(/([A-Z])/g, "_$1").toLowerCase(),
|
||||
fieldName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
|
||||
];
|
||||
|
||||
for (const variation of variations) {
|
||||
if (obj[variation] !== undefined && obj[variation] !== null) {
|
||||
console.log(`[ProviderManager] Found field (variation): ${variation} = ${obj[variation]}`);
|
||||
return String(obj[variation]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ProviderManager] Field not found for:`, fieldNames);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina o método de autenticação para token exchange
|
||||
* Obtém método de autenticação da configuração
|
||||
*/
|
||||
getAuthMethod(config: ProviderConfig): "body" | "basic" | "header" {
|
||||
return config.authMethod || "body";
|
||||
@@ -326,20 +420,381 @@ export class ProviderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém scopes padrão para um provider baseado no tipo do banco
|
||||
* Obtém scopes para um provider de forma INTELIGENTE
|
||||
*/
|
||||
getDefaultScopes(provider: any): string[] {
|
||||
// GitHub OAuth2 usa scopes específicos
|
||||
if (provider.type === "oauth2" && provider.name === "github") {
|
||||
return ["user:email"];
|
||||
getScopes(provider: any): string[] {
|
||||
console.log(`[ProviderManager] Getting scopes for provider: ${provider.name}`);
|
||||
console.log(`[ProviderManager] Provider type: ${provider.type}`);
|
||||
console.log(`[ProviderManager] Provider scope from DB: ${provider.scope}`);
|
||||
|
||||
// PRIMEIRO: Se o provider tem scope definido no banco, usa ele
|
||||
if (provider.scope && provider.scope.trim()) {
|
||||
const scopes = provider.scope.split(" ").filter((s: string) => s.trim());
|
||||
console.log(`[ProviderManager] Using scope from database: ${scopes}`);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
// OIDC padrão
|
||||
if (provider.type === "oidc") {
|
||||
return ["openid", "profile", "email"];
|
||||
// SEGUNDO: Detecta automaticamente baseado no tipo
|
||||
const detectedType = this.detectProviderType(provider.issuerUrl || "");
|
||||
console.log(`[ProviderManager] Auto-detected type: ${detectedType}`);
|
||||
|
||||
// Scopes específicos por tipo de provider
|
||||
const typeScopes: Record<string, string[]> = {
|
||||
// Frontegg
|
||||
frontegg: ["openid", "profile", "email"],
|
||||
// Social providers
|
||||
discord: ["identify", "email"],
|
||||
github: ["read:user", "user:email"],
|
||||
gitlab: ["read_user", "read_api"],
|
||||
google: ["openid", "profile", "email"],
|
||||
microsoft: ["openid", "profile", "email", "User.Read"],
|
||||
facebook: ["public_profile", "email"],
|
||||
twitter: ["tweet.read", "users.read"],
|
||||
linkedin: ["r_liteprofile", "r_emailaddress"],
|
||||
|
||||
// Enterprise providers
|
||||
authentik: ["openid", "profile", "email"],
|
||||
keycloak: ["openid", "profile", "email"],
|
||||
auth0: ["openid", "profile", "email"],
|
||||
okta: ["openid", "profile", "email"],
|
||||
onelogin: ["openid", "profile", "email"],
|
||||
ping: ["openid", "profile", "email"],
|
||||
azure: ["openid", "profile", "email", "User.Read"],
|
||||
aws: ["openid", "profile", "email"],
|
||||
|
||||
// Communication
|
||||
slack: ["identity.basic", "identity.email", "identity.avatar"],
|
||||
teams: ["openid", "profile", "email", "User.Read"],
|
||||
|
||||
// Development tools
|
||||
bitbucket: ["account", "repository"],
|
||||
atlassian: ["read:jira-user", "read:jira-work"],
|
||||
jira: ["read:jira-user", "read:jira-work"],
|
||||
confluence: ["read:confluence-content.summary"],
|
||||
|
||||
// Business tools
|
||||
salesforce: ["api", "refresh_token"],
|
||||
zendesk: ["read"],
|
||||
shopify: ["read_products", "read_customers"],
|
||||
stripe: ["read"],
|
||||
twilio: ["read"],
|
||||
sendgrid: ["mail.send"],
|
||||
mailchimp: ["read"],
|
||||
hubspot: ["contacts", "crm.objects.contacts.read"],
|
||||
|
||||
// Productivity
|
||||
zoom: ["user:read:admin"],
|
||||
notion: ["read"],
|
||||
figma: ["files:read"],
|
||||
dropbox: ["files.content.read"],
|
||||
box: ["root_readwrite"],
|
||||
trello: ["read"],
|
||||
asana: ["default"],
|
||||
monday: ["read"],
|
||||
clickup: ["read"],
|
||||
linear: ["read"],
|
||||
};
|
||||
|
||||
// TERCEIRO: Se encontrou tipo específico, usa os scopes dele
|
||||
if (typeScopes[detectedType]) {
|
||||
console.log(`[ProviderManager] Using type-specific scopes for ${detectedType}: ${typeScopes[detectedType]}`);
|
||||
return typeScopes[detectedType];
|
||||
}
|
||||
|
||||
// OAuth2 genérico
|
||||
return ["profile", "email"];
|
||||
// QUARTO: Se o provider tem um tipo definido no banco, tenta usar ele
|
||||
if (provider.type && typeScopes[provider.type]) {
|
||||
console.log(`[ProviderManager] Using database type scopes for ${provider.type}: ${typeScopes[provider.type]}`);
|
||||
return typeScopes[provider.type];
|
||||
}
|
||||
|
||||
// QUINTO: Fallback para scopes OIDC padrão
|
||||
console.log(`[ProviderManager] Using default OIDC scopes: openid, profile, email`);
|
||||
return ["openid", "profile", "email"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta automaticamente o tipo de provider baseado na URL
|
||||
*/
|
||||
private detectProviderType(issuerUrl: string): string {
|
||||
const url = issuerUrl.toLowerCase();
|
||||
|
||||
// Padrões conhecidos para detecção automática
|
||||
const patterns = [
|
||||
{ pattern: "frontegg.com", type: "frontegg" },
|
||||
{ pattern: "discord.com", type: "discord" },
|
||||
{ pattern: "github.com", type: "github" },
|
||||
{ pattern: "gitlab.com", type: "gitlab" },
|
||||
{ pattern: "google.com", type: "google" },
|
||||
{ pattern: "microsoft.com", type: "microsoft" },
|
||||
{ pattern: "facebook.com", type: "facebook" },
|
||||
{ pattern: "twitter.com", type: "twitter" },
|
||||
{ pattern: "linkedin.com", type: "linkedin" },
|
||||
{ pattern: "authentik", type: "authentik" },
|
||||
{ pattern: "keycloak", type: "keycloak" },
|
||||
{ pattern: "auth0.com", type: "auth0" },
|
||||
{ pattern: "okta.com", type: "okta" },
|
||||
{ pattern: "onelogin.com", type: "onelogin" },
|
||||
{ pattern: "pingidentity.com", type: "ping" },
|
||||
{ pattern: "azure.com", type: "azure" },
|
||||
{ pattern: "aws.amazon.com", type: "aws" },
|
||||
{ pattern: "slack.com", type: "slack" },
|
||||
{ pattern: "bitbucket.org", type: "bitbucket" },
|
||||
{ pattern: "atlassian.com", type: "atlassian" },
|
||||
{ pattern: "salesforce.com", type: "salesforce" },
|
||||
{ pattern: "zendesk.com", type: "zendesk" },
|
||||
{ pattern: "shopify.com", type: "shopify" },
|
||||
{ pattern: "stripe.com", type: "stripe" },
|
||||
{ pattern: "twilio.com", type: "twilio" },
|
||||
{ pattern: "sendgrid.com", type: "sendgrid" },
|
||||
{ pattern: "mailchimp.com", type: "mailchimp" },
|
||||
{ pattern: "hubspot.com", type: "hubspot" },
|
||||
{ pattern: "zoom.us", type: "zoom" },
|
||||
{ pattern: "teams.microsoft.com", type: "teams" },
|
||||
{ pattern: "notion.so", type: "notion" },
|
||||
{ pattern: "figma.com", type: "figma" },
|
||||
{ pattern: "dropbox.com", type: "dropbox" },
|
||||
{ pattern: "box.com", type: "box" },
|
||||
{ pattern: "trello.com", type: "trello" },
|
||||
{ pattern: "asana.com", type: "asana" },
|
||||
{ pattern: "monday.com", type: "monday" },
|
||||
{ pattern: "clickup.com", type: "clickup" },
|
||||
{ pattern: "linear.app", type: "linear" },
|
||||
{ pattern: "jira", type: "jira" },
|
||||
{ pattern: "confluence", type: "confluence" },
|
||||
{ pattern: "bamboo", type: "bamboo" },
|
||||
{ pattern: "bitbucket", type: "bitbucket" },
|
||||
{ pattern: "crowd", type: "crowd" },
|
||||
{ pattern: "fisheye", type: "fisheye" },
|
||||
{ pattern: "crucible", type: "crucible" },
|
||||
{ pattern: "statuspage", type: "statuspage" },
|
||||
{ pattern: "opsgenie", type: "opsgenie" },
|
||||
{ pattern: "jira", type: "jira" },
|
||||
{ pattern: "confluence", type: "confluence" },
|
||||
{ pattern: "bamboo", type: "bamboo" },
|
||||
{ pattern: "bitbucket", type: "bitbucket" },
|
||||
{ pattern: "crowd", type: "crowd" },
|
||||
{ pattern: "fisheye", type: "fisheye" },
|
||||
{ pattern: "crucible", type: "crucible" },
|
||||
{ pattern: "statuspage", type: "statuspage" },
|
||||
{ pattern: "opsgenie", type: "opsgenie" },
|
||||
];
|
||||
|
||||
for (const { pattern, type } of patterns) {
|
||||
if (url.includes(pattern)) {
|
||||
console.log(`[ProviderManager] Auto-detected provider type: ${type} from URL: ${issuerUrl}`);
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// Se não encontrou padrão conhecido, tenta extrair domínio
|
||||
try {
|
||||
const domain = new URL(issuerUrl).hostname.replace("www.", "");
|
||||
console.log(`[ProviderManager] No known pattern found, using domain: ${domain}`);
|
||||
return domain;
|
||||
} catch {
|
||||
console.log(`[ProviderManager] Could not parse URL, using 'custom'`);
|
||||
return "custom";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina se o provider deve suportar discovery baseado no tipo
|
||||
*/
|
||||
private shouldSupportDiscovery(providerType: string): boolean {
|
||||
// Providers que geralmente suportam discovery
|
||||
const discoveryProviders = [
|
||||
"frontegg",
|
||||
"oidc",
|
||||
"authentik",
|
||||
"keycloak",
|
||||
"auth0",
|
||||
"okta",
|
||||
"onelogin",
|
||||
"ping",
|
||||
"azure",
|
||||
"aws",
|
||||
"google",
|
||||
"microsoft",
|
||||
];
|
||||
|
||||
return discoveryProviders.includes(providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém o endpoint de discovery apropriado para o tipo
|
||||
*/
|
||||
private getDiscoveryEndpoint(providerType: string): string {
|
||||
const discoveryEndpoints: Record<string, string> = {
|
||||
frontegg: "/.well-known/openid_configuration",
|
||||
oidc: "/.well-known/openid_configuration",
|
||||
authentik: "/.well-known/openid_configuration",
|
||||
keycloak: "/.well-known/openid_configuration",
|
||||
auth0: "/.well-known/openid_configuration",
|
||||
okta: "/.well-known/openid_configuration",
|
||||
onelogin: "/.well-known/openid_configuration",
|
||||
ping: "/.well-known/openid_configuration",
|
||||
azure: "/.well-known/openid_configuration",
|
||||
aws: "/.well-known/openid_configuration",
|
||||
google: "/.well-known/openid_configuration",
|
||||
microsoft: "/.well-known/openid_configuration",
|
||||
};
|
||||
|
||||
return discoveryEndpoints[providerType] || "/.well-known/openid_configuration";
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém fallback endpoints apropriados para o tipo
|
||||
*/
|
||||
private getFallbackEndpoints(providerType: string): any {
|
||||
const fallbackPatterns: Record<string, any> = {
|
||||
// Frontegg
|
||||
frontegg: {
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/api/oauth/userinfo",
|
||||
},
|
||||
// OIDC padrão
|
||||
oidc: {
|
||||
authorizationEndpoint: "/oauth2/authorize",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/oauth2/userinfo",
|
||||
},
|
||||
// OAuth2 padrão
|
||||
oauth2: {
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/oauth/userinfo",
|
||||
},
|
||||
// GitHub
|
||||
github: {
|
||||
authorizationEndpoint: "/login/oauth/authorize",
|
||||
tokenEndpoint: "/login/oauth/access_token",
|
||||
userInfoEndpoint: "/user",
|
||||
},
|
||||
// GitLab
|
||||
gitlab: {
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/api/v4/user",
|
||||
},
|
||||
// Discord
|
||||
discord: {
|
||||
authorizationEndpoint: "/oauth2/authorize",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/users/@me",
|
||||
},
|
||||
// Slack
|
||||
slack: {
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/api/oauth.access",
|
||||
userInfoEndpoint: "/api/users.identity",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
fallbackPatterns[providerType] || {
|
||||
authorizationEndpoint: "/oauth2/authorize",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/oauth2/userinfo",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém field mappings apropriados para o tipo
|
||||
*/
|
||||
private getFieldMappings(providerType: string): any {
|
||||
const fieldMappings: Record<string, any> = {
|
||||
// Frontegg
|
||||
frontegg: {
|
||||
id: ["sub", "id", "user_id"],
|
||||
email: ["email", "preferred_username"],
|
||||
name: ["name", "preferred_username"],
|
||||
firstName: ["given_name", "name"],
|
||||
lastName: ["family_name"],
|
||||
avatar: ["picture"],
|
||||
},
|
||||
// GitHub
|
||||
github: {
|
||||
id: ["id"],
|
||||
email: ["email"],
|
||||
name: ["name", "login"],
|
||||
firstName: ["name"],
|
||||
lastName: [""],
|
||||
avatar: ["avatar_url"],
|
||||
},
|
||||
// GitLab
|
||||
gitlab: {
|
||||
id: ["id"],
|
||||
email: ["email"],
|
||||
name: ["name", "username"],
|
||||
firstName: ["name"],
|
||||
lastName: [""],
|
||||
avatar: ["avatar_url"],
|
||||
},
|
||||
// Discord
|
||||
discord: {
|
||||
id: ["id"],
|
||||
email: ["email"],
|
||||
name: ["username", "global_name"],
|
||||
firstName: ["username"],
|
||||
lastName: [""],
|
||||
avatar: ["avatar"],
|
||||
},
|
||||
// Slack
|
||||
slack: {
|
||||
id: ["id"],
|
||||
email: ["email"],
|
||||
name: ["name", "real_name"],
|
||||
firstName: ["name"],
|
||||
lastName: [""],
|
||||
avatar: ["image_192"],
|
||||
},
|
||||
};
|
||||
|
||||
return fieldMappings[providerType] || this.config.genericProviderTemplate.fieldMappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém special handling apropriado para o tipo
|
||||
*/
|
||||
private getSpecialHandling(providerType: string): any {
|
||||
const specialHandling: Record<string, any> = {
|
||||
// Frontegg - usa OAuth2 padrão
|
||||
frontegg: {
|
||||
emailEndpoint: "",
|
||||
emailFetchRequired: false,
|
||||
responseFormat: "json",
|
||||
urlCleaning: {
|
||||
removeFromEnd: [],
|
||||
},
|
||||
},
|
||||
// GitHub - precisa de endpoint adicional para email
|
||||
github: {
|
||||
emailEndpoint: "/user/emails",
|
||||
emailFetchRequired: true,
|
||||
responseFormat: "json",
|
||||
},
|
||||
// GitLab - OAuth2 padrão
|
||||
gitlab: {
|
||||
emailEndpoint: "",
|
||||
emailFetchRequired: false,
|
||||
responseFormat: "json",
|
||||
},
|
||||
// Discord - OAuth2 padrão
|
||||
discord: {
|
||||
emailEndpoint: "",
|
||||
emailFetchRequired: false,
|
||||
responseFormat: "json",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
specialHandling[providerType] || {
|
||||
emailEndpoint: "",
|
||||
emailFetchRequired: false,
|
||||
responseFormat: "json",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -3,12 +3,9 @@ import { ProviderConfig, ProvidersConfigFile } from "./types";
|
||||
/**
|
||||
* Configuração técnica oficial do GitHub
|
||||
* OAuth2 com busca separada de email
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const githubConfig: ProviderConfig = {
|
||||
name: "GitHub",
|
||||
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
||||
userInfoEndpoint: "https://api.github.com/user",
|
||||
supportsDiscovery: false,
|
||||
authMethod: "body",
|
||||
specialHandling: {
|
||||
@@ -29,16 +26,11 @@ const githubConfig: ProviderConfig = {
|
||||
/**
|
||||
* Configuração técnica oficial do Auth0
|
||||
* OIDC com discovery automático
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const auth0Config: ProviderConfig = {
|
||||
name: "Auth0",
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||
fallbackEndpoints: {
|
||||
authorizationEndpoint: "/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/userinfo",
|
||||
},
|
||||
authMethod: "body",
|
||||
fieldMappings: {
|
||||
id: ["sub"],
|
||||
@@ -53,16 +45,11 @@ const auth0Config: ProviderConfig = {
|
||||
/**
|
||||
* Configuração técnica oficial do Kinde
|
||||
* OIDC com mapeamentos de campo customizados
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const kindeConfig: ProviderConfig = {
|
||||
name: "Kinde",
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||
fallbackEndpoints: {
|
||||
authorizationEndpoint: "/oauth2/auth",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/oauth2/user_profile",
|
||||
},
|
||||
authMethod: "body",
|
||||
fieldMappings: {
|
||||
id: ["id"],
|
||||
@@ -76,23 +63,13 @@ const kindeConfig: ProviderConfig = {
|
||||
|
||||
/**
|
||||
* Configuração técnica oficial do Zitadel
|
||||
* OIDC com Basic Auth e limpeza de URL
|
||||
* OIDC com Basic Auth
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const zitadelConfig: ProviderConfig = {
|
||||
name: "Zitadel",
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||
fallbackEndpoints: {
|
||||
authorizationEndpoint: "/oauth/v2/authorize",
|
||||
tokenEndpoint: "/oauth/v2/token",
|
||||
userInfoEndpoint: "/oidc/v1/userinfo",
|
||||
},
|
||||
authMethod: "basic",
|
||||
specialHandling: {
|
||||
urlCleaning: {
|
||||
removeFromEnd: ["/oauth/v2/authorize", "/oauth/v2", "/authorize"],
|
||||
},
|
||||
},
|
||||
fieldMappings: {
|
||||
id: ["sub"],
|
||||
email: ["email"],
|
||||
@@ -105,23 +82,13 @@ const zitadelConfig: ProviderConfig = {
|
||||
|
||||
/**
|
||||
* Configuração técnica oficial do Authentik
|
||||
* OIDC self-hosted com discovery e limpeza de URL
|
||||
* OIDC self-hosted com discovery
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const authentikConfig: ProviderConfig = {
|
||||
name: "Authentik",
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||
fallbackEndpoints: {
|
||||
authorizationEndpoint: "/application/o/authorize",
|
||||
tokenEndpoint: "/application/o/token",
|
||||
userInfoEndpoint: "/application/o/userinfo",
|
||||
},
|
||||
authMethod: "body",
|
||||
specialHandling: {
|
||||
urlCleaning: {
|
||||
removeFromEnd: ["/.well-known/openid-configuration", "/.well-known/openid_configuration"],
|
||||
},
|
||||
},
|
||||
fieldMappings: {
|
||||
id: ["sub"],
|
||||
email: ["email"],
|
||||
@@ -133,26 +100,50 @@ const authentikConfig: ProviderConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Template genérico para providers customizados
|
||||
* Configuração técnica padrão OIDC que funciona com a maioria dos providers
|
||||
* Configuração técnica oficial do Frontegg
|
||||
* OIDC multi-tenant com discovery automático
|
||||
* Endpoints vêm do banco de dados
|
||||
*/
|
||||
const fronteggConfig: ProviderConfig = {
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid-configuration",
|
||||
authMethod: "body",
|
||||
fieldMappings: {
|
||||
id: ["sub", "id", "user_id"],
|
||||
email: ["email", "preferred_username"],
|
||||
name: ["name", "preferred_username"],
|
||||
firstName: ["given_name", "name"],
|
||||
lastName: ["family_name"],
|
||||
avatar: ["picture"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Template genérico ULTRA-INTELIGENTE para providers customizados
|
||||
* Detecta automaticamente padrões comuns e se adapta
|
||||
*/
|
||||
const genericProviderTemplate: ProviderConfig = {
|
||||
name: "",
|
||||
supportsDiscovery: true,
|
||||
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||
fallbackEndpoints: {
|
||||
authorizationEndpoint: "/auth",
|
||||
tokenEndpoint: "/token",
|
||||
userInfoEndpoint: "/userinfo",
|
||||
authorizationEndpoint: "/oauth2/authorize",
|
||||
tokenEndpoint: "/oauth2/token",
|
||||
userInfoEndpoint: "/oauth2/userinfo",
|
||||
},
|
||||
authMethod: "body",
|
||||
specialHandling: {
|
||||
emailEndpoint: "",
|
||||
emailFetchRequired: false,
|
||||
responseFormat: "json",
|
||||
},
|
||||
|
||||
fieldMappings: {
|
||||
id: ["sub", "id"],
|
||||
email: ["email"],
|
||||
name: ["name"],
|
||||
firstName: ["given_name", "first_name"],
|
||||
lastName: ["family_name", "last_name"],
|
||||
avatar: ["picture", "avatar_url"],
|
||||
id: ["sub", "id", "user_id", "uid", "userid", "account_id"],
|
||||
email: ["email", "mail", "email_address", "preferred_email", "primary_email"],
|
||||
name: ["name", "display_name", "full_name", "username", "login", "first_name last_name", "given_name family_name"],
|
||||
firstName: ["given_name", "first_name", "firstname", "first", "name"],
|
||||
lastName: ["family_name", "last_name", "lastname", "last", "surname"],
|
||||
avatar: ["picture", "avatar", "avatar_url", "profile_picture", "photo", "image", "thumbnail"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,6 +158,7 @@ export const providersConfig: ProvidersConfigFile = {
|
||||
kinde: kindeConfig,
|
||||
zitadel: zitadelConfig,
|
||||
authentik: authentikConfig,
|
||||
frontegg: fronteggConfig,
|
||||
},
|
||||
genericProviderTemplate,
|
||||
};
|
||||
@@ -174,4 +166,12 @@ export const providersConfig: ProvidersConfigFile = {
|
||||
/**
|
||||
* Exportações individuais para facilitar importação
|
||||
*/
|
||||
export { githubConfig, auth0Config, kindeConfig, zitadelConfig, authentikConfig, genericProviderTemplate };
|
||||
export {
|
||||
githubConfig,
|
||||
auth0Config,
|
||||
kindeConfig,
|
||||
zitadelConfig,
|
||||
authentikConfig,
|
||||
fronteggConfig,
|
||||
genericProviderTemplate,
|
||||
};
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { AuthProvidersController } from "./controller";
|
||||
import { CreateAuthProviderSchema, UpdateAuthProviderSchema, UpdateProvidersOrderSchema } from "./dto";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -110,13 +111,18 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
|
||||
tags: ["Auth Providers"],
|
||||
operationId: "createAuthProvider",
|
||||
summary: "Create authentication provider",
|
||||
description: "Create a new authentication provider",
|
||||
body: z.any(),
|
||||
description:
|
||||
"Create a new authentication provider. Use either issuerUrl for automatic discovery OR provide all three custom endpoints.",
|
||||
body: CreateAuthProviderSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
data: z.any(),
|
||||
}),
|
||||
400: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
401: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
@@ -145,14 +151,7 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
|
||||
operationId: "updateProvidersOrder",
|
||||
summary: "Update providers order",
|
||||
description: "Update the display order of authentication providers",
|
||||
body: z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
body: UpdateProvidersOrderSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
@@ -180,6 +179,51 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
|
||||
authProvidersController.updateProvidersOrder.bind(authProvidersController)
|
||||
);
|
||||
|
||||
// Test provider configuration (admin only)
|
||||
fastify.post(
|
||||
"/providers/:id/test",
|
||||
{
|
||||
preValidation: adminPreValidation,
|
||||
schema: {
|
||||
tags: ["Auth Providers"],
|
||||
operationId: "testAuthProvider",
|
||||
summary: "Test authentication provider configuration",
|
||||
description: "Test if the provider configuration is valid by checking endpoints and connectivity",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
data: z.any(),
|
||||
}),
|
||||
400: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
details: z.string().optional(),
|
||||
}),
|
||||
401: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
403: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
404: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
500: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
authProvidersController.testProvider.bind(authProvidersController)
|
||||
);
|
||||
|
||||
// Update provider configuration (admin only)
|
||||
fastify.put(
|
||||
"/providers/:id",
|
||||
@@ -189,16 +233,21 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
|
||||
tags: ["Auth Providers"],
|
||||
operationId: "updateAuthProvider",
|
||||
summary: "Update authentication provider",
|
||||
description: "Update configuration for a specific authentication provider",
|
||||
description:
|
||||
"Update configuration for a specific authentication provider. Use either issuerUrl for automatic discovery OR provide all three custom endpoints.",
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
body: z.any(),
|
||||
body: z.any(), // Validação manual no controller para providers oficiais
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
data: z.any(),
|
||||
}),
|
||||
400: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
}),
|
||||
401: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string(),
|
||||
|
@@ -33,7 +33,7 @@ export class AuthProvidersService {
|
||||
});
|
||||
|
||||
return providers.map((provider) => {
|
||||
const config = this.providerManager.getProviderConfig(provider.name, provider.issuerUrl || undefined);
|
||||
const config = this.providerManager.getProviderConfig(provider);
|
||||
const authUrl = this.generateAuthUrl(provider, requestContext);
|
||||
|
||||
return {
|
||||
@@ -122,7 +122,7 @@ export class AuthProvidersService {
|
||||
enabled: provider.enabled,
|
||||
});
|
||||
|
||||
const config = this.providerManager.getProviderConfig(providerName, provider.issuerUrl || undefined);
|
||||
const config = this.providerManager.getProviderConfig(provider);
|
||||
if (!config) {
|
||||
throw new Error(`Configuration not found for provider ${providerName}`);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export class AuthProvidersService {
|
||||
if (provider.scope) {
|
||||
scopes = provider.scope.split(" ").filter((s: string) => s.trim());
|
||||
} else {
|
||||
scopes = this.providerManager.getDefaultScopes(provider);
|
||||
scopes = this.providerManager.getScopes(provider);
|
||||
}
|
||||
|
||||
console.log(`[AuthProvidersService] Using scopes:`, scopes);
|
||||
@@ -214,7 +214,7 @@ export class AuthProvidersService {
|
||||
issuerUrl: provider.issuerUrl,
|
||||
});
|
||||
|
||||
const config = this.providerManager.getProviderConfig(providerName, provider.issuerUrl || undefined);
|
||||
const config = this.providerManager.getProviderConfig(provider);
|
||||
if (!config) {
|
||||
throw new Error(`Configuration not found for provider ${providerName}`);
|
||||
}
|
||||
@@ -370,12 +370,26 @@ export class AuthProvidersService {
|
||||
console.error(`[AuthProvidersService] UserInfo request failed:`, {
|
||||
status: userInfoResponse.status,
|
||||
statusText: userInfoResponse.statusText,
|
||||
error: errorText,
|
||||
url: endpoints.userInfoEndpoint,
|
||||
headers: Object.fromEntries(userInfoResponse.headers.entries()),
|
||||
error: errorText.substring(0, 500), // Limita o tamanho do log
|
||||
});
|
||||
throw new Error(`UserInfo request failed: ${userInfoResponse.status}`);
|
||||
throw new Error(`UserInfo request failed: ${userInfoResponse.status} - ${errorText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
let rawUserInfo: any;
|
||||
try {
|
||||
rawUserInfo = (await userInfoResponse.json()) as any;
|
||||
} catch (parseError) {
|
||||
const responseText = await userInfoResponse.text();
|
||||
console.error(`[AuthProvidersService] Failed to parse userinfo response:`, {
|
||||
error: parseError,
|
||||
responseText: responseText.substring(0, 500),
|
||||
contentType: userInfoResponse.headers.get("content-type"),
|
||||
});
|
||||
throw new Error(`Invalid JSON response from userinfo endpoint: ${parseError}`);
|
||||
}
|
||||
|
||||
const rawUserInfo = (await userInfoResponse.json()) as any;
|
||||
console.log(`[AuthProvidersService] User info received:`, {
|
||||
hasId: !!(rawUserInfo as any).sub,
|
||||
hasEmail: !!(rawUserInfo as any).email,
|
||||
@@ -475,15 +489,34 @@ export class AuthProvidersService {
|
||||
});
|
||||
|
||||
if (existingAuthProvider) {
|
||||
// Atualiza informações do usuário
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: existingAuthProvider.user.id },
|
||||
data: {
|
||||
firstName: userInfo.firstName || existingAuthProvider.user.firstName,
|
||||
lastName: userInfo.lastName || existingAuthProvider.user.lastName,
|
||||
},
|
||||
});
|
||||
return updatedUser;
|
||||
// Atualiza informações do usuário apenas se estiverem vazias
|
||||
const updateData: any = {};
|
||||
|
||||
// Só atualiza firstName se estiver vazio
|
||||
if (!existingAuthProvider.user.firstName && userInfo.firstName) {
|
||||
updateData.firstName = userInfo.firstName;
|
||||
}
|
||||
|
||||
// Só atualiza lastName se estiver vazio
|
||||
if (!existingAuthProvider.user.lastName && userInfo.lastName) {
|
||||
updateData.lastName = userInfo.lastName;
|
||||
}
|
||||
|
||||
// Só atualiza image se estiver vazia
|
||||
if (!existingAuthProvider.user.image && userInfo.avatar) {
|
||||
updateData.image = userInfo.avatar;
|
||||
}
|
||||
|
||||
// Só faz update se houver dados para atualizar
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: existingAuthProvider.user.id },
|
||||
data: updateData,
|
||||
});
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
return existingAuthProvider.user;
|
||||
}
|
||||
|
||||
// Verifica se usuário existe por email
|
||||
@@ -501,16 +534,34 @@ export class AuthProvidersService {
|
||||
},
|
||||
});
|
||||
|
||||
// Atualiza informações do usuário
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
firstName: userInfo.firstName || existingUser.firstName,
|
||||
lastName: userInfo.lastName || existingUser.lastName,
|
||||
},
|
||||
});
|
||||
// Atualiza informações do usuário apenas se estiverem vazias
|
||||
const updateData: any = {};
|
||||
|
||||
return updatedUser;
|
||||
// Só atualiza firstName se estiver vazio
|
||||
if (!existingUser.firstName && userInfo.firstName) {
|
||||
updateData.firstName = userInfo.firstName;
|
||||
}
|
||||
|
||||
// Só atualiza lastName se estiver vazio
|
||||
if (!existingUser.lastName && userInfo.lastName) {
|
||||
updateData.lastName = userInfo.lastName;
|
||||
}
|
||||
|
||||
// Só atualiza image se estiver vazia
|
||||
if (!existingUser.image && userInfo.avatar) {
|
||||
updateData.image = userInfo.avatar;
|
||||
}
|
||||
|
||||
// Só faz update se houver dados para atualizar
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: updateData,
|
||||
});
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
// Cria novo usuário
|
||||
@@ -525,6 +576,7 @@ export class AuthProvidersService {
|
||||
username: userInfo.email.split("@")[0],
|
||||
firstName,
|
||||
lastName,
|
||||
image: userInfo.avatar || null,
|
||||
isAdmin: false,
|
||||
authProviders: {
|
||||
create: {
|
||||
@@ -568,4 +620,480 @@ export class AuthProvidersService {
|
||||
await prisma.$transaction(updatePromises);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async testProviderConfiguration(provider: any) {
|
||||
console.log(`[AuthProvidersService] Testing provider configuration: ${provider.name}`);
|
||||
|
||||
const testResults = {
|
||||
providerName: provider.name,
|
||||
displayName: provider.displayName,
|
||||
type: provider.type,
|
||||
tests: [] as any[],
|
||||
overall: { status: "unknown", message: "" },
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Teste de endpoints acessíveis
|
||||
await this.testEndpointsAccessibility(provider, testResults);
|
||||
|
||||
// 2. Teste de credenciais válidas
|
||||
await this.testCredentialsValidity(provider, testResults);
|
||||
|
||||
// 3. Teste de dados retornados
|
||||
await this.testDataRetrieval(provider, testResults);
|
||||
|
||||
// 4. Teste de login funcional
|
||||
await this.testLoginFunctionality(provider, testResults);
|
||||
|
||||
// Determina status geral
|
||||
const failedTests = testResults.tests.filter((t) => t.status === "error");
|
||||
const warningTests = testResults.tests.filter((t) => t.status === "warning");
|
||||
|
||||
if (failedTests.length > 0) {
|
||||
testResults.overall = {
|
||||
status: "error",
|
||||
message: `Configuration has ${failedTests.length} critical error(s)`,
|
||||
};
|
||||
} else if (warningTests.length > 0) {
|
||||
testResults.overall = {
|
||||
status: "warning",
|
||||
message: `Configuration has ${warningTests.length} warning(s) but should work`,
|
||||
};
|
||||
} else {
|
||||
testResults.overall = {
|
||||
status: "success",
|
||||
message: "Provider is fully functional and ready to use",
|
||||
};
|
||||
}
|
||||
|
||||
return testResults;
|
||||
} catch (error) {
|
||||
console.error(`[AuthProvidersService] Test failed for ${provider.name}:`, error);
|
||||
|
||||
testResults.overall = {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Unknown test error",
|
||||
};
|
||||
|
||||
return testResults;
|
||||
}
|
||||
}
|
||||
|
||||
private async testEndpointsAccessibility(provider: any, results: any) {
|
||||
console.log(`[AuthProvidersService] Testing endpoints accessibility for ${provider.name}`);
|
||||
|
||||
const config = await this.providerManager.getProviderConfig(provider);
|
||||
const baseUrl = provider.issuerUrl;
|
||||
|
||||
if (!baseUrl) {
|
||||
results.tests.push({
|
||||
name: "Endpoints Accessibility",
|
||||
status: "error",
|
||||
message: "Provider URL is missing",
|
||||
details: { missing: "issuerUrl" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoints = [
|
||||
{
|
||||
name: "Authorization Endpoint",
|
||||
url: provider.authorizationEndpoint || config.authorizationEndpoint,
|
||||
},
|
||||
{
|
||||
name: "Token Endpoint",
|
||||
url: provider.tokenEndpoint || config.tokenEndpoint,
|
||||
},
|
||||
{
|
||||
name: "UserInfo Endpoint",
|
||||
url: provider.userInfoEndpoint || config.userInfoEndpoint,
|
||||
},
|
||||
];
|
||||
|
||||
const accessibleEndpoints = [];
|
||||
const inaccessibleEndpoints = [];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
if (!endpoint.url) continue;
|
||||
|
||||
const fullUrl = endpoint.url.startsWith("http") ? endpoint.url : `${baseUrl}${endpoint.url}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Qualquer resposta HTTP válida (não timeout/erro de rede) significa que o endpoint existe
|
||||
if (response.status >= 100 && response.status < 600) {
|
||||
let message = "Endpoint exists and is accessible";
|
||||
let statusType = "accessible";
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
message = "Endpoint exists and is properly protected";
|
||||
statusType = "protected";
|
||||
} else if (response.status === 405) {
|
||||
message = "Endpoint exists (method not allowed)";
|
||||
statusType = "exists";
|
||||
} else if (response.status >= 500) {
|
||||
message = "Endpoint exists but server error (may be temporary)";
|
||||
statusType = "server_error";
|
||||
} else if (response.status >= 400) {
|
||||
message = "Endpoint exists but client error";
|
||||
statusType = "client_error";
|
||||
}
|
||||
|
||||
accessibleEndpoints.push({
|
||||
name: endpoint.name,
|
||||
status: response.status,
|
||||
message,
|
||||
type: statusType,
|
||||
});
|
||||
} else {
|
||||
// Resposta inválida
|
||||
inaccessibleEndpoints.push({
|
||||
name: endpoint.name,
|
||||
status: response.status,
|
||||
type: "invalid_response",
|
||||
message: "Invalid HTTP response",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
// Se é timeout ou erro de rede, considera inacessível
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
inaccessibleEndpoints.push({
|
||||
name: endpoint.name,
|
||||
error: "Connection timeout",
|
||||
type: "timeout",
|
||||
message: "Connection timeout (10s)",
|
||||
});
|
||||
} else {
|
||||
inaccessibleEndpoints.push({
|
||||
name: endpoint.name,
|
||||
error: errorMessage,
|
||||
type: "connection_error",
|
||||
message: "Connection failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determina o status geral baseado nos resultados
|
||||
if (inaccessibleEndpoints.length === 0) {
|
||||
const protectedCount = accessibleEndpoints.filter((e) => e.type === "protected").length;
|
||||
const accessibleCount = accessibleEndpoints.filter((e) => e.type === "accessible").length;
|
||||
const existsCount = accessibleEndpoints.filter((e) => e.type === "exists").length;
|
||||
const errorCount = accessibleEndpoints.filter(
|
||||
(e) => e.type === "server_error" || e.type === "client_error"
|
||||
).length;
|
||||
|
||||
let message = `All endpoints are working correctly`;
|
||||
const parts = [];
|
||||
|
||||
if (accessibleCount > 0) parts.push(`${accessibleCount} accessible`);
|
||||
if (protectedCount > 0) parts.push(`${protectedCount} properly protected`);
|
||||
if (existsCount > 0) parts.push(`${existsCount} exist`);
|
||||
if (errorCount > 0) parts.push(`${errorCount} with temporary errors`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
message += ` (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
results.tests.push({
|
||||
name: "Endpoints Accessibility",
|
||||
status: "success",
|
||||
message,
|
||||
details: {
|
||||
accessibleEndpoints: accessibleEndpoints.map((e) => ({
|
||||
name: e.name,
|
||||
status: e.status,
|
||||
message: e.message,
|
||||
type: e.type,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const timeoutErrors = inaccessibleEndpoints.filter((e) => e.type === "timeout");
|
||||
const connectionErrors = inaccessibleEndpoints.filter((e) => e.type === "connection_error");
|
||||
const invalidResponses = inaccessibleEndpoints.filter((e) => e.type === "invalid_response");
|
||||
|
||||
let message = "";
|
||||
if (timeoutErrors.length > 0) {
|
||||
message += `${timeoutErrors.length} endpoint(s) with connection timeout. `;
|
||||
}
|
||||
if (connectionErrors.length > 0) {
|
||||
message += `${connectionErrors.length} endpoint(s) with connection errors. `;
|
||||
}
|
||||
if (invalidResponses.length > 0) {
|
||||
message += `${invalidResponses.length} endpoint(s) with invalid responses. `;
|
||||
}
|
||||
|
||||
const status = timeoutErrors.length > 0 || connectionErrors.length > 0 ? "error" : "warning";
|
||||
|
||||
results.tests.push({
|
||||
name: "Endpoints Accessibility",
|
||||
status,
|
||||
message: message.trim(),
|
||||
details: {
|
||||
accessibleEndpoints: accessibleEndpoints.map((e) => ({
|
||||
name: e.name,
|
||||
status: e.status,
|
||||
message: e.message,
|
||||
type: e.type,
|
||||
})),
|
||||
inaccessibleEndpoints: inaccessibleEndpoints.map((e) => ({
|
||||
name: e.name,
|
||||
status: e.status,
|
||||
type: e.type,
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async testCredentialsValidity(provider: any, results: any) {
|
||||
console.log(`[AuthProvidersService] Testing credentials validity for ${provider.name}`);
|
||||
|
||||
if (!provider.clientId || !provider.clientSecret) {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "error",
|
||||
message: "Client ID or Client Secret is missing",
|
||||
details: { missing: !provider.clientId ? "clientId" : "clientSecret" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await this.providerManager.getProviderConfig(provider);
|
||||
const tokenEndpoint = provider.tokenEndpoint || config.tokenEndpoint;
|
||||
|
||||
if (!tokenEndpoint) {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "warning",
|
||||
message: "Cannot test credentials without token endpoint",
|
||||
details: { missing: "tokenEndpoint" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Tenta fazer uma requisição para o token endpoint com as credenciais
|
||||
const baseUrl = provider.issuerUrl;
|
||||
const fullUrl = tokenEndpoint.startsWith("http") ? tokenEndpoint : `${baseUrl}${tokenEndpoint}`;
|
||||
|
||||
const authHeader = this.buildAuthHeader(provider.clientId, provider.clientSecret, config.authMethod);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
...authHeader,
|
||||
},
|
||||
body: "grant_type=client_credentials&scope=openid",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 200) {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "success",
|
||||
message: "Credentials are valid and working",
|
||||
details: { status: response.status },
|
||||
});
|
||||
} else if (response.status === 400) {
|
||||
// 400 é esperado para client_credentials sem scope adequado, mas indica credenciais válidas
|
||||
const responseText = await response.text();
|
||||
if (responseText.includes("invalid_scope") || responseText.includes("unsupported_grant_type")) {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "success",
|
||||
message: "Credentials are valid (expected error for client_credentials grant)",
|
||||
details: {
|
||||
status: response.status,
|
||||
reason: "client_credentials not supported or invalid scope",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "warning",
|
||||
message: "Credentials test inconclusive",
|
||||
details: {
|
||||
status: response.status,
|
||||
response: responseText.substring(0, 200),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
// 401 significa que o endpoint existe e está funcionando, mas as credenciais são inválidas
|
||||
// Isso é normal para um teste de validação - o importante é que o endpoint respondeu
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "success",
|
||||
message: "Endpoint is working correctly (401 expected for invalid credentials test)",
|
||||
details: {
|
||||
status: response.status,
|
||||
note: "This is normal - the endpoint exists and is properly validating credentials",
|
||||
},
|
||||
});
|
||||
} else if (response.status === 403) {
|
||||
// 403 significa que o endpoint existe e está funcionando, mas o acesso é negado
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "success",
|
||||
message: "Endpoint is working correctly (403 expected for access control)",
|
||||
details: {
|
||||
status: response.status,
|
||||
note: "This is normal - the endpoint exists and is properly controlling access",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "warning",
|
||||
message: "Credentials test inconclusive",
|
||||
details: { status: response.status },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.tests.push({
|
||||
name: "Credentials Validity",
|
||||
status: "error",
|
||||
message: "Failed to test credentials",
|
||||
details: { error: errorMessage },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async testDataRetrieval(provider: any, results: any) {
|
||||
console.log(`[AuthProvidersService] Testing data retrieval for ${provider.name}`);
|
||||
|
||||
const config = await this.providerManager.getProviderConfig(provider);
|
||||
const userInfoEndpoint = provider.userInfoEndpoint || config.userInfoEndpoint;
|
||||
|
||||
if (!userInfoEndpoint) {
|
||||
results.tests.push({
|
||||
name: "Data Retrieval",
|
||||
status: "warning",
|
||||
message: "Cannot test data retrieval without userInfo endpoint",
|
||||
details: { missing: "userInfoEndpoint" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Simula dados de usuário para testar o mapeamento
|
||||
const mockUserInfo = {
|
||||
sub: "test_user_123",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
given_name: "Test",
|
||||
family_name: "User",
|
||||
picture: "https://example.com/avatar.jpg",
|
||||
};
|
||||
|
||||
const extractedInfo = this.providerManager.extractUserInfo(mockUserInfo, config);
|
||||
|
||||
const requiredFields = ["id", "email"];
|
||||
const missingFields = requiredFields.filter((field) => !extractedInfo[field]);
|
||||
|
||||
if (missingFields.length === 0) {
|
||||
results.tests.push({
|
||||
name: "Data Retrieval",
|
||||
status: "success",
|
||||
message: "Field mappings are properly configured",
|
||||
details: {
|
||||
extractedFields: Object.keys(extractedInfo).filter((k) => extractedInfo[k]),
|
||||
missingFields: [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.tests.push({
|
||||
name: "Data Retrieval",
|
||||
status: "error",
|
||||
message: `Missing required field mappings: ${missingFields.join(", ")}`,
|
||||
details: {
|
||||
extractedFields: Object.keys(extractedInfo).filter((k) => extractedInfo[k]),
|
||||
missingFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.tests.push({
|
||||
name: "Data Retrieval",
|
||||
status: "error",
|
||||
message: "Failed to test data retrieval",
|
||||
details: { error: errorMessage },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async testLoginFunctionality(provider: any, results: any) {
|
||||
console.log(`[AuthProvidersService] Testing login functionality for ${provider.name}`);
|
||||
|
||||
try {
|
||||
// Testa se consegue gerar URL de autorização
|
||||
const authUrl = await this.getAuthorizationUrl(provider.name, "test_state");
|
||||
|
||||
if (authUrl && authUrl.includes("response_type=code")) {
|
||||
results.tests.push({
|
||||
name: "Login Functionality",
|
||||
status: "success",
|
||||
message: "Authorization URL generation works correctly",
|
||||
details: {
|
||||
authUrlGenerated: true,
|
||||
hasCodeResponse: authUrl.includes("response_type=code"),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.tests.push({
|
||||
name: "Login Functionality",
|
||||
status: "error",
|
||||
message: "Failed to generate proper authorization URL",
|
||||
details: { authUrlGenerated: !!authUrl },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.tests.push({
|
||||
name: "Login Functionality",
|
||||
status: "error",
|
||||
message: "Failed to test login functionality",
|
||||
details: { error: errorMessage },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildAuthHeader(clientId: string, clientSecret: string, authMethod: string): Record<string, string> {
|
||||
switch (authMethod) {
|
||||
case "basic": {
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
case "header":
|
||||
return {
|
||||
"X-Client-ID": clientId,
|
||||
"X-Client-Secret": clientSecret,
|
||||
};
|
||||
case "body":
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
export interface ProviderConfig {
|
||||
name: string;
|
||||
name?: string;
|
||||
issuerUrl?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
@@ -16,9 +16,6 @@ export interface ProviderConfig {
|
||||
emailEndpoint?: string;
|
||||
emailFetchRequired?: boolean;
|
||||
responseFormat?: string;
|
||||
urlCleaning?: {
|
||||
removeFromEnd: string[];
|
||||
};
|
||||
};
|
||||
fieldMappings: {
|
||||
id: string[];
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const apiRes = await fetch(`${API_BASE_URL}/auth/providers/${id}/test`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
// Forward any authorization headers
|
||||
...Object.fromEntries(
|
||||
Array.from(request.headers.entries()).filter(
|
||||
([key]) => key.startsWith("authorization") || key.startsWith("cookie")
|
||||
)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await apiRes.json();
|
||||
|
||||
return NextResponse.json(data, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error proxying auth provider test:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -44,6 +46,9 @@ interface AuthProvider {
|
||||
adminEmailDomains?: string;
|
||||
sortOrder: number;
|
||||
isOfficial?: boolean;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userInfoEndpoint?: string;
|
||||
}
|
||||
|
||||
interface NewProvider {
|
||||
@@ -51,6 +56,14 @@ interface NewProvider {
|
||||
displayName: string;
|
||||
type: "oidc" | "oauth2";
|
||||
icon: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuerUrl: string;
|
||||
scope: string;
|
||||
// Endpoints customizados opcionais
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
}
|
||||
|
||||
export function AuthProvidersSettings() {
|
||||
@@ -61,13 +74,102 @@ export function AuthProvidersSettings() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
|
||||
const [editingFormData, setEditingFormData] = useState<Record<string, any>>({});
|
||||
const [testResults, setTestResults] = useState<Record<string, any>>({});
|
||||
const [testingProvider, setTestingProvider] = useState<string | null>(null);
|
||||
const [newProvider, setNewProvider] = useState<NewProvider>({
|
||||
name: "",
|
||||
displayName: "",
|
||||
type: "oidc",
|
||||
icon: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
scope: "openid profile email",
|
||||
authorizationEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
userInfoEndpoint: "",
|
||||
});
|
||||
|
||||
// Auto-sugestão de scopes baseada na Provider URL
|
||||
const detectProviderTypeAndSuggestScopes = (url: string): string[] => {
|
||||
if (!url) return [];
|
||||
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
// Padrões conhecidos para detecção automática
|
||||
const providerPatterns = [
|
||||
{ pattern: "frontegg.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "discord.com", scopes: ["identify", "email"] },
|
||||
{ pattern: "github.com", scopes: ["read:user", "user:email"] },
|
||||
{ pattern: "gitlab.com", scopes: ["read_user", "read_api"] },
|
||||
{ pattern: "google.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
|
||||
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
|
||||
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
|
||||
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
|
||||
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
|
||||
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
|
||||
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
|
||||
{ pattern: "zendesk.com", scopes: ["read"] },
|
||||
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
|
||||
{ pattern: "stripe.com", scopes: ["read"] },
|
||||
{ pattern: "twilio.com", scopes: ["read"] },
|
||||
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
|
||||
{ pattern: "mailchimp.com", scopes: ["read"] },
|
||||
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
|
||||
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
|
||||
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "notion.so", scopes: ["read"] },
|
||||
{ pattern: "figma.com", scopes: ["files:read"] },
|
||||
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
|
||||
{ pattern: "box.com", scopes: ["root_readwrite"] },
|
||||
{ pattern: "trello.com", scopes: ["read"] },
|
||||
{ pattern: "asana.com", scopes: ["default"] },
|
||||
{ pattern: "monday.com", scopes: ["read"] },
|
||||
{ pattern: "clickup.com", scopes: ["read"] },
|
||||
{ pattern: "linear.app", scopes: ["read"] },
|
||||
];
|
||||
|
||||
// Procura por padrões conhecidos
|
||||
for (const { pattern, scopes } of providerPatterns) {
|
||||
if (urlLower.includes(pattern)) {
|
||||
return scopes;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback baseado no tipo do provider
|
||||
if (newProvider.type === "oidc") {
|
||||
return ["openid", "profile", "email"];
|
||||
} else {
|
||||
return ["profile", "email"];
|
||||
}
|
||||
};
|
||||
|
||||
// Função para auto-sugerir scopes baseado na Provider URL (onBlur)
|
||||
const updateProviderUrl = (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
const suggestedScopes = detectProviderTypeAndSuggestScopes(url);
|
||||
|
||||
setNewProvider((prev) => {
|
||||
const shouldUpdateScopes = !prev.scope || prev.scope === "openid profile email" || prev.scope === "profile email";
|
||||
|
||||
return {
|
||||
...prev,
|
||||
scope: shouldUpdateScopes ? suggestedScopes.join(" ") : prev.scope,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Load providers
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
@@ -146,8 +248,26 @@ export function AuthProvidersSettings() {
|
||||
|
||||
// Add new provider
|
||||
const addProvider = async () => {
|
||||
if (!newProvider.name || !newProvider.displayName) {
|
||||
toast.error("Please fill in all required fields");
|
||||
if (!newProvider.name || !newProvider.displayName || !newProvider.clientId || !newProvider.clientSecret) {
|
||||
toast.error("Please fill in all required fields (name, display name, client ID, client secret)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de configuração
|
||||
const hasIssuerUrl = !!newProvider.issuerUrl;
|
||||
const hasAllCustomEndpoints = !!(
|
||||
newProvider.authorizationEndpoint &&
|
||||
newProvider.tokenEndpoint &&
|
||||
newProvider.userInfoEndpoint
|
||||
);
|
||||
|
||||
if (!hasIssuerUrl && !hasAllCustomEndpoints) {
|
||||
toast.error("Either provide a Provider URL for automatic discovery OR all three custom endpoints");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasIssuerUrl && hasAllCustomEndpoints) {
|
||||
toast.error("Choose either automatic discovery (Provider URL) OR manual endpoints, not both");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,12 +277,21 @@ export function AuthProvidersSettings() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...newProvider,
|
||||
name: newProvider.name.toLowerCase().replace(/\s+/g, "-"),
|
||||
displayName: newProvider.displayName,
|
||||
type: newProvider.type,
|
||||
icon: newProvider.icon,
|
||||
clientId: newProvider.clientId,
|
||||
clientSecret: newProvider.clientSecret,
|
||||
enabled: false,
|
||||
autoRegister: true,
|
||||
scope: newProvider.type === "oidc" ? "openid profile email" : "user:email",
|
||||
scope: newProvider.scope || (newProvider.type === "oidc" ? "openid profile email" : "user:email"),
|
||||
sortOrder: providers.length + 1,
|
||||
// Incluir apenas campos relevantes baseado no modo
|
||||
...(newProvider.issuerUrl ? { issuerUrl: newProvider.issuerUrl } : {}),
|
||||
...(newProvider.authorizationEndpoint ? { authorizationEndpoint: newProvider.authorizationEndpoint } : {}),
|
||||
...(newProvider.tokenEndpoint ? { tokenEndpoint: newProvider.tokenEndpoint } : {}),
|
||||
...(newProvider.userInfoEndpoint ? { userInfoEndpoint: newProvider.userInfoEndpoint } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -170,7 +299,19 @@ export function AuthProvidersSettings() {
|
||||
|
||||
if (data.success) {
|
||||
await loadProviders();
|
||||
setNewProvider({ name: "", displayName: "", type: "oidc", icon: "" });
|
||||
setNewProvider({
|
||||
name: "",
|
||||
displayName: "",
|
||||
type: "oidc",
|
||||
icon: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
scope: "openid profile email",
|
||||
authorizationEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
userInfoEndpoint: "",
|
||||
});
|
||||
setShowAddForm(false);
|
||||
toast.success("Provider added");
|
||||
} else {
|
||||
@@ -219,6 +360,57 @@ export function AuthProvidersSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Test provider
|
||||
const testProvider = async (id: string) => {
|
||||
try {
|
||||
setTestingProvider(id);
|
||||
setTestResults((prev) => ({ ...prev, [id]: null }));
|
||||
|
||||
const response = await fetch(`/api/auth/providers/manage/${id}/test`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const result = data.data;
|
||||
setTestResults((prev) => ({ ...prev, [id]: result }));
|
||||
|
||||
if (result.overall.status === "success") {
|
||||
toast.success(`✅ ${result.overall.message}`);
|
||||
} else if (result.overall.status === "warning") {
|
||||
toast.warning(`⚠️ ${result.overall.message}`);
|
||||
} else {
|
||||
toast.error(`❌ ${result.overall.message}`);
|
||||
}
|
||||
|
||||
// Log detailed results for debugging
|
||||
console.log("Provider Test Results:", result);
|
||||
} else {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
overall: { status: "error", message: data.error },
|
||||
tests: [],
|
||||
},
|
||||
}));
|
||||
toast.error(`Test failed: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error testing provider:", error);
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
overall: { status: "error", message: "Failed to test provider" },
|
||||
tests: [],
|
||||
},
|
||||
}));
|
||||
toast.error("Failed to test provider");
|
||||
} finally {
|
||||
setTestingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDragEnd = async (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
@@ -397,6 +589,186 @@ export function AuthProvidersSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Method Toggle */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium mb-3">Configuration Method</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="add-auto-discovery"
|
||||
name="addConfigMethod"
|
||||
checked={
|
||||
!newProvider.authorizationEndpoint &&
|
||||
!newProvider.tokenEndpoint &&
|
||||
!newProvider.userInfoEndpoint
|
||||
}
|
||||
onChange={() =>
|
||||
setNewProvider((prev) => ({
|
||||
...prev,
|
||||
authorizationEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
userInfoEndpoint: "",
|
||||
}))
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="add-auto-discovery" className="text-sm">
|
||||
<span className="font-medium">Automatic Discovery</span>
|
||||
<span className="text-muted-foreground ml-2">(Just provide Provider URL)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="add-manual-endpoints"
|
||||
name="addConfigMethod"
|
||||
checked={
|
||||
!!(
|
||||
newProvider.authorizationEndpoint ||
|
||||
newProvider.tokenEndpoint ||
|
||||
newProvider.userInfoEndpoint
|
||||
)
|
||||
}
|
||||
onChange={() => {
|
||||
if (
|
||||
!newProvider.authorizationEndpoint &&
|
||||
!newProvider.tokenEndpoint &&
|
||||
!newProvider.userInfoEndpoint
|
||||
) {
|
||||
setNewProvider((prev) => ({
|
||||
...prev,
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/oauth/userinfo",
|
||||
issuerUrl: "",
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="add-manual-endpoints" className="text-sm">
|
||||
<span className="font-medium">Manual Endpoints</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
(Recommended - For providers that don't support discovery)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Automatic Discovery Mode */}
|
||||
{!newProvider.authorizationEndpoint &&
|
||||
!newProvider.tokenEndpoint &&
|
||||
!newProvider.userInfoEndpoint && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Provider URL *</Label>
|
||||
<Input
|
||||
placeholder="https://your-provider.com (endpoints will be discovered automatically)"
|
||||
value={newProvider.issuerUrl}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))}
|
||||
onBlur={(e) => updateProviderUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The system will automatically discover authorization, token, and userinfo endpoints
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Endpoints Mode */}
|
||||
{(newProvider.authorizationEndpoint || newProvider.tokenEndpoint || newProvider.userInfoEndpoint) && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Provider URL *</Label>
|
||||
<Input
|
||||
placeholder="https://your-provider.com"
|
||||
value={newProvider.issuerUrl}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))}
|
||||
onBlur={(e) => updateProviderUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Base URL of your provider (endpoints will be relative to this)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Authorization Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/authorize"
|
||||
value={newProvider.authorizationEndpoint}
|
||||
onChange={(e) =>
|
||||
setNewProvider((prev) => ({ ...prev, authorizationEndpoint: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Token Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/token"
|
||||
value={newProvider.tokenEndpoint}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">User Info Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/userinfo"
|
||||
value={newProvider.userInfoEndpoint}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, userInfoEndpoint: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-blue-700 dark:text-blue-300">
|
||||
<IconInfoCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium">Manual Configuration</p>
|
||||
<p className="mt-1">
|
||||
You're providing all endpoints manually. Make sure they're correct for your provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client Credentials */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Client ID *</Label>
|
||||
<Input
|
||||
placeholder="Your OAuth client ID"
|
||||
value={newProvider.clientId}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, clientId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Client Secret *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your OAuth client secret"
|
||||
value={newProvider.clientSecret}
|
||||
onChange={(e) => setNewProvider((prev) => ({ ...prev, clientSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Scopes */}
|
||||
<div>
|
||||
<Label className="mb-2 block">OAuth Scopes</Label>
|
||||
<TagsInput
|
||||
value={newProvider.scope ? newProvider.scope.split(/[,\s]+/).filter(Boolean) : []}
|
||||
onChange={(tags) => setNewProvider((prev) => ({ ...prev, scope: tags.join(" ") }))}
|
||||
placeholder="Enter scopes (e.g., openid, profile, email)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{newProvider.type === "oidc"
|
||||
? "Scopes auto-suggested based on Provider URL. Common OIDC scopes: openid, profile, email, groups"
|
||||
: "Scopes auto-suggested based on Provider URL. Common OAuth2 scopes depend on the provider"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show callback URL if provider name is filled */}
|
||||
{newProvider.name && (
|
||||
<div className="pt-2">
|
||||
@@ -448,7 +820,10 @@ export function AuthProvidersSettings() {
|
||||
}
|
||||
}}
|
||||
onDelete={() => deleteProvider(provider.id, provider.displayName)}
|
||||
onTest={() => testProvider(provider.id)}
|
||||
saving={saving === provider.id}
|
||||
testing={testingProvider === provider.id}
|
||||
testResults={testResults[provider.id]}
|
||||
getIcon={getProviderIcon}
|
||||
editingProvider={editingProvider}
|
||||
editProvider={editProvider}
|
||||
@@ -491,7 +866,10 @@ interface ProviderRowProps {
|
||||
onUpdate: (updates: Partial<AuthProvider>) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
testResults: any;
|
||||
getIcon: (provider: AuthProvider) => React.ReactNode;
|
||||
editingProvider: AuthProvider | null;
|
||||
editProvider: (data: Partial<AuthProvider>) => void;
|
||||
@@ -507,7 +885,10 @@ function ProviderRow({
|
||||
onUpdate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
saving,
|
||||
testing,
|
||||
testResults,
|
||||
getIcon,
|
||||
editingProvider,
|
||||
editProvider,
|
||||
@@ -545,7 +926,7 @@ function ProviderRow({
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{provider.type.toUpperCase()} • {provider.name}
|
||||
{provider.isOfficial && <span className="text-blue-600 dark:text-blue-400"> • Optimized by Palmr.</span>}
|
||||
{provider.isOfficial && <span className="text-blue-600 dark:text-blue-400"> • Official Provider</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,7 +963,10 @@ function ProviderRow({
|
||||
provider={provider}
|
||||
onSave={editProvider}
|
||||
onCancel={onCancelEdit}
|
||||
onTest={onTest}
|
||||
saving={saving}
|
||||
testing={testing}
|
||||
testResults={testResults}
|
||||
editingFormData={editingFormData}
|
||||
setEditingFormData={setEditingFormData}
|
||||
/>
|
||||
@@ -597,7 +981,10 @@ interface EditProviderFormProps {
|
||||
provider: AuthProvider;
|
||||
onSave: (data: Partial<AuthProvider>) => void;
|
||||
onCancel: () => void;
|
||||
onTest: () => void;
|
||||
saving: boolean;
|
||||
testing: boolean;
|
||||
testResults: any;
|
||||
editingFormData: Record<string, any>;
|
||||
setEditingFormData: (data: Record<string, any>) => void;
|
||||
}
|
||||
@@ -606,7 +993,10 @@ function EditProviderForm({
|
||||
provider,
|
||||
onSave,
|
||||
onCancel,
|
||||
onTest,
|
||||
saving,
|
||||
testing,
|
||||
testResults,
|
||||
editingFormData,
|
||||
setEditingFormData,
|
||||
}: EditProviderFormProps) {
|
||||
@@ -623,11 +1013,99 @@ function EditProviderForm({
|
||||
scope: savedData.scope || provider.scope || "",
|
||||
autoRegister: savedData.autoRegister !== undefined ? savedData.autoRegister : provider.autoRegister,
|
||||
adminEmailDomains: savedData.adminEmailDomains || provider.adminEmailDomains || "",
|
||||
authorizationEndpoint: savedData.authorizationEndpoint || provider.authorizationEndpoint || "",
|
||||
tokenEndpoint: savedData.tokenEndpoint || provider.tokenEndpoint || "",
|
||||
userInfoEndpoint: savedData.userInfoEndpoint || provider.userInfoEndpoint || "",
|
||||
});
|
||||
|
||||
const [showClientSecret, setShowClientSecret] = useState(false);
|
||||
const isOfficial = provider.isOfficial;
|
||||
|
||||
// Auto-sugestão de scopes para formulário de edição
|
||||
const detectProviderTypeAndSuggestScopesEdit = (url: string, currentType: string): string[] => {
|
||||
if (!url) return [];
|
||||
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
// Mesmos padrões do formulário de adição
|
||||
const providerPatterns = [
|
||||
{ pattern: "frontegg.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "discord.com", scopes: ["identify", "email"] },
|
||||
{ pattern: "github.com", scopes: ["read:user", "user:email"] },
|
||||
{ pattern: "gitlab.com", scopes: ["read_user", "read_api"] },
|
||||
{ pattern: "google.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
|
||||
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
|
||||
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
|
||||
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
|
||||
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
|
||||
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
|
||||
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
|
||||
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
|
||||
{ pattern: "zendesk.com", scopes: ["read"] },
|
||||
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
|
||||
{ pattern: "stripe.com", scopes: ["read"] },
|
||||
{ pattern: "twilio.com", scopes: ["read"] },
|
||||
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
|
||||
{ pattern: "mailchimp.com", scopes: ["read"] },
|
||||
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
|
||||
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
|
||||
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
||||
{ pattern: "notion.so", scopes: ["read"] },
|
||||
{ pattern: "figma.com", scopes: ["files:read"] },
|
||||
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
|
||||
{ pattern: "box.com", scopes: ["root_readwrite"] },
|
||||
{ pattern: "trello.com", scopes: ["read"] },
|
||||
{ pattern: "asana.com", scopes: ["default"] },
|
||||
{ pattern: "monday.com", scopes: ["read"] },
|
||||
{ pattern: "clickup.com", scopes: ["read"] },
|
||||
{ pattern: "linear.app", scopes: ["read"] },
|
||||
];
|
||||
|
||||
// Procura por padrões conhecidos
|
||||
for (const { pattern, scopes } of providerPatterns) {
|
||||
if (urlLower.includes(pattern)) {
|
||||
return scopes;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback baseado no tipo do provider
|
||||
if (currentType === "oidc") {
|
||||
return ["openid", "profile", "email"];
|
||||
} else {
|
||||
return ["profile", "email"];
|
||||
}
|
||||
};
|
||||
|
||||
// Função para auto-sugerir scopes baseado na Provider URL no formulário de edição (onBlur)
|
||||
const updateProviderUrlEdit = (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
if (isOfficial) {
|
||||
// Para providers oficiais, não faz auto-sugestão de scopes
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedScopes = detectProviderTypeAndSuggestScopesEdit(url, formData.type);
|
||||
const shouldUpdateScopes =
|
||||
!formData.scope || formData.scope === "openid profile email" || formData.scope === "profile email";
|
||||
|
||||
// Só atualiza scopes, não a URL (já foi atualizada pelo onChange)
|
||||
if (shouldUpdateScopes) {
|
||||
updateFormData({
|
||||
scope: suggestedScopes.join(" "),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateFormData = (updates: Partial<typeof formData>) => {
|
||||
const newFormData = { ...formData, ...updates };
|
||||
setFormData(newFormData);
|
||||
@@ -706,14 +1184,154 @@ function EditProviderForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.type === "oidc" && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Issuer URL *</Label>
|
||||
<Input
|
||||
placeholder="https://your-provider.com/app/authorize"
|
||||
value={formData.issuerUrl}
|
||||
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
|
||||
/>
|
||||
{/* Configuration - Only for custom providers */}
|
||||
{!isOfficial && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium mb-3">Configuration Method</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="auto-discovery"
|
||||
name="configMethod"
|
||||
checked={!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint}
|
||||
onChange={() =>
|
||||
updateFormData({
|
||||
authorizationEndpoint: "",
|
||||
tokenEndpoint: "",
|
||||
userInfoEndpoint: "",
|
||||
})
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="auto-discovery" className="text-sm">
|
||||
<span className="font-medium">Automatic Discovery</span>
|
||||
<span className="text-muted-foreground ml-2">(Just provide Provider URL)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="manual-endpoints"
|
||||
name="configMethod"
|
||||
checked={!!(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint)}
|
||||
onChange={() => {
|
||||
if (!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint) {
|
||||
updateFormData({
|
||||
authorizationEndpoint: "/oauth/authorize",
|
||||
tokenEndpoint: "/oauth/token",
|
||||
userInfoEndpoint: "/oauth/userinfo",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="manual-endpoints" className="text-sm">
|
||||
<span className="font-medium">Manual Endpoints</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
(Recommended - For providers that don't support discovery)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Automatic Discovery Mode */}
|
||||
{!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Provider URL *</Label>
|
||||
<Input
|
||||
placeholder="https://your-provider.com (endpoints will be discovered automatically)"
|
||||
value={formData.issuerUrl}
|
||||
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
|
||||
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The system will automatically discover authorization, token, and userinfo endpoints
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Endpoints Mode */}
|
||||
{(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint) && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Provider URL *</Label>
|
||||
<Input
|
||||
placeholder="https://your-provider.com"
|
||||
value={formData.issuerUrl}
|
||||
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
|
||||
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Base URL of your provider (endpoints will be relative to this)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Authorization Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/authorize"
|
||||
value={formData.authorizationEndpoint}
|
||||
onChange={(e) => updateFormData({ authorizationEndpoint: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Token Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/token"
|
||||
value={formData.tokenEndpoint}
|
||||
onChange={(e) => updateFormData({ tokenEndpoint: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">User Info Endpoint *</Label>
|
||||
<Input
|
||||
placeholder="/oauth/userinfo"
|
||||
value={formData.userInfoEndpoint}
|
||||
onChange={(e) => updateFormData({ userInfoEndpoint: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-blue-700 dark:text-blue-300">
|
||||
<IconInfoCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium">Manual Configuration</p>
|
||||
<p className="mt-1">
|
||||
You're providing all endpoints manually. Make sure they're correct for your provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Official Provider - Only Provider URL and Icon */}
|
||||
{isOfficial && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-2 block">Provider URL *</Label>
|
||||
<Input
|
||||
placeholder={`Replace placeholder with your ${provider.displayName} URL`}
|
||||
value={formData.issuerUrl}
|
||||
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
|
||||
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This is an official provider. Endpoints are pre-configured. You can edit just this URL.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">Icon</Label>
|
||||
<IconPicker
|
||||
value={formData.icon}
|
||||
onChange={(icon) => updateFormData({ icon })}
|
||||
placeholder="Select an icon"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">You can customize the icon for this official provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -762,8 +1380,8 @@ function EditProviderForm({
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.type === "oidc"
|
||||
? "Common OIDC scopes: openid, profile, email, groups"
|
||||
: "Common OAuth2 scopes depend on the provider"}
|
||||
? "Scopes auto-suggested based on Provider URL. Common OIDC scopes: openid, profile, email, groups"
|
||||
: "Scopes auto-suggested based on Provider URL. Common OAuth2 scopes depend on the provider"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -787,14 +1405,90 @@ function EditProviderForm({
|
||||
<Label className="cursor-pointer">Auto-register new users</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={onCancel} size="sm">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving} size="sm">
|
||||
{saving ? "Salvando..." : "Salvar Provider"}
|
||||
<div className="flex gap-2 justify-between pt-4">
|
||||
<Button variant="outline" onClick={onTest} disabled={saving || testing} size="sm">
|
||||
{testing ? (
|
||||
<>
|
||||
<IconSettings className="h-3 w-3 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
"Test Provider"
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} size="sm">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || testing} size="sm">
|
||||
{saving ? "Salvando..." : "Salvar Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results Display */}
|
||||
{testResults && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className={`rounded-lg p-3 border ${
|
||||
testResults.overall?.status === "success"
|
||||
? "bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800"
|
||||
: testResults.overall?.status === "warning"
|
||||
? "bg-yellow-50 dark:bg-yellow-950/50 border-yellow-200 dark:border-yellow-800"
|
||||
: "bg-red-50 dark:bg-red-950/50 border-red-200 dark:border-red-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{testResults.overall?.status === "success" ? (
|
||||
<IconCheck className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
) : testResults.overall?.status === "warning" ? (
|
||||
<IconAlertTriangle className="h-4 w-4 mt-0.5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
<IconX className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
testResults.overall?.status === "success"
|
||||
? "text-green-700 dark:text-green-300"
|
||||
: testResults.overall?.status === "warning"
|
||||
? "text-yellow-700 dark:text-yellow-300"
|
||||
: "text-red-700 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResults.overall?.message}
|
||||
</p>
|
||||
{testResults.tests && testResults.tests.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{testResults.tests.map((test: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
{test.status === "success" ? (
|
||||
<IconCheck className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
) : test.status === "warning" ? (
|
||||
<IconAlertTriangle className="h-3 w-3 text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<IconX className="h-3 w-3 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
test.status === "success"
|
||||
? "text-green-700 dark:text-green-300"
|
||||
: test.status === "warning"
|
||||
? "text-yellow-700 dark:text-yellow-300"
|
||||
: "text-red-700 dark:text-red-300"
|
||||
}
|
||||
>
|
||||
{test.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -36,7 +36,7 @@ import * as WiIcons from "react-icons/wi";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
@@ -158,7 +158,7 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
|
||||
<div className="grid grid-cols-8 sm:grid-cols-12 lg:grid-cols-16 xl:grid-cols-20 gap-2 sm:gap-3">
|
||||
{categoryIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.name}
|
||||
key={`${icon.category}-${icon.name}`}
|
||||
className="h-12 w-12 sm:h-14 sm:w-14 p-0 hover:bg-muted transition-colors flex-shrink-0 rounded-md flex items-center justify-center cursor-pointer"
|
||||
onClick={() => onIconSelect(icon.name)}
|
||||
title={icon.name}
|
||||
@@ -190,7 +190,7 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
|
||||
<div className="grid grid-cols-8 sm:grid-cols-12 lg:grid-cols-16 xl:grid-cols-20 gap-2 sm:gap-3">
|
||||
{visibleIcons.map((icon) => (
|
||||
<button
|
||||
key={icon.name}
|
||||
key={`${icon.category}-${icon.name}`}
|
||||
className="h-12 w-12 sm:h-14 sm:w-14 p-0 hover:bg-muted transition-colors flex-shrink-0 rounded-md flex items-center justify-center cursor-pointer"
|
||||
onClick={() => onIconSelect(icon.name)}
|
||||
title={`${icon.name} (${icon.category})`}
|
||||
@@ -312,10 +312,17 @@ export function IconPicker({ value, onChange, placeholder = "Select an icon" }:
|
||||
];
|
||||
|
||||
const icons: IconData[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
iconSets.forEach(({ icons: iconSet, prefix, category }) => {
|
||||
Object.entries(iconSet).forEach(([name, component]) => {
|
||||
if (typeof component === "function" && name.startsWith(prefix)) {
|
||||
// Skip duplicates - keep only the first occurrence
|
||||
if (seenNames.has(name)) {
|
||||
return;
|
||||
}
|
||||
seenNames.add(name);
|
||||
|
||||
icons.push({
|
||||
name,
|
||||
component: component as React.ComponentType<{ className?: string }>,
|
||||
@@ -390,7 +397,7 @@ export function IconPicker({ value, onChange, placeholder = "Select an icon" }:
|
||||
<DialogContent className="max-w-5xl xl:max-w-6xl max-h-[90vh] overflow-hidden">
|
||||
<div className="space-y-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Selecionar Ícone</h3>
|
||||
<DialogTitle>Selecionar Ícone</DialogTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{allIcons.length.toLocaleString()} ícones de {categories.length} bibliotecas
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user