forked from baron/baron-sso
3688 lines
144 KiB
TypeScript
3688 lines
144 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { AxiosError } from "axios";
|
||
import {
|
||
ExternalLink,
|
||
Info,
|
||
Plus,
|
||
Save,
|
||
Shield,
|
||
ShieldHalf,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useAuth } from "react-oidc-context";
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { PageHeader } from "../../../../common/core/components/page";
|
||
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "../../components/ui/card";
|
||
import { CopyButton } from "../../components/ui/copy-button";
|
||
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 {
|
||
ClientStatus,
|
||
ClientType,
|
||
ClientUpsertRequest,
|
||
TenantSummary,
|
||
} from "../../lib/devApi";
|
||
import {
|
||
type ClientRelation,
|
||
createClient,
|
||
deleteClient,
|
||
fetchClient,
|
||
fetchClientRelations,
|
||
fetchTenants,
|
||
refreshHeadlessJwksCache,
|
||
revokeHeadlessJwksCache,
|
||
updateClient,
|
||
updateClientStatus,
|
||
} from "../../lib/devApi";
|
||
import { t } from "../../lib/i18n";
|
||
import { resolveProfileRole } from "../../lib/role";
|
||
import { cn } from "../../lib/utils";
|
||
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||
import {
|
||
SettingsTable,
|
||
SettingsTableBody,
|
||
SettingsTableCell,
|
||
SettingsTableEmptyState,
|
||
SettingsTableHead,
|
||
SettingsTableHeader,
|
||
SettingsTableRow,
|
||
SettingsTableShell,
|
||
} from "./components/SettingsTable";
|
||
import { TenantAccessPicker } from "./components/TenantAccessPicker";
|
||
import {
|
||
claimDateTimeValueToInputString,
|
||
dateTimeInputToUnixSeconds,
|
||
getBrowserTimeZone,
|
||
getSupportedTimeZones,
|
||
} from "./rpClaimDateTime";
|
||
|
||
interface ScopeItem {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
mandatory: boolean;
|
||
locked?: boolean;
|
||
}
|
||
|
||
interface ScopeCandidate {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
source: "standard" | "custom_claim" | "manual";
|
||
}
|
||
|
||
type ClaimNamespace = "rp_claims";
|
||
type ClaimValueType =
|
||
| "text"
|
||
| "number"
|
||
| "float"
|
||
| "boolean"
|
||
| "array"
|
||
| "object"
|
||
| "date"
|
||
| "datetime";
|
||
type CustomClaimPermission = "admin_only" | "user_and_admin";
|
||
|
||
interface IdTokenClaimItem {
|
||
id: string;
|
||
namespace: ClaimNamespace;
|
||
key: string;
|
||
value: string;
|
||
timeZone: string;
|
||
valueType: ClaimValueType;
|
||
nullable: boolean;
|
||
readPermission: CustomClaimPermission;
|
||
writePermission: CustomClaimPermission;
|
||
}
|
||
|
||
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 === "rp_claims";
|
||
}
|
||
|
||
function isClaimValueType(value: string): value is ClaimValueType {
|
||
return (
|
||
value === "text" ||
|
||
value === "number" ||
|
||
value === "float" ||
|
||
value === "boolean" ||
|
||
value === "array" ||
|
||
value === "object" ||
|
||
value === "date" ||
|
||
value === "datetime"
|
||
);
|
||
}
|
||
|
||
function isCustomClaimPermission(
|
||
value: unknown,
|
||
): value is CustomClaimPermission {
|
||
return value === "admin_only" || value === "user_and_admin";
|
||
}
|
||
|
||
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||
return {
|
||
id,
|
||
namespace: "rp_claims",
|
||
key: "",
|
||
value: "",
|
||
timeZone: getBrowserTimeZone(),
|
||
valueType: "text",
|
||
nullable: false,
|
||
readPermission: "admin_only",
|
||
writePermission: "admin_only",
|
||
};
|
||
}
|
||
|
||
function normalizeIdTokenClaimPermissions(
|
||
claim: IdTokenClaimItem,
|
||
): IdTokenClaimItem {
|
||
if (claim.writePermission !== "user_and_admin") {
|
||
return claim;
|
||
}
|
||
return {
|
||
...claim,
|
||
readPermission: "user_and_admin",
|
||
};
|
||
}
|
||
|
||
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
|
||
: null;
|
||
if (namespaceValue === null) {
|
||
return null;
|
||
}
|
||
const keyValue = typeof record.key === "string" ? record.key : "";
|
||
const rawValue = record.value;
|
||
const valueTypeValue =
|
||
typeof record.valueType === "string" &&
|
||
isClaimValueType(record.valueType)
|
||
? record.valueType
|
||
: "text";
|
||
const timeZoneValue = getBrowserTimeZone();
|
||
const valueValue =
|
||
valueTypeValue === "date" || valueTypeValue === "datetime"
|
||
? claimDateTimeValueToInputString(
|
||
rawValue,
|
||
"",
|
||
valueTypeValue,
|
||
timeZoneValue,
|
||
)
|
||
: typeof rawValue === "string"
|
||
? rawValue
|
||
: rawValue == null
|
||
? ""
|
||
: JSON.stringify(rawValue);
|
||
|
||
return normalizeIdTokenClaimPermissions({
|
||
id: `claim-${index + 1}`,
|
||
namespace: namespaceValue,
|
||
key: keyValue,
|
||
value: valueValue,
|
||
timeZone: timeZoneValue,
|
||
valueType: valueTypeValue,
|
||
nullable: record.nullable === true,
|
||
readPermission: isCustomClaimPermission(record.readPermission)
|
||
? record.readPermission
|
||
: "admin_only",
|
||
writePermission: isCustomClaimPermission(record.writePermission)
|
||
? record.writePermission
|
||
: "admin_only",
|
||
});
|
||
})
|
||
.filter((item): item is IdTokenClaimItem => item !== null);
|
||
}
|
||
|
||
function normalizeClaimPreviewValue(
|
||
value: string,
|
||
valueType: ClaimValueType,
|
||
nullable: boolean,
|
||
timeZone: string,
|
||
): unknown {
|
||
const trimmed = value.trim();
|
||
if (nullable && trimmed === "") {
|
||
return null;
|
||
}
|
||
if (valueType === "date" || valueType === "datetime") {
|
||
if (trimmed === "") return "";
|
||
const unixSeconds = dateTimeInputToUnixSeconds(
|
||
trimmed,
|
||
valueType,
|
||
timeZone,
|
||
);
|
||
return unixSeconds ?? trimmed;
|
||
}
|
||
if (valueType === "number" || valueType === "float") {
|
||
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 isJsonObjectValue(value: unknown): value is Record<string, unknown> {
|
||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function isIntegerClaimDefaultValue(value: string) {
|
||
return /^-?\d+$/.test(value);
|
||
}
|
||
|
||
function isFloatClaimDefaultValue(value: string) {
|
||
return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value);
|
||
}
|
||
|
||
function isValidDateInputValue(value: string) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||
const date = new Date(`${value}T00:00:00Z`);
|
||
if (Number.isNaN(date.getTime())) return false;
|
||
return date.toISOString().slice(0, 10) === value;
|
||
}
|
||
|
||
function isValidDateTimeInputValue(value: string) {
|
||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
|
||
return false;
|
||
}
|
||
const date = new Date(value);
|
||
return !Number.isNaN(date.getTime());
|
||
}
|
||
|
||
function normalizedClaimValue(claim: IdTokenClaimItem): string | number {
|
||
const value = claim.value.trim();
|
||
if (claim.valueType !== "date" && claim.valueType !== "datetime") {
|
||
return value;
|
||
}
|
||
if (value === "") {
|
||
return value;
|
||
}
|
||
return (
|
||
dateTimeInputToUnixSeconds(value, claim.valueType, claim.timeZone) ?? value
|
||
);
|
||
}
|
||
|
||
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
|
||
const value = claim.value.trim();
|
||
if (value === "") {
|
||
return null;
|
||
}
|
||
|
||
switch (claim.valueType) {
|
||
case "number":
|
||
return isIntegerClaimDefaultValue(value)
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
case "float":
|
||
return isFloatClaimDefaultValue(value)
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
case "boolean":
|
||
return value === "true" || value === "false"
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
case "array": {
|
||
try {
|
||
return Array.isArray(JSON.parse(value))
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
} catch {
|
||
return t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
}
|
||
}
|
||
case "object": {
|
||
try {
|
||
return isJsonObjectValue(JSON.parse(value))
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
} catch {
|
||
return t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
}
|
||
}
|
||
case "date":
|
||
return isValidDateInputValue(value)
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
case "datetime":
|
||
return isValidDateTimeInputValue(value)
|
||
? null
|
||
: t(
|
||
"msg.dev.clients.general.id_token_claims.invalid_default_value",
|
||
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
|
||
{ key: claim.key || "-", valueType: claim.valueType },
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function claimDefaultInputType(valueType: ClaimValueType) {
|
||
if (valueType === "date") return "date";
|
||
if (valueType === "datetime") return "datetime-local";
|
||
return "text";
|
||
}
|
||
|
||
function claimDefaultInputMode(valueType: ClaimValueType) {
|
||
if (valueType === "number") return "numeric";
|
||
if (valueType === "float") return "decimal";
|
||
return undefined;
|
||
}
|
||
|
||
function claimDefaultInputPattern(valueType: ClaimValueType) {
|
||
if (valueType === "number") return "-?[0-9]*";
|
||
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
|
||
return undefined;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
rpClaims[key] = normalizeClaimPreviewValue(
|
||
item.value,
|
||
item.valueType,
|
||
item.nullable,
|
||
item.timeZone,
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
const host = url.hostname.toLowerCase();
|
||
if (
|
||
host === "localhost" ||
|
||
host === "127.0.0.1" ||
|
||
host === "::1" ||
|
||
host === "host.docker.internal"
|
||
) {
|
||
return true;
|
||
}
|
||
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
||
return (
|
||
host.startsWith("10.") ||
|
||
host.startsWith("192.168.") ||
|
||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
|
||
host.startsWith("169.254.")
|
||
);
|
||
}
|
||
// Docker service names and other single-label local hosts are allowed
|
||
// only for HTTP local development use.
|
||
return !host.includes(".");
|
||
} 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 hasAccessToken = Boolean(auth.user?.access_token);
|
||
const clientId = params.id;
|
||
const isCreate = !clientId;
|
||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||
const systemRole = resolveProfileRole(userProfile);
|
||
const { data: me, isLoading: isLoadingMe } = useQuery<UserProfile>({
|
||
queryKey: ["userMe"],
|
||
queryFn: fetchMe,
|
||
enabled: hasAccessToken,
|
||
});
|
||
const currentUserId = me?.id ?? auth.user?.profile.sub;
|
||
const effectiveSystemRole = me?.role?.trim() || systemRole;
|
||
const {
|
||
hasDeveloperAccess: hasClientCreateAccess,
|
||
isDeveloperRequestPending,
|
||
canRequestDeveloperAccess,
|
||
isLoadingDeveloperAccessGate,
|
||
} = useDeveloperAccessGate({
|
||
hasAccessToken,
|
||
profileRole: effectiveSystemRole,
|
||
tenantId: userProfile?.tenant_id as string | undefined,
|
||
requiredPages: ["client_create"],
|
||
isLoadingIdentity: isLoadingMe,
|
||
});
|
||
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: ["tenants", "all"],
|
||
queryFn: () => fetchTenants(),
|
||
});
|
||
|
||
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 [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||
const [idTokenClaimsEnabled, setIdTokenClaimsEnabled] = useState(false);
|
||
|
||
// Public Key Registration States
|
||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||
const [jwksUri, setJwksUri] = useState("");
|
||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||
|
||
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
|
||
const [isOfflineAccessGuideOpen, setIsOfflineAccessGuideOpen] =
|
||
useState(false);
|
||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||
{
|
||
id: "1",
|
||
name: "openid",
|
||
description: t(
|
||
"msg.dev.clients.scopes.openid",
|
||
"OIDC 로그인에 필요한 기본 scope",
|
||
),
|
||
mandatory: true,
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "tenants",
|
||
description: t(
|
||
"msg.dev.clients.scopes.tenants",
|
||
"tenant_id, joined_tenants, tenants 상세 및 root/부모 테넌트 접근",
|
||
),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "profile",
|
||
description: t(
|
||
"msg.dev.clients.scopes.profile",
|
||
"사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근",
|
||
),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "4",
|
||
name: "email",
|
||
description: t(
|
||
"msg.dev.clients.scopes.email",
|
||
"top-level email과 profile.email",
|
||
),
|
||
mandatory: false,
|
||
},
|
||
]);
|
||
const [idTokenClaims, setIdTokenClaims] = useState<IdTokenClaimItem[]>([]);
|
||
const browserTimeZone = useMemo(() => getBrowserTimeZone(), []);
|
||
const timeZoneOptions = useMemo(
|
||
() => getSupportedTimeZones(browserTimeZone),
|
||
[browserTimeZone],
|
||
);
|
||
|
||
const tenantScopeDescription = t(
|
||
"msg.dev.clients.scopes.tenants",
|
||
"소속 테넌트 정보 접근",
|
||
);
|
||
|
||
const buildTenantScope = useCallback(
|
||
(id: string): ScopeItem => ({
|
||
id,
|
||
name: "tenants",
|
||
description: tenantScopeDescription,
|
||
mandatory: true,
|
||
locked: true,
|
||
}),
|
||
[tenantScopeDescription],
|
||
);
|
||
|
||
const normalizeScopesForTenantAccess = useCallback(
|
||
(nextScopes: ScopeItem[], restricted: boolean): ScopeItem[] => {
|
||
const normalized = nextScopes.map((scope) => {
|
||
const scopeName = scope.name.trim();
|
||
if (scopeName !== "tenants" && scopeName !== "tenant") {
|
||
return scope;
|
||
}
|
||
const canonicalName = "tenants";
|
||
if (restricted) {
|
||
return {
|
||
...scope,
|
||
name: canonicalName,
|
||
description: scope.description || tenantScopeDescription,
|
||
mandatory: true,
|
||
locked: true,
|
||
};
|
||
}
|
||
return {
|
||
...scope,
|
||
name: canonicalName,
|
||
description: scope.description || tenantScopeDescription,
|
||
locked: false,
|
||
};
|
||
});
|
||
|
||
if (
|
||
restricted &&
|
||
!normalized.some(
|
||
(scope) =>
|
||
scope.name.trim() === "tenants" || scope.name.trim() === "tenant",
|
||
)
|
||
) {
|
||
normalized.push(buildTenantScope(`tenants-${Date.now()}`));
|
||
}
|
||
|
||
const openidScopes = normalized.filter(
|
||
(scope) => scope.name.trim() === "openid",
|
||
);
|
||
const tenantScopes = normalized.filter(
|
||
(scope) =>
|
||
scope.name.trim() === "tenants" || scope.name.trim() === "tenant",
|
||
);
|
||
const remainingScopes = normalized.filter((scope) => {
|
||
const name = scope.name.trim();
|
||
return name !== "openid" && name !== "tenants" && name !== "tenant";
|
||
});
|
||
|
||
return [...openidScopes, ...tenantScopes, ...remainingScopes];
|
||
},
|
||
[buildTenantScope, tenantScopeDescription],
|
||
);
|
||
|
||
const supportedScopeCandidates = useMemo<ScopeCandidate[]>(
|
||
() => [
|
||
{
|
||
id: "standard-openid",
|
||
name: "openid",
|
||
description: t(
|
||
"msg.dev.clients.scopes.openid",
|
||
"OIDC 로그인에 필요한 기본 scope",
|
||
),
|
||
source: "standard",
|
||
},
|
||
{
|
||
id: "standard-profile",
|
||
name: "profile",
|
||
description: t(
|
||
"msg.dev.clients.scopes.profile",
|
||
"사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근",
|
||
),
|
||
source: "standard",
|
||
},
|
||
{
|
||
id: "standard-email",
|
||
name: "email",
|
||
description: t(
|
||
"msg.dev.clients.scopes.email",
|
||
"top-level email과 profile.email",
|
||
),
|
||
source: "standard",
|
||
},
|
||
{
|
||
id: "standard-tenant",
|
||
name: "tenants",
|
||
description: tenantScopeDescription,
|
||
source: "standard",
|
||
},
|
||
{
|
||
id: "standard-offline-access",
|
||
name: "offline_access",
|
||
description: t(
|
||
"msg.dev.clients.scopes.offline_access",
|
||
"refresh token 발급 요청",
|
||
),
|
||
source: "standard",
|
||
},
|
||
],
|
||
[tenantScopeDescription],
|
||
);
|
||
|
||
const customClaimScopeCandidates = useMemo<ScopeCandidate[]>(() => {
|
||
const seen = new Set<string>();
|
||
const candidates: ScopeCandidate[] = [];
|
||
for (const claim of idTokenClaims) {
|
||
const name = claim.key.trim();
|
||
if (!name || seen.has(name)) {
|
||
continue;
|
||
}
|
||
seen.add(name);
|
||
candidates.push({
|
||
id: `custom-claim-${name}`,
|
||
name,
|
||
description: t(
|
||
"msg.dev.clients.scopes.custom_claim",
|
||
"Custom Claim 요청 scope",
|
||
),
|
||
source: "custom_claim",
|
||
});
|
||
}
|
||
return candidates;
|
||
}, [idTokenClaims]);
|
||
|
||
const scopeCandidates = useMemo<ScopeCandidate[]>(
|
||
() => [
|
||
...supportedScopeCandidates,
|
||
...customClaimScopeCandidates,
|
||
{
|
||
id: "manual-scope",
|
||
name: "",
|
||
description: t(
|
||
"msg.dev.clients.scopes.manual",
|
||
"목록에 없는 scope를 직접 입력합니다.",
|
||
),
|
||
source: "manual",
|
||
},
|
||
],
|
||
[customClaimScopeCandidates, supportedScopeCandidates],
|
||
);
|
||
|
||
const existingScopeNames = useMemo(() => {
|
||
const names = new Set<string>();
|
||
for (const scope of scopes) {
|
||
const name = scope.name.trim();
|
||
if (name) {
|
||
names.add(name);
|
||
}
|
||
}
|
||
return names;
|
||
}, [scopes]);
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
const { client } = data;
|
||
const metadata = client.metadata ?? {};
|
||
const headlessEnabled = !!metadata.headless_login_enabled;
|
||
|
||
setName(client.name || client.id);
|
||
setClientType(headlessEnabled ? "private" : client.type);
|
||
setStatus(client.status);
|
||
setInitialStatus(client.status);
|
||
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);
|
||
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
const savedIdTokenClaims = readIdTokenClaimsMetadata(metadata);
|
||
setIdTokenClaims(savedIdTokenClaims);
|
||
setIdTokenClaimsEnabled(
|
||
typeof metadata.id_token_claims_enabled === "boolean"
|
||
? metadata.id_token_claims_enabled
|
||
: savedIdTokenClaims.length > 0,
|
||
);
|
||
}, [data, normalizeScopesForTenantAccess]);
|
||
|
||
const securityProfile: SecurityProfile =
|
||
clientType === "pkce" ? "pkce" : "private";
|
||
const canEditExistingClientGeneralSettings =
|
||
effectiveSystemRole === "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") {
|
||
setHeadlessLoginEnabled(false);
|
||
setTokenEndpointAuthMethod("none");
|
||
} else {
|
||
setTokenEndpointAuthMethod(
|
||
headlessLoginEnabled ? "private_key_jwt" : "client_secret_basic",
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleHeadlessToggle = (enabled: boolean) => {
|
||
setHeadlessLoginEnabled(enabled);
|
||
if (enabled) {
|
||
setClientType("private");
|
||
setTokenEndpointAuthMethod("private_key_jwt");
|
||
return;
|
||
}
|
||
|
||
if (clientType === "private") {
|
||
setTokenEndpointAuthMethod("client_secret_basic");
|
||
}
|
||
};
|
||
|
||
const handleTenantAccessToggle = (enabled: boolean) => {
|
||
setTenantAccessRestricted(enabled);
|
||
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],
|
||
);
|
||
};
|
||
|
||
const addScope = () => {
|
||
setIsScopePickerOpen((current) => !current);
|
||
};
|
||
|
||
const selectScopeCandidate = (candidate: ScopeCandidate) => {
|
||
const name = candidate.name.trim();
|
||
if (name && existingScopeNames.has(name)) {
|
||
setIsScopePickerOpen(false);
|
||
return;
|
||
}
|
||
const newScope: ScopeItem = {
|
||
id: `scope-${candidate.source}-${name || "manual"}-${Date.now()}`,
|
||
name,
|
||
description: candidate.source === "manual" ? "" : candidate.description,
|
||
mandatory: false,
|
||
};
|
||
setScopes((current) =>
|
||
normalizeScopesForTenantAccess(
|
||
[...current, newScope],
|
||
tenantAccessRestricted,
|
||
),
|
||
);
|
||
setIsScopePickerOpen(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 = () => {
|
||
setIdTokenClaimsEnabled(true);
|
||
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 setIdTokenClaimPermissionAllowed = (
|
||
id: string,
|
||
field: "readPermission" | "writePermission",
|
||
allowed: boolean,
|
||
) => {
|
||
const permission = allowed ? "user_and_admin" : "admin_only";
|
||
setIdTokenClaims((current) =>
|
||
current.map((claim) => {
|
||
if (claim.id !== id) {
|
||
return claim;
|
||
}
|
||
return normalizeIdTokenClaimPermissions({
|
||
...claim,
|
||
[field]: permission,
|
||
});
|
||
}),
|
||
);
|
||
};
|
||
|
||
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 normalizedIdTokenClaimItems = idTokenClaims.map((claim) =>
|
||
normalizeIdTokenClaimPermissions({
|
||
...claim,
|
||
key: claim.key.trim(),
|
||
value: claim.value.trim(),
|
||
}),
|
||
);
|
||
const normalizedIdTokenClaims = normalizedIdTokenClaimItems.map((claim) => {
|
||
const { timeZone: _timeZone, value: _value, ...persisted } = claim;
|
||
return {
|
||
...persisted,
|
||
value: normalizedClaimValue(claim),
|
||
};
|
||
});
|
||
|
||
if (headlessLoginEnabled) {
|
||
if (!trimmedJwksUri) {
|
||
validationErrors.push(
|
||
t(
|
||
"ui.dev.clients.general.public_key.validation.missing_jwks_uri",
|
||
"JWKS URI를 입력해야 합니다.",
|
||
),
|
||
);
|
||
} else if (!isValidUrl(trimmedJwksUri)) {
|
||
validationErrors.push(
|
||
t(
|
||
"ui.dev.clients.general.public_key.validation.invalid_jwks_uri",
|
||
"JWKS URI 형식이 올바르지 않습니다.",
|
||
),
|
||
);
|
||
}
|
||
if (unsupportedParsedAlgorithms.length > 0) {
|
||
validationErrors.push(
|
||
t(
|
||
"ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
|
||
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
|
||
{ details: unsupportedParsedAlgorithmSummary },
|
||
),
|
||
);
|
||
}
|
||
if (missingParsedAlgorithms.length > 0) {
|
||
validationErrors.push(
|
||
t(
|
||
"ui.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[] = [];
|
||
if (idTokenClaimsEnabled) {
|
||
const seenClaimKeys = new Set<string>();
|
||
for (const claim of normalizedIdTokenClaimItems) {
|
||
if (!claim.key) {
|
||
claimValidationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.id_token_claims.key_required",
|
||
"Claim key를 입력해야 합니다.",
|
||
),
|
||
);
|
||
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: t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||
"rp_claims",
|
||
),
|
||
key: claim.key,
|
||
},
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
seenClaimKeys.add(keySignature);
|
||
|
||
const defaultValueError = claimDefaultValueValidationError(claim);
|
||
if (defaultValueError) {
|
||
claimValidationErrors.push(defaultValueError);
|
||
}
|
||
}
|
||
}
|
||
validationErrors.push(...claimValidationErrors);
|
||
|
||
const hasValidationErrors = validationErrors.length > 0;
|
||
const idTokenClaimPreview = idTokenClaimsEnabled
|
||
? buildIdTokenClaimsPreview(normalizedIdTokenClaimItems)
|
||
: [];
|
||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
|
||
const selectedAllowedTenants = allowedTenantIds
|
||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||
.filter((tenant): tenant is TenantSummary => 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, host.docker.internal, Docker 서비스명, 사설 IP의 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 persistedClientType = headlessLoginEnabled ? "private" : clientType;
|
||
const effectiveTokenEndpointAuthMethod = tokenEndpointAuthMethod;
|
||
|
||
const payload: ClientUpsertRequest = {
|
||
name,
|
||
type: persistedClientType,
|
||
scopes: scopeNames,
|
||
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
|
||
jwksUri:
|
||
(headlessLoginEnabled ||
|
||
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_enabled: idTokenClaimsEnabled,
|
||
id_token_claims: idTokenClaimsEnabled ? normalizedIdTokenClaims : [],
|
||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||
headless_login_enabled: headlessLoginEnabled,
|
||
headless_token_endpoint_auth_method: headlessLoginEnabled
|
||
? tokenEndpointAuthMethod
|
||
: undefined,
|
||
headless_jwks_uri: 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);
|
||
}
|
||
|
||
await queryClient.cancelQueries({ queryKey: ["client", clientId] });
|
||
const updated = await updateClient(clientId as string, payload);
|
||
if (status !== initialStatus) {
|
||
await updateClientStatus(clientId as string, status);
|
||
}
|
||
return updated;
|
||
},
|
||
onSuccess: (result) => {
|
||
const resultClientId = result?.client?.id ?? clientId;
|
||
if (resultClientId) {
|
||
queryClient.setQueryData(["client", resultClientId], 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 && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) {
|
||
return (
|
||
<div className="p-8 text-center">
|
||
{t(
|
||
"msg.dev.clients.general.loading",
|
||
isCreate ? "Loading client creation..." : "Loading client...",
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (isCreate && !hasClientCreateAccess) {
|
||
return (
|
||
<div className="p-8">
|
||
<div className="mx-auto max-w-2xl">
|
||
<DeveloperAccessRequestCard
|
||
title={t("ui.dev.clients.general.title_create", "Create Client")}
|
||
isPending={isDeveloperRequestPending}
|
||
canRequest={canRequestDeveloperAccess}
|
||
pendingMessage={t(
|
||
"msg.dev.clients.general.create_pending",
|
||
"개발자 권한 신청을 검토 중입니다.",
|
||
)}
|
||
deniedMessage={t(
|
||
"msg.dev.clients.general.create_forbidden",
|
||
"이 RP를 생성할 권한이 없습니다.",
|
||
)}
|
||
pendingDetailMessage={t(
|
||
"msg.dev.clients.general.create_pending_detail",
|
||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||
)}
|
||
deniedDetailMessage={t(
|
||
"msg.dev.clients.general.create_forbidden_detail",
|
||
"개발자 권한 신청에서 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
|
||
)}
|
||
actionLabel={t(
|
||
"ui.dev.welcome.btn_request",
|
||
"개발자 등록 신청하기",
|
||
)}
|
||
onAction={() => navigate("/developer-requests")}
|
||
/>
|
||
</div>
|
||
</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>
|
||
<PageHeader
|
||
icon={<ShieldHalf size={20} />}
|
||
title={
|
||
isCreate
|
||
? t("ui.dev.clients.general.title_create", "Create Client")
|
||
: t("ui.dev.clients.general.title_edit", "Client Settings")
|
||
}
|
||
description={t(
|
||
"ui.dev.clients.general.subtitle",
|
||
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
|
||
)}
|
||
/>
|
||
</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>
|
||
|
||
{/* 2. Scopes */}
|
||
{/* 3. Custom Claims */}
|
||
<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="default"
|
||
size="sm"
|
||
onClick={addScope}
|
||
className="gap-2"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
aria-expanded={isScopePickerOpen}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||
<Info className="h-4 w-4" />
|
||
<span>
|
||
{t(
|
||
"ui.dev.clients.general.scopes.offline_access_title",
|
||
"Refresh token 사용 시 offline_access scope가 필요합니다.",
|
||
)}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs leading-relaxed text-amber-950/80 dark:text-amber-50/80">
|
||
{t(
|
||
"msg.dev.clients.general.scopes.offline_access_summary",
|
||
"RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 shrink-0 gap-1 text-amber-900 hover:bg-amber-500/10 hover:text-amber-950 dark:text-amber-100 dark:hover:bg-amber-500/20 dark:hover:text-amber-50"
|
||
onClick={() => setIsOfflineAccessGuideOpen((prev) => !prev)}
|
||
aria-expanded={isOfflineAccessGuideOpen}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.scopes.offline_access_toggle",
|
||
"상세 안내 보기",
|
||
)}
|
||
>
|
||
{isOfflineAccessGuideOpen ? (
|
||
<X className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Info className="h-3.5 w-3.5" />
|
||
)}
|
||
{t("ui.common.info", "상세 안내")}
|
||
</Button>
|
||
</div>
|
||
{isOfflineAccessGuideOpen ? (
|
||
<div className="mt-3 rounded-lg border border-amber-500/20 bg-background/70 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||
<p className="font-semibold">
|
||
{t(
|
||
"msg.dev.clients.general.scopes.offline_access_conditions_title",
|
||
"Hydra 기준으로 refresh token 발급 조건",
|
||
)}
|
||
</p>
|
||
<ul className="mt-2 list-disc space-y-1 pl-4">
|
||
<li>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.offline_access_condition_request",
|
||
"authorization request scope에 offline 또는 offline_access 포함",
|
||
)}
|
||
</li>
|
||
<li>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.offline_access_condition_consent",
|
||
"consent accept의 granted_scope에 offline 또는 offline_access 포함",
|
||
)}
|
||
</li>
|
||
<li>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.offline_access_condition_grant_type",
|
||
"client grant_types에 refresh_token 포함",
|
||
)}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{isScopePickerOpen && (
|
||
<div className="space-y-3 rounded-md border border-border bg-muted/10 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.picker_title",
|
||
"Add a scope",
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.picker_help",
|
||
"Choose a supported scope or custom claim key to add it to the scope list.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={() => setIsScopePickerOpen(false)}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.scopes.close_picker",
|
||
"scope 선택 닫기",
|
||
)}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{scopeCandidates.map((candidate) => {
|
||
const isManual = candidate.source === "manual";
|
||
const isDuplicate =
|
||
candidate.name.trim() !== "" &&
|
||
existingScopeNames.has(candidate.name.trim());
|
||
return (
|
||
<button
|
||
key={candidate.id}
|
||
type="button"
|
||
className={cn(
|
||
"flex min-h-16 items-start justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-left text-sm transition-colors",
|
||
isDuplicate
|
||
? "cursor-not-allowed opacity-50"
|
||
: "hover:border-primary/60 hover:bg-primary/5",
|
||
)}
|
||
onClick={() => selectScopeCandidate(candidate)}
|
||
disabled={isDuplicate || isGeneralSettingsReadOnly}
|
||
>
|
||
<span className="min-w-0 space-y-1">
|
||
<span className="block font-mono text-xs font-semibold">
|
||
{isManual
|
||
? t(
|
||
"ui.dev.clients.general.scopes.manual_input",
|
||
"직접 입력",
|
||
)
|
||
: candidate.name}
|
||
</span>
|
||
<span className="block text-xs text-muted-foreground">
|
||
{candidate.description}
|
||
</span>
|
||
</span>
|
||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||
{candidate.source === "custom_claim"
|
||
? t(
|
||
"ui.dev.clients.general.scopes.source_custom_claim",
|
||
"Custom Claim",
|
||
)
|
||
: candidate.source === "manual"
|
||
? t(
|
||
"ui.dev.clients.general.scopes.source_manual",
|
||
"Manual",
|
||
)
|
||
: t(
|
||
"ui.dev.clients.general.scopes.source_standard",
|
||
"Standard",
|
||
)}
|
||
</Badge>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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, host.docker.internal, Docker 서비스명, 사설 IP의 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>
|
||
|
||
<SettingsTableShell>
|
||
<SettingsTable>
|
||
<SettingsTableHeader>
|
||
<tr>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.name",
|
||
"Scope Name",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.description",
|
||
"Description",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="text-center">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.mandatory",
|
||
"Mandatory",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="text-right">
|
||
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
|
||
</SettingsTableHead>
|
||
</tr>
|
||
</SettingsTableHeader>
|
||
<SettingsTableBody>
|
||
{scopes.length > 0 ? (
|
||
scopes.map((s) => (
|
||
<SettingsTableRow
|
||
key={s.id}
|
||
className={cn(
|
||
s.locked ? "bg-primary/5" : "hover:bg-muted/20",
|
||
)}
|
||
>
|
||
<SettingsTableCell>
|
||
<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}
|
||
/>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell>
|
||
<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}
|
||
/>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="text-center">
|
||
<div className="flex justify-center">
|
||
<Switch
|
||
checked={s.mandatory}
|
||
onCheckedChange={(checked) =>
|
||
updateScope(s.id, "mandatory", checked)
|
||
}
|
||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="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>
|
||
</SettingsTableCell>
|
||
</SettingsTableRow>
|
||
))
|
||
) : (
|
||
<SettingsTableEmptyState colSpan={4}>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.empty",
|
||
"등록된 스코프가 없습니다.",
|
||
)}
|
||
</SettingsTableEmptyState>
|
||
)}
|
||
</SettingsTableBody>
|
||
</SettingsTable>
|
||
</SettingsTableShell>
|
||
</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-3">
|
||
<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",
|
||
"RP 전용 확장 claim을 구분해서 관리합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
{idTokenClaimsEnabled ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={addIdTokenClaim}
|
||
className="gap-2"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.add",
|
||
"Claim 추가",
|
||
)}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className="flex shrink-0 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">
|
||
{idTokenClaimsEnabled
|
||
? t("ui.common.enabled", "사용")
|
||
: t("ui.common.disabled", "사용 안 함")}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.enabled",
|
||
"커스텀 클레임 사용",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={idTokenClaimsEnabled}
|
||
onCheckedChange={setIdTokenClaimsEnabled}
|
||
id="custom-claims-enabled"
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.enabled",
|
||
"커스텀 클레임 사용",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
{idTokenClaimsEnabled ? (
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,0.75fr)]">
|
||
<div className="space-y-3">
|
||
<SettingsTableShell>
|
||
<SettingsTable>
|
||
<SettingsTableHeader>
|
||
<tr>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.key",
|
||
"Claim Key",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.namespace",
|
||
"Namespace",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.value_type",
|
||
"Value Type",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="text-center">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.nullable",
|
||
"Nullable",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="text-center">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
|
||
"User read",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="text-center">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
|
||
"User write",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.default_value",
|
||
"Default Value",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="w-[56px] text-center">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.table.delete",
|
||
"Delete",
|
||
)}
|
||
</SettingsTableHead>
|
||
</tr>
|
||
</SettingsTableHeader>
|
||
<SettingsTableBody>
|
||
{idTokenClaims.length > 0 ? (
|
||
idTokenClaims.map((claim) => {
|
||
const defaultValueError =
|
||
claimDefaultValueValidationError(claim);
|
||
|
||
return (
|
||
<SettingsTableRow
|
||
key={claim.id}
|
||
className="hover:bg-muted/20"
|
||
>
|
||
<SettingsTableCell>
|
||
<Input
|
||
value={claim.key}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"key",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="h-8 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.id_token_claims.key_placeholder",
|
||
"e.g. locale",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell>
|
||
<Badge
|
||
variant="muted"
|
||
className="h-8 rounded-md border bg-muted/40 px-3 py-1.5 font-mono text-xs"
|
||
>
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||
"rp_claims",
|
||
)}
|
||
</Badge>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell>
|
||
<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 값 타입",
|
||
)}
|
||
className="h-8 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="float">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_float",
|
||
"Float",
|
||
)}
|
||
</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>
|
||
<option value="date">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_date",
|
||
"Date",
|
||
)}
|
||
</option>
|
||
<option value="datetime">
|
||
{t(
|
||
"ui.dev.clients.general.id_token_claims.value_type_datetime",
|
||
"Datetime",
|
||
)}
|
||
</option>
|
||
</select>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="text-center">
|
||
<div className="flex h-8 items-center justify-center">
|
||
<Switch
|
||
checked={claim.nullable}
|
||
onCheckedChange={(checked) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"nullable",
|
||
checked,
|
||
)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.nullable_label",
|
||
"Nullable",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="text-center">
|
||
<div className="flex h-8 items-center justify-center">
|
||
<Switch
|
||
checked={
|
||
claim.readPermission === "user_and_admin"
|
||
}
|
||
onCheckedChange={(checked) =>
|
||
setIdTokenClaimPermissionAllowed(
|
||
claim.id,
|
||
"readPermission",
|
||
checked,
|
||
)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
|
||
"사용자 읽기 허용",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="text-center">
|
||
<div className="flex h-8 items-center justify-center">
|
||
<Switch
|
||
checked={
|
||
claim.writePermission === "user_and_admin"
|
||
}
|
||
onCheckedChange={(checked) =>
|
||
setIdTokenClaimPermissionAllowed(
|
||
claim.id,
|
||
"writePermission",
|
||
checked,
|
||
)
|
||
}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
|
||
"사용자 쓰기 허용",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</div>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell>
|
||
{claim.valueType === "array" ||
|
||
claim.valueType === "object" ? (
|
||
<Textarea
|
||
value={claim.value}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"value",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="min-h-9 font-mono text-xs"
|
||
placeholder={
|
||
claim.valueType === "array"
|
||
? `["value"]`
|
||
: `{"key": "value"}`
|
||
}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
) : claim.valueType === "boolean" ? (
|
||
<select
|
||
value={
|
||
claim.value === "false" ? "false" : "true"
|
||
}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"value",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<option value="true">true</option>
|
||
<option value="false">false</option>
|
||
</select>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
key={claim.valueType}
|
||
type={claimDefaultInputType(
|
||
claim.valueType,
|
||
)}
|
||
inputMode={claimDefaultInputMode(
|
||
claim.valueType,
|
||
)}
|
||
pattern={claimDefaultInputPattern(
|
||
claim.valueType,
|
||
)}
|
||
value={claim.value}
|
||
onChange={(e) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"value",
|
||
e.target.value,
|
||
)
|
||
}
|
||
className="h-8 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.id_token_claims.value_placeholder",
|
||
"Enter the default value",
|
||
)}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
aria-invalid={
|
||
defaultValueError ? true : undefined
|
||
}
|
||
/>
|
||
{(claim.valueType === "date" ||
|
||
claim.valueType === "datetime") && (
|
||
<select
|
||
value={claim.timeZone}
|
||
onChange={(event) =>
|
||
updateIdTokenClaim(
|
||
claim.id,
|
||
"timeZone",
|
||
event.target.value,
|
||
)
|
||
}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
aria-label={t(
|
||
"ui.dev.clients.general.id_token_claims.timezone_label",
|
||
"Claim 기본값 시간대",
|
||
)}
|
||
>
|
||
{timeZoneOptions.map((timeZone) => (
|
||
<option
|
||
key={timeZone}
|
||
value={timeZone}
|
||
>
|
||
{timeZone}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
{defaultValueError && (
|
||
<p className="mt-1 text-xs text-destructive">
|
||
{defaultValueError}
|
||
</p>
|
||
)}
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="w-[56px] text-center align-top">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeIdTokenClaim(claim.id)}
|
||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</SettingsTableCell>
|
||
</SettingsTableRow>
|
||
);
|
||
})
|
||
) : (
|
||
<SettingsTableEmptyState colSpan={8}>
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.empty",
|
||
"아직 추가된 ID Token claim이 없습니다.",
|
||
)}
|
||
</SettingsTableEmptyState>
|
||
)}
|
||
</SettingsTableBody>
|
||
</SettingsTable>
|
||
</SettingsTableShell>
|
||
<p className="text-xs leading-6 text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.id_token_claims.hint",
|
||
"사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
|
||
)}
|
||
</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",
|
||
"설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Textarea
|
||
readOnly
|
||
value={idTokenClaimPreviewJson}
|
||
className="mt-4 min-h-72 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
) : null}
|
||
</Card>
|
||
|
||
{/* 4. Tenant Access Restriction */}
|
||
<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>
|
||
<div className="text-sm text-muted-foreground">
|
||
<p className="leading-relaxed">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.subtitle",
|
||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||
)}
|
||
</p>
|
||
<p className="leading-relaxed">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.hint",
|
||
"제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</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-3">
|
||
{tenantAccessRestricted ? (
|
||
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.picker_label",
|
||
"Add allowed tenant",
|
||
)}{" "}
|
||
<span className="text-destructive">*</span>
|
||
</Label>
|
||
<TenantAccessPicker
|
||
disabled={isGeneralSettingsReadOnly}
|
||
selectedCount={allowedTenantIds.length}
|
||
onSelectTenant={(selection) =>
|
||
handleSelectAllowedTenant(selection.id)
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.selected_title",
|
||
"허용 테넌트",
|
||
)}
|
||
</Label>
|
||
<SettingsTableShell bodyClassName="max-h-80">
|
||
<SettingsTable>
|
||
<SettingsTableHeader className="sticky top-0 z-10">
|
||
<tr>
|
||
<SettingsTableHead className="w-[28%]">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.table.name",
|
||
"테넌트명",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="w-[18%]">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.table.slug",
|
||
"슬러그",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead>
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.table.id",
|
||
"테넌트 ID",
|
||
)}
|
||
</SettingsTableHead>
|
||
<SettingsTableHead className="w-[112px] text-right">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.table.actions",
|
||
"작업",
|
||
)}
|
||
</SettingsTableHead>
|
||
</tr>
|
||
</SettingsTableHeader>
|
||
<SettingsTableBody>
|
||
{selectedAllowedTenants.length > 0 ? (
|
||
<>
|
||
{selectedAllowedTenants.map((tenant) => (
|
||
<SettingsTableRow
|
||
key={tenant.id}
|
||
data-testid={`allowed-tenant-${tenant.id}`}
|
||
>
|
||
<SettingsTableCell className="align-middle font-medium">
|
||
<span className="block truncate">
|
||
{tenant.name}
|
||
</span>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle text-muted-foreground">
|
||
{tenant.slug || "-"}
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
|
||
<span className="break-all">{tenant.id}</span>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle text-right">
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<CopyButton
|
||
aria-label="테넌트 UUID 복사"
|
||
className="h-8 w-8"
|
||
data-testid={`allowed-tenant-copy-${tenant.id}`}
|
||
size="icon"
|
||
value={tenant.id}
|
||
variant="ghost"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={t("ui.common.delete", "삭제")}
|
||
onClick={() =>
|
||
toggleAllowedTenant(tenant.id)
|
||
}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||
data-testid={`allowed-tenant-remove-${tenant.id}`}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</SettingsTableCell>
|
||
</SettingsTableRow>
|
||
))}
|
||
{allowedTenantIds
|
||
.filter(
|
||
(tenantId) =>
|
||
!selectedAllowedTenants.some(
|
||
(tenant) => tenant.id === tenantId,
|
||
),
|
||
)
|
||
.map((tenantId) => (
|
||
<SettingsTableRow
|
||
key={tenantId}
|
||
data-testid={`allowed-tenant-${tenantId}`}
|
||
>
|
||
<SettingsTableCell className="align-middle font-medium">
|
||
<span className="block truncate">
|
||
{tenantId}
|
||
</span>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle text-muted-foreground">
|
||
-
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle font-mono text-sm text-muted-foreground">
|
||
<span className="break-all">{tenantId}</span>
|
||
</SettingsTableCell>
|
||
<SettingsTableCell className="align-middle text-right">
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<CopyButton
|
||
aria-label="테넌트 UUID 복사"
|
||
className="h-8 w-8"
|
||
data-testid={`allowed-tenant-copy-${tenantId}`}
|
||
size="icon"
|
||
value={tenantId}
|
||
variant="ghost"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={t("ui.common.delete", "삭제")}
|
||
onClick={() =>
|
||
toggleAllowedTenant(tenantId)
|
||
}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||
data-testid={`allowed-tenant-remove-${tenantId}`}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</SettingsTableCell>
|
||
</SettingsTableRow>
|
||
))}
|
||
</>
|
||
) : (
|
||
<SettingsTableEmptyState colSpan={4} className="py-4">
|
||
{t(
|
||
"ui.dev.clients.general.tenant_access.selected_empty",
|
||
"아직 선택된 테넌트가 없습니다.",
|
||
)}
|
||
</SettingsTableEmptyState>
|
||
)}
|
||
</SettingsTableBody>
|
||
</SettingsTable>
|
||
</SettingsTableShell>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 5. Auto Login */}
|
||
<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>
|
||
{autoLoginSupported ? (
|
||
<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)}
|
||
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>
|
||
) : null}
|
||
</Card>
|
||
|
||
{/* 6. 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>
|
||
|
||
{securityProfile === "private" && (
|
||
<fieldset
|
||
className="mt-4 flex items-center justify-between border-t border-primary/20 pt-4"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="space-y-0.5">
|
||
<Label
|
||
className="cursor-pointer text-xs font-bold"
|
||
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",
|
||
"Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="headless-login-toggle"
|
||
checked={headlessLoginEnabled}
|
||
onCheckedChange={handleHeadlessToggle}
|
||
disabled={isGeneralSettingsReadOnly}
|
||
/>
|
||
</fieldset>
|
||
)}
|
||
</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>
|
||
</label>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 4. Public Key Registration (Headless Login) */}
|
||
{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",
|
||
"Server side App의 Headless Login capability에 필요한 공개키와 검증 정보를 관리합니다.",
|
||
)}
|
||
</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",
|
||
"RP가 자체 로그인 UI를 제공하더라도 실제 인증 흐름은 Baron API와 RP backend의 signed key 검증을 통해 이어집니다.",
|
||
)}
|
||
</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) => {
|
||
const normalizedAlgorithm = key.alg?.trim() ?? "";
|
||
const isMissingAlgorithm =
|
||
normalizedAlgorithm === "";
|
||
const isUnsupportedAlgorithm =
|
||
!isMissingAlgorithm &&
|
||
!HEADLESS_LOGIN_ALLOWED_ALGORITHM_SET.has(
|
||
normalizedAlgorithm,
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={`${key.kid ?? "missing-kid"}-${key.kty ?? ""}-${key.alg ?? ""}-${key.n ?? ""}`}
|
||
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;
|