forked from baron/baron-sso
2853 lines
110 KiB
TypeScript
2853 lines
110 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { AxiosError } from "axios";
|
||
import {
|
||
ArrowLeft,
|
||
Check,
|
||
ExternalLink,
|
||
Info,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
Shield,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { useAuth } from "react-oidc-context";
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "../../components/ui/card";
|
||
import { Input } from "../../components/ui/input";
|
||
import { Label } from "../../components/ui/label";
|
||
import { Switch } from "../../components/ui/switch";
|
||
import { Textarea } from "../../components/ui/textarea";
|
||
import { toast } from "../../components/ui/use-toast";
|
||
import {
|
||
type ClientRelation,
|
||
createClient,
|
||
deleteClient,
|
||
fetchClient,
|
||
fetchClientRelations,
|
||
fetchMyTenants,
|
||
refreshHeadlessJwksCache,
|
||
revokeHeadlessJwksCache,
|
||
updateClient,
|
||
updateClientStatus,
|
||
} from "../../lib/devApi";
|
||
import type {
|
||
ClientStatus,
|
||
ClientType,
|
||
ClientUpsertRequest,
|
||
MyTenantSummary,
|
||
TenantSummary,
|
||
} from "../../lib/devApi";
|
||
import { t } from "../../lib/i18n";
|
||
import { resolveProfileRole } from "../../lib/role";
|
||
import { cn } from "../../lib/utils";
|
||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||
|
||
interface ScopeItem {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
mandatory: boolean;
|
||
locked?: boolean;
|
||
}
|
||
|
||
type ClaimNamespace = "top_level" | "rp_claims";
|
||
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
|
||
|
||
interface IdTokenClaimItem {
|
||
id: string;
|
||
namespace: ClaimNamespace;
|
||
key: string;
|
||
value: string;
|
||
valueType: ClaimValueType;
|
||
}
|
||
|
||
type SecurityProfile = "private" | "pkce";
|
||
type TokenEndpointAuthMethod =
|
||
| "none"
|
||
| "client_secret_basic"
|
||
| "private_key_jwt";
|
||
|
||
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
|
||
"RS256",
|
||
"RS384",
|
||
"RS512",
|
||
"PS256",
|
||
"PS384",
|
||
"PS512",
|
||
"ES256",
|
||
"ES384",
|
||
"ES512",
|
||
"EdDSA",
|
||
] as const;
|
||
|
||
const HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET = new Set<string>(
|
||
HEADLESS_LOGIN_ALLOWED_ALGORITHMS,
|
||
);
|
||
|
||
function formatHeadlessParsedKeyLabel(
|
||
kid: string | undefined,
|
||
index: number,
|
||
): string {
|
||
const trimmedKid = kid?.trim();
|
||
if (trimmedKid) {
|
||
return trimmedKid;
|
||
}
|
||
return `key #${index + 1}`;
|
||
}
|
||
|
||
function isTokenEndpointAuthMethod(
|
||
value: string,
|
||
): value is TokenEndpointAuthMethod {
|
||
return (
|
||
value === "none" ||
|
||
value === "client_secret_basic" ||
|
||
value === "private_key_jwt"
|
||
);
|
||
}
|
||
|
||
function readMetadataString(
|
||
metadata: Record<string, unknown>,
|
||
key: string,
|
||
): string {
|
||
const value = metadata[key];
|
||
return typeof value === "string" ? value : "";
|
||
}
|
||
|
||
function isClaimNamespace(value: string): value is ClaimNamespace {
|
||
return value === "top_level" || value === "rp_claims";
|
||
}
|
||
|
||
function isClaimValueType(value: string): value is ClaimValueType {
|
||
return (
|
||
value === "text" ||
|
||
value === "number" ||
|
||
value === "boolean" ||
|
||
value === "array" ||
|
||
value === "object"
|
||
);
|
||
}
|
||
|
||
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||
return {
|
||
id,
|
||
namespace: "top_level",
|
||
key: "",
|
||
value: "",
|
||
valueType: "text",
|
||
};
|
||
}
|
||
|
||
function readIdTokenClaimsMetadata(
|
||
metadata: Record<string, unknown>,
|
||
): IdTokenClaimItem[] {
|
||
const rawClaims = metadata.id_token_claims;
|
||
if (!Array.isArray(rawClaims)) {
|
||
return [];
|
||
}
|
||
|
||
return rawClaims
|
||
.map((item, index) => {
|
||
if (!item || typeof item !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const record = item as Record<string, unknown>;
|
||
const namespaceValue =
|
||
typeof record.namespace === "string" &&
|
||
isClaimNamespace(record.namespace)
|
||
? record.namespace
|
||
: "top_level";
|
||
const keyValue = typeof record.key === "string" ? record.key : "";
|
||
const rawValue = record.value;
|
||
const valueValue =
|
||
typeof rawValue === "string"
|
||
? rawValue
|
||
: rawValue == null
|
||
? ""
|
||
: JSON.stringify(rawValue);
|
||
const valueTypeValue =
|
||
typeof record.valueType === "string" &&
|
||
isClaimValueType(record.valueType)
|
||
? record.valueType
|
||
: "text";
|
||
|
||
return {
|
||
id: `claim-${index + 1}`,
|
||
namespace: namespaceValue,
|
||
key: keyValue,
|
||
value: valueValue,
|
||
valueType: valueTypeValue,
|
||
};
|
||
})
|
||
.filter((item): item is IdTokenClaimItem => item !== null);
|
||
}
|
||
|
||
function normalizeClaimPreviewValue(
|
||
value: string,
|
||
valueType: ClaimValueType,
|
||
): unknown {
|
||
const trimmed = value.trim();
|
||
if (valueType === "number") {
|
||
if (trimmed === "") return "";
|
||
const parsed = Number(trimmed);
|
||
return Number.isFinite(parsed) ? parsed : trimmed;
|
||
}
|
||
|
||
if (valueType === "boolean") {
|
||
return ["true", "1", "yes", "on"].includes(trimmed.toLowerCase());
|
||
}
|
||
|
||
if (valueType === "array") {
|
||
if (trimmed === "") return [];
|
||
try {
|
||
if (trimmed.startsWith("[")) {
|
||
const parsed = JSON.parse(trimmed);
|
||
return Array.isArray(parsed) ? parsed : [parsed];
|
||
}
|
||
} catch {
|
||
// Fall through to comma-separated parsing.
|
||
}
|
||
return trimmed
|
||
.split(",")
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
if (valueType === "object") {
|
||
if (trimmed === "") return {};
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
return parsed;
|
||
} catch {
|
||
return trimmed;
|
||
}
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
function buildIdTokenClaimsPreview(
|
||
items: IdTokenClaimItem[],
|
||
): Record<string, unknown> {
|
||
const preview: Record<string, unknown> = {};
|
||
const rpClaims: Record<string, unknown> = {};
|
||
|
||
for (const item of items) {
|
||
const key = item.key.trim();
|
||
if (!key) {
|
||
continue;
|
||
}
|
||
|
||
const target = item.namespace === "rp_claims" ? rpClaims : preview;
|
||
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
|
||
}
|
||
|
||
if (Object.keys(rpClaims).length > 0) {
|
||
preview.rp_claims = rpClaims;
|
||
}
|
||
|
||
return preview;
|
||
}
|
||
|
||
function isValidUrl(value: string): boolean {
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === "https:" || url.protocol === "http:";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isValidBackchannelLogoutUrl(value: string): boolean {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
const url = new URL(trimmed);
|
||
if (url.hash) {
|
||
return false;
|
||
}
|
||
if (url.protocol === "https:") {
|
||
return true;
|
||
}
|
||
if (url.protocol !== "http:") {
|
||
return false;
|
||
}
|
||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
function ClientGeneralPage() {
|
||
const auth = useAuth();
|
||
const params = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const clientId = params.id;
|
||
const isCreate = !clientId;
|
||
const currentUserId = auth.user?.profile.sub;
|
||
const systemRole = resolveProfileRole(
|
||
auth.user?.profile as Record<string, unknown> | undefined,
|
||
);
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["client", clientId],
|
||
queryFn: () => fetchClient(clientId as string),
|
||
enabled: !isCreate,
|
||
});
|
||
const { data: relationData } = useQuery({
|
||
queryKey: ["client-relations", clientId],
|
||
queryFn: () => fetchClientRelations(clientId as string),
|
||
enabled: !isCreate,
|
||
retry: false,
|
||
});
|
||
const { data: tenantData } = useQuery({
|
||
queryKey: ["my-tenants"],
|
||
queryFn: fetchMyTenants,
|
||
});
|
||
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [logoUrl, setLogoUrl] = useState("");
|
||
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
|
||
"idle" | "loading" | "loaded" | "error"
|
||
>("idle");
|
||
const [clientType, setClientType] = useState<ClientType>("private");
|
||
const [status, setStatus] = useState<ClientStatus>("active");
|
||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||
const [redirectUris, setRedirectUris] = useState("");
|
||
const [backchannelLogoutUri, setBackchannelLogoutUri] = useState("");
|
||
const [
|
||
backchannelLogoutSessionRequired,
|
||
setBackchannelLogoutSessionRequired,
|
||
] = useState(false);
|
||
const [
|
||
isBackchannelSessionRequiredInfoOpen,
|
||
setIsBackchannelSessionRequiredInfoOpen,
|
||
] = useState(false);
|
||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||
const [tenantSearch, setTenantSearch] = useState("");
|
||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||
|
||
// Public Key Registration States
|
||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||
const [jwksUri, setJwksUri] = useState("");
|
||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||
|
||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||
{
|
||
id: "1",
|
||
name: "openid",
|
||
description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"),
|
||
mandatory: true,
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "tenant",
|
||
description: t("msg.dev.clients.scopes.tenant", "소속 테넌트 정보 접근"),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "profile",
|
||
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "4",
|
||
name: "email",
|
||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||
mandatory: false,
|
||
},
|
||
]);
|
||
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
const { client } = data;
|
||
setName(client.name || client.id);
|
||
setClientType(client.type);
|
||
setStatus(client.status);
|
||
setInitialStatus(client.status);
|
||
|
||
const metadata = client.metadata ?? {};
|
||
if (typeof metadata.description === "string")
|
||
setDescription(metadata.description);
|
||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||
setBackchannelLogoutUri(
|
||
client.backchannelLogoutUri ||
|
||
readMetadataString(metadata, "backchannel_logout_uri"),
|
||
);
|
||
setBackchannelLogoutSessionRequired(
|
||
client.backchannelLogoutSessionRequired === true ||
|
||
metadata.backchannel_logout_session_required === true,
|
||
);
|
||
setAutoLoginSupported(metadata.auto_login_supported === true);
|
||
if (typeof metadata.auto_login_url === "string")
|
||
setAutoLoginUrl(metadata.auto_login_url);
|
||
|
||
const headlessEnabled = !!metadata.headless_login_enabled;
|
||
setHeadlessLoginEnabled(headlessEnabled);
|
||
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
|
||
? metadata.allowed_tenants
|
||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||
.filter(Boolean)
|
||
: [];
|
||
setTenantAccessRestricted(
|
||
restrictedTenants.length > 0 ||
|
||
metadata.tenant_access_restricted === true,
|
||
);
|
||
setAllowedTenantIds(restrictedTenants);
|
||
|
||
const savedAuthMethod =
|
||
client.tokenEndpointAuthMethod ||
|
||
(client.type === "pkce" ? "none" : "client_secret_basic");
|
||
const headlessAuthMethod = readMetadataString(
|
||
metadata,
|
||
"headless_token_endpoint_auth_method",
|
||
);
|
||
const selectedAuthMethod =
|
||
headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod)
|
||
? headlessAuthMethod
|
||
: savedAuthMethod;
|
||
if (isTokenEndpointAuthMethod(selectedAuthMethod)) {
|
||
setTokenEndpointAuthMethod(selectedAuthMethod);
|
||
}
|
||
|
||
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
|
||
if (headlessJwksUri) {
|
||
setJwksUri(headlessJwksUri);
|
||
} else if (client.jwksUri) {
|
||
setJwksUri(client.jwksUri);
|
||
} else {
|
||
setJwksUri("");
|
||
}
|
||
|
||
// Fallbacks from metadata if top-level fields are empty
|
||
if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
|
||
const metaAuth = readMetadataString(
|
||
metadata,
|
||
"token_endpoint_auth_method",
|
||
);
|
||
if (isTokenEndpointAuthMethod(metaAuth)) {
|
||
setTokenEndpointAuthMethod(metaAuth);
|
||
}
|
||
}
|
||
|
||
if (!client.jwksUri && !headlessEnabled) {
|
||
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
||
if (metaJwksUri) {
|
||
setJwksUri(metaJwksUri);
|
||
}
|
||
}
|
||
|
||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||
if (savedScopes && Array.isArray(savedScopes)) {
|
||
setScopes(
|
||
normalizeScopesForTenantAccess(
|
||
savedScopes,
|
||
restrictedTenants.length > 0 ||
|
||
metadata.tenant_access_restricted === true,
|
||
),
|
||
);
|
||
} else {
|
||
setScopes(
|
||
normalizeScopesForTenantAccess(
|
||
client.scopes.map((s, idx) => ({
|
||
id: String(idx + 1),
|
||
name: s,
|
||
description: "",
|
||
mandatory: s === "openid",
|
||
})),
|
||
restrictedTenants.length > 0 ||
|
||
metadata.tenant_access_restricted === true,
|
||
),
|
||
);
|
||
}
|
||
setIdTokenClaims(readIdTokenClaimsMetadata(metadata));
|
||
}, [data]);
|
||
|
||
const securityProfile: SecurityProfile =
|
||
clientType === "pkce" ? "pkce" : "private";
|
||
const canEditExistingClientGeneralSettings =
|
||
systemRole === "super_admin" ||
|
||
relationData?.items?.some(
|
||
(item: ClientRelation) =>
|
||
item.subject === `User:${currentUserId}` &&
|
||
(item.relation === "admins" || item.relation === "config_editor"),
|
||
) === true;
|
||
const isGeneralSettingsReadOnly =
|
||
!isCreate && relationData != null && !canEditExistingClientGeneralSettings;
|
||
const trimmedLogoUrl = logoUrl.trim();
|
||
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||
const trimmedBackchannelLogoutUri = backchannelLogoutUri.trim();
|
||
const hasBackchannelLogoutUri = trimmedBackchannelLogoutUri.length > 0;
|
||
const hasValidBackchannelLogoutUri =
|
||
!hasBackchannelLogoutUri ||
|
||
isValidBackchannelLogoutUrl(trimmedBackchannelLogoutUri);
|
||
const hasValidAutoLoginUrl =
|
||
!autoLoginSupported ||
|
||
(trimmedAutoLoginUrl.length > 0 && isValidUrl(trimmedAutoLoginUrl));
|
||
|
||
useEffect(() => {
|
||
if (!hasLogoUrl) {
|
||
setLogoPreviewStatus("idle");
|
||
return;
|
||
}
|
||
if (!hasValidLogoUrl) {
|
||
setLogoPreviewStatus("error");
|
||
return;
|
||
}
|
||
setLogoPreviewStatus("loading");
|
||
}, [hasLogoUrl, hasValidLogoUrl]);
|
||
|
||
const handleSecurityProfileChange = (profile: SecurityProfile) => {
|
||
setClientType(profile);
|
||
if (profile === "pkce") {
|
||
setTokenEndpointAuthMethod(
|
||
headlessLoginEnabled ? "private_key_jwt" : "none",
|
||
);
|
||
} else {
|
||
setTokenEndpointAuthMethod("client_secret_basic");
|
||
}
|
||
};
|
||
|
||
const handleHeadlessToggle = (enabled: boolean) => {
|
||
setHeadlessLoginEnabled(enabled);
|
||
if (clientType === "pkce") {
|
||
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
|
||
}
|
||
};
|
||
|
||
const tenantScopeDescription = t(
|
||
"msg.dev.clients.scopes.tenant",
|
||
"소속 테넌트 정보 접근",
|
||
);
|
||
|
||
const buildTenantScope = (id: string): ScopeItem => ({
|
||
id,
|
||
name: "tenant",
|
||
description: tenantScopeDescription,
|
||
mandatory: true,
|
||
locked: true,
|
||
});
|
||
|
||
function normalizeScopesForTenantAccess(
|
||
nextScopes: ScopeItem[],
|
||
restricted: boolean,
|
||
): ScopeItem[] {
|
||
const normalized = nextScopes.map((scope) => {
|
||
if (scope.name.trim() !== "tenant") {
|
||
return scope;
|
||
}
|
||
return {
|
||
...scope,
|
||
description: scope.description || tenantScopeDescription,
|
||
mandatory: restricted,
|
||
locked: restricted,
|
||
};
|
||
});
|
||
|
||
if (
|
||
restricted &&
|
||
!normalized.some((scope) => scope.name.trim() === "tenant")
|
||
) {
|
||
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
|
||
}
|
||
|
||
const openidScopes = normalized.filter(
|
||
(scope) => scope.name.trim() === "openid",
|
||
);
|
||
const tenantScopes = normalized.filter(
|
||
(scope) => scope.name.trim() === "tenant",
|
||
);
|
||
const remainingScopes = normalized.filter((scope) => {
|
||
const name = scope.name.trim();
|
||
return name !== "openid" && name !== "tenant";
|
||
});
|
||
|
||
return [...openidScopes, ...tenantScopes, ...remainingScopes];
|
||
}
|
||
|
||
const handleTenantAccessToggle = (enabled: boolean) => {
|
||
setTenantAccessRestricted(enabled);
|
||
setIsTenantSearchOpen(enabled);
|
||
if (!enabled) {
|
||
setTenantSearch("");
|
||
}
|
||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||
};
|
||
|
||
const toggleAllowedTenant = (tenantId: string) => {
|
||
setAllowedTenantIds((current) =>
|
||
current.includes(tenantId)
|
||
? current.filter((id) => id !== tenantId)
|
||
: [...current, tenantId],
|
||
);
|
||
};
|
||
|
||
const handleSelectAllowedTenant = (tenantId: string) => {
|
||
setAllowedTenantIds((current) =>
|
||
current.includes(tenantId) ? current : [...current, tenantId],
|
||
);
|
||
setTenantSearch("");
|
||
setIsTenantSearchOpen(true);
|
||
};
|
||
|
||
const addScope = () => {
|
||
const newId = String(Date.now());
|
||
setScopes([
|
||
...scopes,
|
||
{ id: newId, name: "", description: "", mandatory: false },
|
||
]);
|
||
};
|
||
|
||
const updateScope = <K extends keyof ScopeItem>(
|
||
id: string,
|
||
field: K,
|
||
value: ScopeItem[K],
|
||
) => {
|
||
setScopes((current) =>
|
||
current.map((scope) => {
|
||
if (scope.id !== id) {
|
||
return scope;
|
||
}
|
||
if (scope.locked) {
|
||
return scope;
|
||
}
|
||
return { ...scope, [field]: value };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const removeScope = (id: string) => {
|
||
setScopes((current) =>
|
||
current.filter((scope) => scope.id !== id || scope.locked === true),
|
||
);
|
||
};
|
||
|
||
const addIdTokenClaim = () => {
|
||
setIdTokenClaims((current) => [
|
||
...current,
|
||
createIdTokenClaimItem(`claim-${Date.now()}`),
|
||
]);
|
||
};
|
||
|
||
const updateIdTokenClaim = <K extends keyof IdTokenClaimItem>(
|
||
id: string,
|
||
field: K,
|
||
value: IdTokenClaimItem[K],
|
||
) => {
|
||
setIdTokenClaims((current) =>
|
||
current.map((claim) => {
|
||
if (claim.id !== id) {
|
||
return claim;
|
||
}
|
||
return { ...claim, [field]: value };
|
||
}),
|
||
);
|
||
};
|
||
|
||
const removeIdTokenClaim = (id: string) => {
|
||
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
|
||
};
|
||
|
||
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||
setStatus(nextStatus);
|
||
const statusLabel =
|
||
nextStatus === "active"
|
||
? t("ui.common.status.active", "Active")
|
||
: t("ui.common.status.inactive", "Inactive");
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.status_changed",
|
||
"상태가 {{status}}로 변경되었습니다.",
|
||
{ status: statusLabel },
|
||
),
|
||
);
|
||
};
|
||
|
||
const validationErrors: string[] = [];
|
||
const trimmedJwksUri = jwksUri.trim();
|
||
const currentHeadlessJwksCache = data?.headlessJwksCache;
|
||
const parsedKeysForCurrentJwksUri =
|
||
headlessLoginEnabled &&
|
||
trimmedJwksUri !== "" &&
|
||
currentHeadlessJwksCache?.jwksUri === trimmedJwksUri
|
||
? (currentHeadlessJwksCache.parsedKeys ?? [])
|
||
: [];
|
||
const unsupportedParsedAlgorithms = parsedKeysForCurrentJwksUri
|
||
.map((key, index) => ({
|
||
alg: key.alg?.trim() ?? "",
|
||
label: formatHeadlessParsedKeyLabel(key.kid, index),
|
||
}))
|
||
.filter(
|
||
(entry) =>
|
||
entry.alg !== "" &&
|
||
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(entry.alg),
|
||
);
|
||
const missingParsedAlgorithms = parsedKeysForCurrentJwksUri
|
||
.map((key, index) => ({
|
||
alg: key.alg?.trim() ?? "",
|
||
label: formatHeadlessParsedKeyLabel(key.kid, index),
|
||
}))
|
||
.filter((entry) => entry.alg === "");
|
||
const unsupportedParsedAlgorithmSummary = unsupportedParsedAlgorithms
|
||
.map((entry) => `${entry.label}: ${entry.alg}`)
|
||
.join(", ");
|
||
const missingParsedAlgorithmSummary = missingParsedAlgorithms
|
||
.map((entry) => entry.label)
|
||
.join(", ");
|
||
const allowedHeadlessAlgorithmsTooltip = t(
|
||
"msg.dev.clients.general.public_key.allowed_algorithms_tooltip",
|
||
"허용 알고리즘: {{algorithms}}",
|
||
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
|
||
);
|
||
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({
|
||
...claim,
|
||
key: claim.key.trim(),
|
||
value: claim.value.trim(),
|
||
}));
|
||
|
||
if (headlessLoginEnabled) {
|
||
if (!trimmedJwksUri) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
|
||
"JWKS URI를 입력해야 합니다.",
|
||
),
|
||
);
|
||
} else if (!isValidUrl(trimmedJwksUri)) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
|
||
"JWKS URI 형식이 올바르지 않습니다.",
|
||
),
|
||
);
|
||
}
|
||
if (unsupportedParsedAlgorithms.length > 0) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
|
||
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
|
||
{ details: unsupportedParsedAlgorithmSummary },
|
||
),
|
||
);
|
||
}
|
||
if (missingParsedAlgorithms.length > 0) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.missing_parsed_algorithms",
|
||
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
|
||
{ details: missingParsedAlgorithmSummary },
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
|
||
validationErrors.push(
|
||
t(
|
||
"ui.dev.clients.general.tenant_access.validation_required",
|
||
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||
),
|
||
);
|
||
}
|
||
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.auto_login.invalid_url",
|
||
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||
),
|
||
);
|
||
}
|
||
|
||
const claimValidationErrors: string[] = [];
|
||
const seenClaimKeys = new Set<string>();
|
||
for (const claim of normalizedIdTokenClaims) {
|
||
if (!claim.key) {
|
||
claimValidationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.id_token_claims.key_required",
|
||
"Claim key를 입력해야 합니다.",
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
|
||
claimValidationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.id_token_claims.reserved_key",
|
||
"`rp_claims`는 예약된 namespace 키입니다.",
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const keySignature = `${claim.namespace}:${claim.key}`;
|
||
if (seenClaimKeys.has(keySignature)) {
|
||
claimValidationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.id_token_claims.duplicate_key",
|
||
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
|
||
{
|
||
namespace:
|
||
claim.namespace === "rp_claims"
|
||
? t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||
"rp_claims",
|
||
)
|
||
: t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||
"top-level",
|
||
),
|
||
key: claim.key,
|
||
},
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
seenClaimKeys.add(keySignature);
|
||
}
|
||
validationErrors.push(...claimValidationErrors);
|
||
|
||
const hasValidationErrors = validationErrors.length > 0;
|
||
const idTokenClaimPreview = buildIdTokenClaimsPreview(
|
||
normalizedIdTokenClaims,
|
||
);
|
||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
||
tenantData ?? [];
|
||
const filteredTenants = tenantOptions.filter((tenant) => {
|
||
if (!normalizedTenantSearch) {
|
||
return true;
|
||
}
|
||
const searchable =
|
||
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
|
||
return searchable.includes(normalizedTenantSearch);
|
||
});
|
||
const tenantSuggestions = filteredTenants
|
||
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
|
||
.slice(0, 8);
|
||
const selectedAllowedTenants = allowedTenantIds
|
||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||
.filter(
|
||
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
|
||
);
|
||
|
||
const refreshHeadlessJwksCacheMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!clientId) throw new Error("Missing client id");
|
||
return refreshHeadlessJwksCache(clientId);
|
||
},
|
||
onSuccess: (result) => {
|
||
if (clientId) {
|
||
queryClient.setQueryData(["client", clientId], result);
|
||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||
}
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache_refreshed",
|
||
"JWKS 캐시를 새로 고쳤습니다.",
|
||
),
|
||
);
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message ??
|
||
t("msg.common.unknown_error", "unknown error");
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache_refresh_failed",
|
||
"JWKS 캐시 새로고침에 실패했습니다: {{error}}",
|
||
{ error: errorMessage },
|
||
),
|
||
);
|
||
},
|
||
});
|
||
|
||
const revokeHeadlessJwksCacheMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!clientId) throw new Error("Missing client id");
|
||
return revokeHeadlessJwksCache(clientId);
|
||
},
|
||
onSuccess: () => {
|
||
if (clientId) {
|
||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||
}
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache_revoked",
|
||
"JWKS 캐시를 삭제했습니다.",
|
||
),
|
||
);
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message ??
|
||
t("msg.common.unknown_error", "unknown error");
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache_revoke_failed",
|
||
"JWKS 캐시 삭제에 실패했습니다: {{error}}",
|
||
{ error: errorMessage },
|
||
),
|
||
);
|
||
},
|
||
});
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (hasLogoUrl && !hasValidLogoUrl) {
|
||
throw new Error(
|
||
t(
|
||
"msg.dev.clients.general.identity.logo_invalid",
|
||
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||
),
|
||
);
|
||
}
|
||
if (hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri) {
|
||
throw new Error(
|
||
t(
|
||
"msg.dev.clients.general.backchannel_logout.invalid",
|
||
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
|
||
),
|
||
);
|
||
}
|
||
if (isGeneralSettingsReadOnly) {
|
||
throw new Error(
|
||
t(
|
||
"msg.dev.clients.general.read_only_forbidden",
|
||
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
|
||
),
|
||
);
|
||
}
|
||
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||
throw new Error(
|
||
t(
|
||
"msg.dev.clients.general.auto_login.invalid_url",
|
||
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||
),
|
||
);
|
||
}
|
||
|
||
const normalizedScopes = normalizeScopesForTenantAccess(
|
||
scopes,
|
||
tenantAccessRestricted,
|
||
);
|
||
const normalizedAllowedTenantIds = Array.from(
|
||
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
|
||
);
|
||
const scopeNames = normalizedScopes
|
||
.map((scope) => scope.name.trim())
|
||
.filter(Boolean);
|
||
|
||
const effectiveTokenEndpointAuthMethod =
|
||
clientType === "pkce" && headlessLoginEnabled
|
||
? "none"
|
||
: tokenEndpointAuthMethod;
|
||
|
||
const payload: ClientUpsertRequest = {
|
||
name,
|
||
type: clientType,
|
||
scopes: scopeNames,
|
||
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
|
||
jwksUri:
|
||
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
|
||
trimmedJwksUri
|
||
? trimmedJwksUri
|
||
: undefined,
|
||
backchannelLogoutUri: trimmedBackchannelLogoutUri || undefined,
|
||
backchannelLogoutSessionRequired:
|
||
trimmedBackchannelLogoutUri !== ""
|
||
? backchannelLogoutSessionRequired
|
||
: false,
|
||
metadata: {
|
||
description,
|
||
logo_url: trimmedLogoUrl,
|
||
auto_login_supported: autoLoginSupported,
|
||
auto_login_url: autoLoginSupported ? trimmedAutoLoginUrl : undefined,
|
||
structured_scopes: normalizedScopes,
|
||
id_token_claims: normalizedIdTokenClaims,
|
||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||
headless_login_enabled: headlessLoginEnabled,
|
||
headless_token_endpoint_auth_method:
|
||
clientType === "pkce" && headlessLoginEnabled
|
||
? tokenEndpointAuthMethod
|
||
: undefined,
|
||
headless_jwks_uri:
|
||
clientType === "pkce" && headlessLoginEnabled
|
||
? trimmedJwksUri
|
||
: undefined,
|
||
tenant_access_restricted: tenantAccessRestricted,
|
||
allowed_tenants: tenantAccessRestricted
|
||
? normalizedAllowedTenantIds
|
||
: [],
|
||
backchannel_logout_uri: trimmedBackchannelLogoutUri || undefined,
|
||
backchannel_logout_session_required:
|
||
trimmedBackchannelLogoutUri !== ""
|
||
? backchannelLogoutSessionRequired
|
||
: undefined,
|
||
},
|
||
};
|
||
|
||
if (isCreate) {
|
||
payload.status = status;
|
||
payload.redirectUris = redirectUris
|
||
.split(",")
|
||
.map((uri) => uri.trim())
|
||
.filter(Boolean);
|
||
return createClient(payload);
|
||
}
|
||
|
||
const updated = await updateClient(clientId as string, payload);
|
||
if (status !== initialStatus) {
|
||
await updateClientStatus(clientId as string, status);
|
||
}
|
||
return updated;
|
||
},
|
||
onSuccess: (result) => {
|
||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||
if (status !== initialStatus) {
|
||
setInitialStatus(status);
|
||
}
|
||
if (result?.client?.id) {
|
||
navigate(`/clients/${result.client.id}/settings`);
|
||
}
|
||
toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
||
},
|
||
onError: (err) => {
|
||
const axiosError = err as AxiosError<{ error?: string }>;
|
||
if (axiosError.response?.status === 403) {
|
||
alert(
|
||
isCreate
|
||
? t(
|
||
"msg.dev.clients.general.create_forbidden",
|
||
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
|
||
)
|
||
: t(
|
||
"msg.dev.clients.general.save_forbidden",
|
||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const errorMessage =
|
||
axiosError.response?.data?.error ??
|
||
(err as Error)?.message ??
|
||
t("msg.common.unknown_error", "unknown error");
|
||
toast(
|
||
t(
|
||
"msg.dev.clients.general.save_error",
|
||
"저장에 실패했습니다: {{error}}",
|
||
{
|
||
error: errorMessage,
|
||
},
|
||
),
|
||
"error",
|
||
);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteClient(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||
toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
|
||
navigate("/clients");
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message;
|
||
toast(
|
||
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
|
||
error: errorMessage,
|
||
}),
|
||
"error",
|
||
);
|
||
},
|
||
});
|
||
|
||
const handleDelete = () => {
|
||
if (
|
||
clientId &&
|
||
window.confirm(
|
||
t(
|
||
"msg.dev.clients.delete_confirm",
|
||
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
|
||
),
|
||
)
|
||
) {
|
||
deleteMutation.mutate(clientId);
|
||
}
|
||
};
|
||
|
||
if (!isCreate && isLoading) {
|
||
return (
|
||
<div className="p-8 text-center">
|
||
{t("msg.dev.clients.general.loading", "Loading client...")}
|
||
</div>
|
||
);
|
||
}
|
||
if (!isCreate && (error || !data)) {
|
||
const errMsg =
|
||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(error as Error)?.message;
|
||
return (
|
||
<div className="p-8 text-center text-red-500">
|
||
{t(
|
||
"msg.dev.clients.general.load_error",
|
||
"Error loading client: {{error}}",
|
||
{
|
||
error: errMsg || t("msg.common.unknown_error", "unknown error"),
|
||
},
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const publicKeyStatusTone = headlessLoginEnabled
|
||
? hasValidationErrors
|
||
? "border-destructive/40 bg-destructive/5"
|
||
: "border-primary/30 bg-primary/5"
|
||
: "border-border bg-muted/20";
|
||
|
||
const displayName = isCreate
|
||
? t("ui.dev.clients.general.display_new", "새 클라이언트")
|
||
: data?.client?.name || data?.client?.id;
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<header className="space-y-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div className="space-y-2">
|
||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||
<Link to="/" className="hover:text-primary">
|
||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||
</Link>
|
||
<span>/</span>
|
||
<Link to="/clients" className="hover:text-primary">
|
||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||
</Link>
|
||
<span>/</span>
|
||
<span>{displayName}</span>
|
||
{!isCreate && (
|
||
<>
|
||
<span>/</span>
|
||
<span className="text-foreground font-semibold">
|
||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||
</span>
|
||
</>
|
||
)}
|
||
</nav>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="ghost" size="icon" asChild>
|
||
<Link to={isCreate ? "/clients" : `/clients/${clientId}`}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Link>
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-3xl font-black leading-tight">
|
||
{isCreate
|
||
? t("ui.dev.clients.general.title_create", "Create Client")
|
||
: t("ui.dev.clients.general.title_edit", "Client Settings")}
|
||
</h1>
|
||
<p className="text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.subtitle",
|
||
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{!isCreate && (
|
||
<Badge
|
||
variant={status === "active" ? "info" : "muted"}
|
||
className="px-3 py-1 text-xs uppercase"
|
||
>
|
||
{status === "active"
|
||
? t("ui.common.status.active", "Active")
|
||
: t("ui.common.status.inactive", "Inactive")}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{!isCreate && (
|
||
<ClientDetailTabs activeTab="settings" clientId={clientId} />
|
||
)}
|
||
{isGeneralSettingsReadOnly && (
|
||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||
{t(
|
||
"msg.dev.clients.general.read_only_hint",
|
||
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
|
||
)}
|
||
</div>
|
||
)}
|
||
</header>
|
||
|
||
{/* 1. Application Identity */}
|
||
<div className="glass-panel p-6">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<CardTitle className="text-xl font-bold mb-2">
|
||
{t(
|
||
"ui.dev.clients.general.identity.title",
|
||
"Application Identity",
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.identity.subtitle",
|
||
"앱 이름과 설명, 로고를 설정합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-8 md:grid-cols-2">
|
||
<div className="space-y-5">
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t("ui.dev.clients.general.identity.name", "앱 이름")}{" "}
|
||
<span className="text-destructive">*</span>
|
||
</Label>
|
||
<Input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.identity.name_placeholder",
|
||
"My Awesome Application",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.identity.description",
|
||
"Description",
|
||
)}
|
||
</Label>
|
||
<Textarea
|
||
rows={3}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.identity.description_placeholder",
|
||
"앱에 대한 간단한 설명을 입력하세요.",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-5">
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t("ui.dev.clients.general.identity.logo", "App Logo URL")}
|
||
</Label>
|
||
<div className="flex gap-4">
|
||
<div className="flex-1 space-y-2">
|
||
<Input
|
||
value={logoUrl}
|
||
onChange={(e) => setLogoUrl(e.target.value)}
|
||
aria-invalid={!hasValidLogoUrl}
|
||
className={!hasValidLogoUrl ? "border-destructive" : ""}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.identity.logo_placeholder",
|
||
"https://example.com/logo.png",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.identity.logo_help",
|
||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||
)}
|
||
</p>
|
||
{!hasValidLogoUrl ? (
|
||
<p className="text-xs text-destructive">
|
||
{t(
|
||
"msg.dev.clients.general.identity.logo_invalid",
|
||
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||
)}
|
||
</p>
|
||
) : null}
|
||
{hasLogoUrl && hasValidLogoUrl ? (
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span
|
||
className={cn("text-muted-foreground", {
|
||
"text-foreground": logoPreviewStatus === "loaded",
|
||
"text-destructive": logoPreviewStatus === "error",
|
||
})}
|
||
>
|
||
{logoPreviewStatus === "loading"
|
||
? t(
|
||
"msg.dev.clients.general.identity.logo_preview_loading",
|
||
"로고 미리보기를 불러오는 중입니다.",
|
||
)
|
||
: logoPreviewStatus === "loaded"
|
||
? t(
|
||
"msg.dev.clients.general.identity.logo_preview_ready",
|
||
"로고 미리보기를 확인했습니다.",
|
||
)
|
||
: logoPreviewStatus === "error"
|
||
? t(
|
||
"msg.dev.clients.general.identity.logo_preview_failed",
|
||
"로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.",
|
||
)
|
||
: null}
|
||
</span>
|
||
<a
|
||
href={trimmedLogoUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
{t(
|
||
"ui.dev.clients.general.identity.logo_open",
|
||
"새 탭에서 열기",
|
||
)}
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border-2 border-dashed",
|
||
hasLogoUrl &&
|
||
hasValidLogoUrl &&
|
||
logoPreviewStatus !== "error"
|
||
? "bg-white"
|
||
: "bg-muted/40",
|
||
logoPreviewStatus === "error"
|
||
? "border-destructive/60"
|
||
: "border-border",
|
||
)}
|
||
>
|
||
{hasLogoUrl && hasValidLogoUrl ? (
|
||
<img
|
||
key={trimmedLogoUrl}
|
||
src={trimmedLogoUrl}
|
||
alt={t(
|
||
"ui.dev.clients.general.identity.logo_preview",
|
||
"Logo Preview",
|
||
)}
|
||
className="h-full w-full object-contain"
|
||
onLoad={() => setLogoPreviewStatus("loaded")}
|
||
onError={() => setLogoPreviewStatus("error")}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center gap-1 px-2 text-center">
|
||
<Upload
|
||
className={cn("h-5 w-5 text-muted-foreground", {
|
||
"text-destructive": logoPreviewStatus === "error",
|
||
})}
|
||
/>
|
||
{logoPreviewStatus === "error" ? (
|
||
<span className="text-[10px] leading-tight text-destructive">
|
||
{t(
|
||
"ui.dev.clients.general.identity.logo_preview_error_badge",
|
||
"미리보기 실패",
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span className="text-[10px] leading-tight text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.identity.logo_preview_empty",
|
||
"미리보기",
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t("ui.dev.clients.table.status", "상태")}
|
||
</Label>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={status === "active" ? "default" : "outline"}
|
||
onClick={() => handleStatusChange("active")}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
{t("ui.common.status.active", "활성")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={status === "inactive" ? "default" : "outline"}
|
||
onClick={() => handleStatusChange("inactive")}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
{t("ui.common.status.inactive", "비활성")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="glass-panel">
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-xl font-bold">
|
||
{t("ui.dev.clients.general.auto_login.title", "자동 로그인")}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.auto_login.subtitle",
|
||
"RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||
<div className="space-y-0.5 text-right">
|
||
<p className="text-sm font-semibold">
|
||
{autoLoginSupported
|
||
? t("ui.common.enabled", "사용")
|
||
: t("ui.common.disabled", "사용 안 함")}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.auto_login.supported",
|
||
"자동 로그인 지원",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={autoLoginSupported}
|
||
onCheckedChange={setAutoLoginSupported}
|
||
id="auto-login-supported"
|
||
aria-label={t(
|
||
"ui.dev.clients.general.auto_login.supported",
|
||
"자동 로그인 지원",
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="auto-login-url" className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.auto_login.url",
|
||
"자동 로그인 시작 URL",
|
||
)}
|
||
</Label>
|
||
<Input
|
||
id="auto-login-url"
|
||
value={autoLoginUrl}
|
||
onChange={(event) => setAutoLoginUrl(event.target.value)}
|
||
disabled={!autoLoginSupported}
|
||
aria-invalid={!hasValidAutoLoginUrl}
|
||
className={!hasValidAutoLoginUrl ? "border-destructive" : ""}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.auto_login.url_placeholder",
|
||
"https://app.example.com/login?auto=1",
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.auto_login.help",
|
||
"이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다.",
|
||
)}
|
||
</p>
|
||
{!hasValidAutoLoginUrl ? (
|
||
<p className="text-xs text-destructive">
|
||
{t(
|
||
"msg.dev.clients.general.auto_login.invalid_url",
|
||
"자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||
)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 2. Scopes */}
|
||
<Card className="glass-panel">
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||
<div>
|
||
<CardTitle className="text-xl font-bold">
|
||
{t("ui.dev.clients.general.scopes.title", "Scopes")}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.subtitle",
|
||
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addScope}
|
||
className="gap-2"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{isCreate && (
|
||
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
||
<Label className="text-sm font-semibold">
|
||
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
|
||
<span className="text-destructive">*</span>
|
||
</Label>
|
||
<Textarea
|
||
value={redirectUris}
|
||
onChange={(e) => setRedirectUris(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.redirect.placeholder",
|
||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||
)}
|
||
className="font-mono text-sm"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.redirect.help",
|
||
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4 border-b border-border pb-6 mb-6">
|
||
<div className="space-y-2">
|
||
<Label
|
||
className="text-sm font-semibold"
|
||
htmlFor="backchannel-logout-uri"
|
||
>
|
||
{t(
|
||
"ui.dev.clients.general.backchannel_logout.uri",
|
||
"Back-Channel Logout URI",
|
||
)}
|
||
</Label>
|
||
<Input
|
||
id="backchannel-logout-uri"
|
||
value={backchannelLogoutUri}
|
||
onChange={(e) => setBackchannelLogoutUri(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.backchannel_logout.uri_placeholder",
|
||
"https://rp.example.com/oidc/backchannel-logout",
|
||
)}
|
||
className="font-mono text-sm"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.backchannel_logout.uri_help",
|
||
"Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다.",
|
||
)}
|
||
</p>
|
||
{hasBackchannelLogoutUri && !hasValidBackchannelLogoutUri ? (
|
||
<p className="text-xs text-destructive">
|
||
{t(
|
||
"msg.dev.clients.general.backchannel_logout.invalid",
|
||
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
|
||
)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<Label
|
||
className="text-sm font-semibold"
|
||
htmlFor="backchannel-logout-session-required"
|
||
>
|
||
{t(
|
||
"ui.dev.clients.general.backchannel_logout.session_required",
|
||
"SID Claim Required",
|
||
)}
|
||
</Label>
|
||
<button
|
||
type="button"
|
||
className={`rounded-full p-0.5 transition-colors ${
|
||
isBackchannelSessionRequiredInfoOpen
|
||
? "text-primary"
|
||
: "text-muted-foreground/60 hover:text-primary"
|
||
}`}
|
||
onClick={() =>
|
||
setIsBackchannelSessionRequiredInfoOpen((prev) => !prev)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.backchannel_logout.session_required_info",
|
||
"SID Claim Required 설명 보기",
|
||
)}
|
||
>
|
||
{isBackchannelSessionRequiredInfoOpen ? (
|
||
<X className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Info className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.backchannel_logout.session_required_help",
|
||
"RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다.",
|
||
)}
|
||
</p>
|
||
{isBackchannelSessionRequiredInfoOpen ? (
|
||
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||
<Info className="h-3 w-3" />
|
||
{t("ui.common.info", "상세 안내")}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"msg.dev.clients.general.backchannel_logout.session_required_on",
|
||
"켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리",
|
||
)}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"msg.dev.clients.general.backchannel_logout.session_required_off",
|
||
"끄면: sid가 없어도 sub만으로 로그아웃 처리 가능",
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<Switch
|
||
id="backchannel-logout-session-required"
|
||
checked={backchannelLogoutSessionRequired}
|
||
onCheckedChange={setBackchannelLogoutSessionRequired}
|
||
disabled={isGeneralSettingsReadOnly || !hasBackchannelLogoutUri}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.name",
|
||
"Scope Name",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.description",
|
||
"Description",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-center font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.mandatory",
|
||
"Mandatory",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-right font-bold">
|
||
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{scopes.map((s) => (
|
||
<tr
|
||
key={s.id}
|
||
className={cn(
|
||
"transition-colors",
|
||
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
|
||
)}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<Input
|
||
value={s.name}
|
||
onChange={(e) =>
|
||
updateScope(s.id, "name", e.target.value)
|
||
}
|
||
className="h-8 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.scopes.name_placeholder",
|
||
"e.g. profile",
|
||
)}
|
||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Input
|
||
value={s.description}
|
||
onChange={(e) =>
|
||
updateScope(s.id, "description", e.target.value)
|
||
}
|
||
className="h-8 text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.scopes.description_placeholder",
|
||
"권한에 대한 설명",
|
||
)}
|
||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<div className="flex justify-center">
|
||
<Switch
|
||
checked={s.mandatory}
|
||
onCheckedChange={(checked) =>
|
||
updateScope(s.id, "mandatory", checked)
|
||
}
|
||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeScope(s.id)}
|
||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{scopes.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={4}
|
||
className="px-4 py-8 text-center text-muted-foreground"
|
||
>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.empty",
|
||
"등록된 스코프가 없습니다.",
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="glass-panel">
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-xl font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.title",
|
||
"테넌트 접근 제한",
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.subtitle",
|
||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||
<div className="space-y-0.5 text-right">
|
||
<p className="text-sm font-semibold">
|
||
{tenantAccessRestricted
|
||
? t(
|
||
"ui.dev.clients.general.tenant_access.enabled",
|
||
"제한 있음",
|
||
)
|
||
: t(
|
||
"ui.dev.clients.general.tenant_access.disabled",
|
||
"제한 없음",
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.title",
|
||
"테넌트 접근 제한",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={tenantAccessRestricted}
|
||
onCheckedChange={handleTenantAccessToggle}
|
||
id="tenant-access-toggle"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.hint",
|
||
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||
)}
|
||
</p>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||
<div className="space-y-3">
|
||
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||
"테넌트 이름 또는 슬러그로 검색",
|
||
)}
|
||
</Label>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="tenant-search"
|
||
value={tenantSearch}
|
||
onFocus={() => {
|
||
if (tenantAccessRestricted) {
|
||
setIsTenantSearchOpen(true);
|
||
}
|
||
}}
|
||
onBlur={() => {
|
||
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
|
||
}}
|
||
onChange={(e) => {
|
||
setTenantSearch(e.target.value);
|
||
if (tenantAccessRestricted) {
|
||
setIsTenantSearchOpen(true);
|
||
}
|
||
}}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||
"테넌트 이름 또는 슬러그로 검색",
|
||
)}
|
||
className="pl-10"
|
||
disabled={
|
||
isGeneralSettingsReadOnly || !tenantAccessRestricted
|
||
}
|
||
/>
|
||
{tenantAccessRestricted && isTenantSearchOpen && (
|
||
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
|
||
{tenantSuggestions.length > 0 ? (
|
||
tenantSuggestions.map((tenant) => (
|
||
<button
|
||
key={tenant.id}
|
||
type="button"
|
||
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
handleSelectAllowedTenant(tenant.id);
|
||
}}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<div className="min-w-0 space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium">
|
||
{tenant.name}
|
||
</span>
|
||
<Badge variant="outline" className="text-[11px]">
|
||
{tenant.slug}
|
||
</Badge>
|
||
</div>
|
||
<p className="truncate text-xs text-muted-foreground">
|
||
{tenant.description || tenant.type}
|
||
</p>
|
||
</div>
|
||
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.empty",
|
||
"검색 결과가 없습니다.",
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||
{tenantAccessRestricted
|
||
? t(
|
||
"ui.dev.clients.general.tenant_access.autocomplete_hint",
|
||
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
|
||
)
|
||
: t(
|
||
"ui.dev.clients.general.tenant_access.disabled",
|
||
"제한 없음",
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.selected_title",
|
||
"허용 테넌트",
|
||
)}
|
||
</Label>
|
||
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedAllowedTenants.map((tenant) => (
|
||
<Badge
|
||
key={tenant.id}
|
||
variant="secondary"
|
||
className="gap-2 px-3 py-1.5"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{tenant.slug}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t("ui.common.delete", "삭제")}
|
||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||
className="text-muted-foreground transition hover:text-destructive"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
{allowedTenantIds
|
||
.filter(
|
||
(tenantId) =>
|
||
!selectedAllowedTenants.some(
|
||
(tenant) => tenant.id === tenantId,
|
||
),
|
||
)
|
||
.map((tenantId) => (
|
||
<Badge
|
||
key={tenantId}
|
||
variant="secondary"
|
||
className="gap-2 px-3 py-1.5"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span className="max-w-44 truncate">{tenantId}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t("ui.common.delete", "삭제")}
|
||
onClick={() => toggleAllowedTenant(tenantId)}
|
||
className="text-muted-foreground transition hover:text-destructive"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||
{tenantAccessRestricted
|
||
? t(
|
||
"ui.dev.clients.general.tenant_access.selected_empty",
|
||
"아직 선택된 테넌트가 없습니다.",
|
||
)
|
||
: t(
|
||
"ui.dev.clients.general.tenant_access.disabled",
|
||
"제한 없음",
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="glass-panel">
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-xl font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.title",
|
||
"Custom Claims",
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.subtitle",
|
||
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
onClick={addIdTokenClaim}
|
||
className="gap-2"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
|
||
<div className="space-y-3">
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.key",
|
||
"Claim Key",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.namespace",
|
||
"Namespace",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.value_type",
|
||
"Value Type",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.value",
|
||
"Value",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-right font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.delete",
|
||
"Delete",
|
||
)}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{idTokenClaims.map((claim) => (
|
||
<tr key={claim.id} className="hover:bg-muted/20">
|
||
<td className="px-4 py-3 align-top">
|
||
<Input
|
||
value={claim.key}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"key",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="h-9 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.id_token_claims.key_placeholder",
|
||
"e.g. locale",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 align-top">
|
||
<select
|
||
value={claim.namespace}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"namespace",
|
||
e.target.value as ClaimNamespace,
|
||
)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_label",
|
||
"Claim namespace",
|
||
)}
|
||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<option value="top_level">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||
"top-level",
|
||
)}
|
||
</option>
|
||
<option value="rp_claims">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||
"rp_claims",
|
||
)}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td className="px-4 py-3 align-top">
|
||
<select
|
||
value={claim.valueType}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"valueType",
|
||
e.target.value as ClaimValueType,
|
||
)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_label",
|
||
"Claim value type",
|
||
)}
|
||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<option value="text">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_text",
|
||
"Text",
|
||
)}
|
||
</option>
|
||
<option value="number">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_number",
|
||
"Number",
|
||
)}
|
||
</option>
|
||
<option value="boolean">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_boolean",
|
||
"Boolean",
|
||
)}
|
||
</option>
|
||
<option value="array">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_array",
|
||
"Array",
|
||
)}
|
||
</option>
|
||
<option value="object">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_object",
|
||
"Object",
|
||
)}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td className="px-4 py-3 align-top">
|
||
<Input
|
||
value={claim.value}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"value",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="h-9 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.id_token_claims.value_placeholder",
|
||
"Enter the claim value",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 text-right align-top">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeIdTokenClaim(claim.id)}
|
||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{idTokenClaims.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={5}
|
||
className="px-4 py-8 text-center text-muted-foreground"
|
||
>
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.empty",
|
||
"아직 추가된 ID Token claim이 없습니다.",
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="text-xs leading-6 text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.hint",
|
||
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="rounded-xl border border-border bg-muted/20 p-4">
|
||
<div className="flex items-center gap-2">
|
||
<Info className="h-4 w-4 text-primary" />
|
||
<div>
|
||
<p className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.preview_title",
|
||
"Saved JSON Preview",
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.preview_hint",
|
||
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Textarea
|
||
readOnly
|
||
value={idTokenClaimPreviewJson}
|
||
className="mt-4 min-h-72 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 3. Security Settings */}
|
||
<Card className="glass-panel">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-xl font-bold">
|
||
{t("ui.dev.clients.general.security.title", "보안 설정")}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.security.subtitle",
|
||
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<label
|
||
className={cn(
|
||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||
securityProfile === "private"
|
||
? "border-primary bg-primary/5"
|
||
: "border-border bg-card hover:border-muted-foreground/40",
|
||
)}
|
||
>
|
||
<input
|
||
className="sr-only"
|
||
type="radio"
|
||
name="security-profile"
|
||
checked={securityProfile === "private"}
|
||
onChange={() => handleSecurityProfileChange("private")}
|
||
/>
|
||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||
<Shield className="h-4 w-4 text-primary" />
|
||
{t(
|
||
"ui.dev.clients.general.security.private",
|
||
"Server side App",
|
||
)}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.security.private_help",
|
||
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
||
)}
|
||
</span>
|
||
<span className="absolute right-4 top-4 text-primary">
|
||
{securityProfile === "private" ? "✓" : ""}
|
||
</span>
|
||
</label>
|
||
|
||
<label
|
||
className={cn(
|
||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||
securityProfile === "pkce"
|
||
? "border-primary bg-primary/5"
|
||
: "border-border bg-card hover:border-muted-foreground/40",
|
||
)}
|
||
>
|
||
<input
|
||
className="sr-only"
|
||
type="radio"
|
||
name="security-profile"
|
||
checked={securityProfile === "pkce"}
|
||
onChange={() => handleSecurityProfileChange("pkce")}
|
||
/>
|
||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||
<Sparkles className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.security.pkce", "PKCE")}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.security.pkce_help",
|
||
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
||
)}
|
||
</span>
|
||
<span className="absolute right-4 top-4 text-primary">
|
||
{securityProfile === "pkce" ? "✓" : ""}
|
||
</span>
|
||
|
||
{securityProfile === "pkce" && (
|
||
<div
|
||
className="mt-4 pt-4 border-t border-primary/20 flex items-center justify-between"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="space-y-0.5">
|
||
<Label
|
||
className="text-xs font-bold cursor-pointer"
|
||
htmlFor="headless-login-toggle"
|
||
>
|
||
{t(
|
||
"ui.dev.clients.general.security.headless_login_enable",
|
||
"Headless Login (자체 로그인 UI 사용)",
|
||
)}
|
||
</Label>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.security.headless_login_enable_help",
|
||
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="headless-login-toggle"
|
||
checked={headlessLoginEnabled}
|
||
onCheckedChange={handleHeadlessToggle}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 4. Public Key Registration (Headless Login) */}
|
||
{clientType === "pkce" && headlessLoginEnabled && (
|
||
<Card className="glass-panel border-primary/20">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.title",
|
||
"Public Key Registration",
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.public_key.subtitle",
|
||
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label className="text-sm font-bold text-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.headless_toggle",
|
||
"Headless Login 허용 여부",
|
||
)}
|
||
</Label>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.headless_help",
|
||
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Badge
|
||
variant="default"
|
||
className="bg-primary/20 text-primary border-primary/30"
|
||
>
|
||
{t("ui.common.enabled", "Enabled")}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.jwks_uri",
|
||
"JWKS URI",
|
||
)}
|
||
<span className="text-destructive ml-1">*</span>
|
||
</Label>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-muted-foreground"
|
||
title={allowedHeadlessAlgorithmsTooltip}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.public_key.allowed_algorithms_info",
|
||
"Headless Login 허용 알고리즘 정보",
|
||
)}
|
||
>
|
||
<Info className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<Input
|
||
id="jwks-uri"
|
||
value={jwksUri}
|
||
onChange={(e) => setJwksUri(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.public_key.jwks_uri_placeholder",
|
||
"https://rp.example.com/.well-known/jwks.json",
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.jwks_uri_help",
|
||
"RP backend가 제공하는 공개키 endpoint URL을 입력하세요.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<Label className="text-sm font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.title",
|
||
"JWKS Cache",
|
||
)}
|
||
</Label>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache_help",
|
||
"백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => refreshHeadlessJwksCacheMutation.mutate()}
|
||
disabled={refreshHeadlessJwksCacheMutation.isPending}
|
||
>
|
||
{refreshHeadlessJwksCacheMutation.isPending
|
||
? t("msg.common.requesting", "요청 중...")
|
||
: t("ui.common.refresh", "Refresh")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => {
|
||
if (
|
||
!currentHeadlessJwksCache ||
|
||
revokeHeadlessJwksCacheMutation.isPending
|
||
) {
|
||
return;
|
||
}
|
||
const confirmed = window.confirm(
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache_revoke_confirm",
|
||
"JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?",
|
||
),
|
||
);
|
||
if (confirmed) {
|
||
revokeHeadlessJwksCacheMutation.mutate();
|
||
}
|
||
}}
|
||
disabled={
|
||
!currentHeadlessJwksCache ||
|
||
revokeHeadlessJwksCacheMutation.isPending
|
||
}
|
||
>
|
||
{revokeHeadlessJwksCacheMutation.isPending
|
||
? t("msg.common.requesting", "요청 중...")
|
||
: t(
|
||
"ui.dev.clients.general.public_key.revoke_cache",
|
||
"Revoke Cache",
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{currentHeadlessJwksCache ? (
|
||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.status",
|
||
"Status",
|
||
)}
|
||
</p>
|
||
<Badge variant="info" className="w-fit capitalize">
|
||
{currentHeadlessJwksCache.lastRefreshStatus ||
|
||
t("ui.common.unknown", "Unknown")}
|
||
</Badge>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.uri",
|
||
"JWKS URI",
|
||
)}
|
||
</p>
|
||
<p className="break-all font-mono text-xs">
|
||
{currentHeadlessJwksCache.jwksUri}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.cached_at",
|
||
"Cached At",
|
||
)}
|
||
</p>
|
||
<p>{formatDateTime(currentHeadlessJwksCache.cachedAt)}</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.expires_at",
|
||
"Expires At",
|
||
)}
|
||
</p>
|
||
<p>
|
||
{formatDateTime(currentHeadlessJwksCache.expiresAt)}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.last_checked_at",
|
||
"Last Checked",
|
||
)}
|
||
</p>
|
||
<p>
|
||
{formatDateTime(currentHeadlessJwksCache.lastCheckedAt)}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.last_success",
|
||
"Last Successful Verification",
|
||
)}
|
||
</p>
|
||
<p>
|
||
{formatDateTime(
|
||
currentHeadlessJwksCache.lastSuccessfulVerificationAt,
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.failures",
|
||
"Consecutive Failures",
|
||
)}
|
||
</p>
|
||
<p>{currentHeadlessJwksCache.consecutiveFailures ?? 0}</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.kids",
|
||
"Cached KIDs",
|
||
)}
|
||
</p>
|
||
<p className="font-mono text-xs">
|
||
{currentHeadlessJwksCache.cachedKids?.length
|
||
? currentHeadlessJwksCache.cachedKids.join(", ")
|
||
: "-"}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1 md:col-span-2">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.error",
|
||
"Last Error",
|
||
)}
|
||
</p>
|
||
<p className="break-words text-xs text-muted-foreground">
|
||
{currentHeadlessJwksCache.lastError || "-"}
|
||
</p>
|
||
</div>
|
||
{(unsupportedParsedAlgorithms.length > 0 ||
|
||
missingParsedAlgorithms.length > 0) && (
|
||
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 md:col-span-2">
|
||
<p className="text-sm font-semibold text-destructive">
|
||
{unsupportedParsedAlgorithms.length > 0
|
||
? t(
|
||
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_title",
|
||
"지원하지 않는 알고리즘이 감지되었습니다.",
|
||
)
|
||
: t(
|
||
"msg.dev.clients.general.public_key.cache.missing_algorithms_title",
|
||
"알고리즘이 선언되지 않았습니다.",
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-destructive">
|
||
{unsupportedParsedAlgorithms.length > 0
|
||
? t(
|
||
"msg.dev.clients.general.public_key.cache.unsupported_algorithms_help",
|
||
"저장 전 JWKS를 수정해 주세요: {{details}}",
|
||
{ details: unsupportedParsedAlgorithmSummary },
|
||
)
|
||
: t(
|
||
"msg.dev.clients.general.public_key.cache.missing_algorithms_help",
|
||
"저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}",
|
||
{ details: missingParsedAlgorithmSummary },
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="space-y-3 md:col-span-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.parsed_keys",
|
||
"Parsed Keys",
|
||
)}
|
||
</p>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache.parsed_keys_help",
|
||
"Raw JWKS stays hidden. Only parsed key metadata is shown here.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
{currentHeadlessJwksCache.parsedKeys?.length ? (
|
||
<div className="space-y-3">
|
||
{currentHeadlessJwksCache.parsedKeys.map(
|
||
(key, index) => {
|
||
const normalizedAlgorithm = key.alg?.trim() ?? "";
|
||
const isMissingAlgorithm =
|
||
normalizedAlgorithm === "";
|
||
const isUnsupportedAlgorithm =
|
||
!isMissingAlgorithm &&
|
||
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
|
||
normalizedAlgorithm,
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={`${key.kid || "key"}-${index}`}
|
||
className={cn(
|
||
"rounded-xl border bg-muted/30 p-3",
|
||
isUnsupportedAlgorithm || isMissingAlgorithm
|
||
? "border-destructive/50 bg-destructive/5"
|
||
: "border-border",
|
||
)}
|
||
>
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||
KID
|
||
</p>
|
||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
||
{key.kid || "-"}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||
KTY
|
||
</p>
|
||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
||
{key.kty || "-"}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||
USE
|
||
</p>
|
||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
||
{key.use || "-"}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||
ALG
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
"break-all rounded-lg border bg-background px-3 py-2 font-mono text-[11px]",
|
||
isUnsupportedAlgorithm ||
|
||
isMissingAlgorithm
|
||
? "border-destructive/50 text-destructive"
|
||
: "border-border",
|
||
)}
|
||
>
|
||
{key.alg ||
|
||
t(
|
||
"msg.dev.clients.general.public_key.cache.missing_algorithm_badge",
|
||
"알고리즘 미선언",
|
||
)}
|
||
</p>
|
||
{isMissingAlgorithm && (
|
||
<p className="text-[11px] text-destructive">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache.missing_algorithm_reason",
|
||
"이 키는 `alg`가 비어 있어서 저장할 수 없습니다.",
|
||
)}
|
||
</p>
|
||
)}
|
||
{isUnsupportedAlgorithm && (
|
||
<p className="text-[11px] text-destructive">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason",
|
||
"이 알고리즘은 Headless Login에서 지원되지 않습니다.",
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 space-y-1">
|
||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.cache.parsed_key_n",
|
||
"N",
|
||
)}
|
||
</p>
|
||
<p className="min-h-16 break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px] leading-5">
|
||
{key.n || "-"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache.parsed_keys_empty",
|
||
"No parsed JWKS keys are available yet.",
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.cache_empty",
|
||
"아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.",
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{hasValidationErrors && (
|
||
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4 animate-in fade-in">
|
||
<p className="text-sm font-semibold text-destructive flex items-center gap-2">
|
||
<span>⚠️</span>
|
||
{t(
|
||
"ui.dev.clients.general.public_key.validation_title",
|
||
"저장 전 확인 필요",
|
||
)}
|
||
</p>
|
||
<ul className="mt-2 list-disc space-y-1 pl-6 text-sm text-destructive">
|
||
{validationErrors.map((errorMessage) => (
|
||
<li key={errorMessage}>{errorMessage}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||
<div>
|
||
{!isCreate && (
|
||
<Button
|
||
variant="destructive"
|
||
className="gap-2"
|
||
onClick={handleDelete}
|
||
disabled={deleteMutation.isPending}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
{deleteMutation.isPending
|
||
? t("msg.common.requesting", "요청 중...")
|
||
: t("ui.common.delete", "삭제")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||
<Button variant="outline" onClick={() => navigate("/clients")}>
|
||
{t("ui.common.cancel", "취소")}
|
||
</Button>
|
||
<Button
|
||
onClick={() => mutation.mutate()}
|
||
disabled={
|
||
isGeneralSettingsReadOnly ||
|
||
mutation.isPending ||
|
||
isLoading ||
|
||
name.trim() === "" ||
|
||
(isCreate && redirectUris.trim() === "") ||
|
||
hasValidationErrors
|
||
}
|
||
className="shadow-lg shadow-primary/20"
|
||
>
|
||
{mutation.isPending ? (
|
||
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||
) : (
|
||
<Save size={16} className="mr-2" />
|
||
)}
|
||
{mutation.isPending
|
||
? t("msg.common.saving", "저장 중...")
|
||
: isCreate
|
||
? t("ui.dev.clients.general.create", "클라이언트 생성")
|
||
: t("ui.common.save", "저장")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ClientGeneralPage;
|