forked from baron/baron-sso
1180 lines
42 KiB
TypeScript
1180 lines
42 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import type { AxiosError } from "axios";
|
||
import {
|
||
ArrowLeft,
|
||
Plus,
|
||
Save,
|
||
Shield,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "../../components/ui/card";
|
||
import { Input } from "../../components/ui/input";
|
||
import { Label } from "../../components/ui/label";
|
||
import { Switch } from "../../components/ui/switch";
|
||
import { Textarea } from "../../components/ui/textarea";
|
||
import { toast } from "../../components/ui/use-toast";
|
||
import {
|
||
createClient,
|
||
deleteClient,
|
||
fetchClient,
|
||
updateClient,
|
||
updateClientStatus,
|
||
} from "../../lib/devApi";
|
||
import type {
|
||
ClientStatus,
|
||
ClientType,
|
||
ClientUpsertRequest,
|
||
} from "../../lib/devApi";
|
||
import { t } from "../../lib/i18n";
|
||
import { cn } from "../../lib/utils";
|
||
import { tryConvertToJwks } from "../../lib/keyUtils";
|
||
|
||
interface ScopeItem {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
mandatory: boolean;
|
||
}
|
||
|
||
type SecurityProfile = "private" | "pkce";
|
||
type TokenEndpointAuthMethod =
|
||
| "none"
|
||
| "client_secret_basic"
|
||
| "private_key_jwt";
|
||
|
||
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 isValidUrl(value: string): boolean {
|
||
try {
|
||
const url = new URL(value);
|
||
return url.protocol === "https:" || url.protocol === "http:";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isValidJson(value: string): boolean {
|
||
if (!value.trim()) return false;
|
||
try {
|
||
JSON.parse(value);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function ClientGeneralPage() {
|
||
const params = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const clientId = params.id;
|
||
const isCreate = !clientId;
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["client", clientId],
|
||
queryFn: () => fetchClient(clientId as string),
|
||
enabled: !isCreate,
|
||
});
|
||
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [logoUrl, setLogoUrl] = useState("");
|
||
const [clientType, setClientType] = useState<ClientType>("private");
|
||
const [status, setStatus] = useState<ClientStatus>("active");
|
||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||
const [redirectUris, setRedirectUris] = useState("");
|
||
|
||
// Public Key Registration States
|
||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||
const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline");
|
||
const [jwksUri, setJwksUri] = useState("");
|
||
const [jwksText, setJwksText] = useState("");
|
||
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
|
||
useState("RS256");
|
||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||
|
||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||
{
|
||
id: "1",
|
||
name: "openid",
|
||
description: t("msg.dev.clients.scopes.openid", "OIDC 인증 필수 스코프"),
|
||
mandatory: true,
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "profile",
|
||
description: t("msg.dev.clients.scopes.profile", "기본 프로필 정보 접근"),
|
||
mandatory: false,
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "email",
|
||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||
mandatory: false,
|
||
},
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
const { client } = data;
|
||
setName(client.name || client.id);
|
||
setClientType(client.type);
|
||
setStatus(client.status);
|
||
setInitialStatus(client.status);
|
||
|
||
const savedAuthMethod =
|
||
client.tokenEndpointAuthMethod ||
|
||
(client.type === "pkce" ? "none" : "client_secret_basic");
|
||
if (isTokenEndpointAuthMethod(savedAuthMethod)) {
|
||
setTokenEndpointAuthMethod(savedAuthMethod);
|
||
}
|
||
|
||
if (client.jwksUri) {
|
||
setJwksUri(client.jwksUri);
|
||
setJwksSource("uri");
|
||
} else if (client.jwks) {
|
||
setJwksText(
|
||
typeof client.jwks === "string"
|
||
? client.jwks
|
||
: JSON.stringify(client.jwks, null, 2),
|
||
);
|
||
setJwksSource("inline");
|
||
}
|
||
|
||
const metadata = client.metadata ?? {};
|
||
if (typeof metadata.description === "string")
|
||
setDescription(metadata.description);
|
||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||
|
||
setHeadlessLoginEnabled(!!metadata.headless_login_enabled);
|
||
|
||
// Fallbacks from metadata if top-level fields are empty
|
||
if (!client.tokenEndpointAuthMethod) {
|
||
const metaAuth = readMetadataString(
|
||
metadata,
|
||
"token_endpoint_auth_method",
|
||
);
|
||
if (isTokenEndpointAuthMethod(metaAuth)) {
|
||
setTokenEndpointAuthMethod(metaAuth);
|
||
}
|
||
}
|
||
|
||
if (!client.jwksUri && !client.jwks) {
|
||
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
||
if (metaJwksUri) {
|
||
setJwksUri(metaJwksUri);
|
||
setJwksSource("uri");
|
||
}
|
||
}
|
||
|
||
const savedRequestObjectSigningAlg = readMetadataString(
|
||
metadata,
|
||
"request_object_signing_alg",
|
||
);
|
||
if (savedRequestObjectSigningAlg) {
|
||
setRequestObjectSigningAlg(savedRequestObjectSigningAlg);
|
||
} else if (savedAuthMethod === "private_key_jwt") {
|
||
setRequestObjectSigningAlg("RS256");
|
||
}
|
||
|
||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||
if (savedScopes && Array.isArray(savedScopes)) {
|
||
setScopes(savedScopes);
|
||
} else {
|
||
setScopes(
|
||
client.scopes.map((s, idx) => ({
|
||
id: String(idx + 1),
|
||
name: s,
|
||
description: "",
|
||
mandatory: s === "openid",
|
||
})),
|
||
);
|
||
}
|
||
}, [data]);
|
||
|
||
const securityProfile: SecurityProfile =
|
||
clientType === "pkce" ? "pkce" : "private";
|
||
|
||
const handleSecurityProfileChange = (profile: SecurityProfile) => {
|
||
setClientType(profile);
|
||
if (profile === "pkce") {
|
||
setTokenEndpointAuthMethod(
|
||
headlessLoginEnabled ? "private_key_jwt" : "none",
|
||
);
|
||
} else {
|
||
setTokenEndpointAuthMethod("client_secret_basic");
|
||
}
|
||
};
|
||
|
||
const handleHeadlessToggle = (enabled: boolean) => {
|
||
setHeadlessLoginEnabled(enabled);
|
||
if (clientType === "pkce") {
|
||
setTokenEndpointAuthMethod(enabled ? "private_key_jwt" : "none");
|
||
if (enabled && requestObjectSigningAlg.trim() === "") {
|
||
setRequestObjectSigningAlg("RS256");
|
||
}
|
||
}
|
||
};
|
||
|
||
const addScope = () => {
|
||
const newId = String(Date.now());
|
||
setScopes([
|
||
...scopes,
|
||
{ id: newId, name: "", description: "", mandatory: false },
|
||
]);
|
||
};
|
||
|
||
const updateScope = <K extends keyof ScopeItem>(
|
||
id: string,
|
||
field: K,
|
||
value: ScopeItem[K],
|
||
) => {
|
||
setScopes(
|
||
scopes.map((scope) =>
|
||
scope.id === id ? { ...scope, [field]: value } : scope,
|
||
),
|
||
);
|
||
};
|
||
|
||
const removeScope = (id: string) => {
|
||
setScopes(scopes.filter((s) => s.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 },
|
||
),
|
||
);
|
||
};
|
||
|
||
// Convert on blur or change if desired, here we try to convert before validation
|
||
const finalJwksText = tryConvertToJwks(jwksText);
|
||
const validationErrors: string[] = [];
|
||
const trimmedJwksUri = jwksUri.trim();
|
||
const trimmedJwksText = finalJwksText.trim();
|
||
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
|
||
|
||
if (headlessLoginEnabled) {
|
||
if (jwksSource === "uri") {
|
||
if (!trimmedJwksUri) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
|
||
"JWKS URI를 입력해야 합니다.",
|
||
),
|
||
);
|
||
} else if (!isValidUrl(trimmedJwksUri)) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
|
||
"JWKS URI 형식이 올바르지 않습니다.",
|
||
),
|
||
);
|
||
}
|
||
} else if (jwksSource === "inline") {
|
||
if (!trimmedJwksText) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.missing_jwks_inline",
|
||
"공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다.",
|
||
),
|
||
);
|
||
} else if (!isValidJson(trimmedJwksText)) {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.invalid_jwks_inline",
|
||
"입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (trimmedRequestObjectSigningAlg === "") {
|
||
validationErrors.push(
|
||
t(
|
||
"msg.dev.clients.general.public_key.validation.headless_requires_alg",
|
||
"Request Object Signing Algorithm (예: RS256)을 입력해야 합니다.",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
const hasValidationErrors = validationErrors.length > 0;
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: async () => {
|
||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||
|
||
let finalJwks: ClientUpsertRequest["jwks"];
|
||
if (
|
||
tokenEndpointAuthMethod === "private_key_jwt" &&
|
||
jwksSource === "inline" &&
|
||
trimmedJwksText
|
||
) {
|
||
try {
|
||
finalJwks = JSON.parse(trimmedJwksText);
|
||
} catch (e) {
|
||
throw new Error("Invalid Public Key Format");
|
||
}
|
||
}
|
||
|
||
const payload: ClientUpsertRequest = {
|
||
name,
|
||
type: clientType,
|
||
scopes: scopeNames,
|
||
tokenEndpointAuthMethod,
|
||
jwksUri:
|
||
tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri"
|
||
? trimmedJwksUri
|
||
: undefined,
|
||
jwks: finalJwks,
|
||
metadata: {
|
||
description,
|
||
logo_url: logoUrl,
|
||
structured_scopes: scopes,
|
||
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
||
request_object_signing_alg: trimmedRequestObjectSigningAlg,
|
||
headless_login_enabled: headlessLoginEnabled,
|
||
},
|
||
};
|
||
|
||
if (isCreate) {
|
||
payload.status = status;
|
||
payload.redirectUris = redirectUris
|
||
.split(",")
|
||
.map((uri) => uri.trim())
|
||
.filter(Boolean);
|
||
return createClient(payload);
|
||
}
|
||
|
||
const updated = await updateClient(clientId as string, payload);
|
||
if (status !== initialStatus) {
|
||
await updateClientStatus(clientId as string, status);
|
||
}
|
||
return updated;
|
||
},
|
||
onSuccess: (result) => {
|
||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||
if (status !== initialStatus) {
|
||
setInitialStatus(status);
|
||
}
|
||
if (result?.client?.id) {
|
||
navigate(`/clients/${result.client.id}/settings`);
|
||
}
|
||
alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message ??
|
||
t("msg.common.unknown_error", "unknown error");
|
||
alert(
|
||
t(
|
||
"msg.dev.clients.general.save_error",
|
||
"저장에 실패했습니다: {{error}}",
|
||
{
|
||
error: errorMessage,
|
||
},
|
||
),
|
||
);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteClient(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||
alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
|
||
navigate("/clients");
|
||
},
|
||
onError: (err) => {
|
||
const errorMessage =
|
||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(err as Error)?.message;
|
||
alert(
|
||
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
|
||
error: errorMessage,
|
||
}),
|
||
);
|
||
},
|
||
});
|
||
|
||
const handleDelete = () => {
|
||
if (
|
||
clientId &&
|
||
window.confirm(
|
||
t(
|
||
"msg.dev.clients.delete_confirm",
|
||
"정말로 이 앱을 삭제하시습니까? 이 작업은 되돌릴 수 없습니다.",
|
||
),
|
||
)
|
||
) {
|
||
deleteMutation.mutate(clientId);
|
||
}
|
||
};
|
||
|
||
if (!isCreate && isLoading) {
|
||
return (
|
||
<div className="p-8 text-center">
|
||
{t("msg.dev.clients.general.loading", "Loading client...")}
|
||
</div>
|
||
);
|
||
}
|
||
if (!isCreate && (error || !data)) {
|
||
const errMsg =
|
||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(error as Error)?.message;
|
||
return (
|
||
<div className="p-8 text-center text-red-500">
|
||
{t(
|
||
"msg.dev.clients.general.load_error",
|
||
"Error loading client: {{error}}",
|
||
{
|
||
error: errMsg || t("msg.common.unknown_error", "unknown error"),
|
||
},
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const publicKeyStatusTone = headlessLoginEnabled
|
||
? hasValidationErrors
|
||
? "border-destructive/40 bg-destructive/5"
|
||
: "border-primary/30 bg-primary/5"
|
||
: "border-border bg-muted/20";
|
||
|
||
const displayName = isCreate
|
||
? t("ui.dev.clients.general.display_new", "새 클라이언트")
|
||
: data?.client?.name || data?.client?.id;
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<header className="space-y-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div className="space-y-2">
|
||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||
<Link to="/" className="hover:text-primary">
|
||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||
</Link>
|
||
<span>/</span>
|
||
<Link to="/clients" className="hover:text-primary">
|
||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||
</Link>
|
||
<span>/</span>
|
||
<span>{displayName}</span>
|
||
{!isCreate && (
|
||
<>
|
||
<span>/</span>
|
||
<span className="text-foreground font-semibold">
|
||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||
</span>
|
||
</>
|
||
)}
|
||
</nav>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="ghost" size="icon" asChild>
|
||
<Link to={isCreate ? "/clients" : `/clients/${clientId}`}>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Link>
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-3xl font-black leading-tight">
|
||
{isCreate
|
||
? t("ui.dev.clients.general.title_create", "Create Client")
|
||
: t("ui.dev.clients.general.title_edit", "Client Settings")}
|
||
</h1>
|
||
<p className="text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.subtitle",
|
||
"앱 정보, 권한 스코프, 보안 설정을 관리합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{!isCreate && (
|
||
<Badge
|
||
variant={status === "active" ? "info" : "muted"}
|
||
className="px-3 py-1 text-xs uppercase"
|
||
>
|
||
{status === "active"
|
||
? t("ui.common.status.active", "Active")
|
||
: t("ui.common.status.inactive", "Inactive")}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||
{!isCreate && (
|
||
<>
|
||
<Link
|
||
to={`/clients/${clientId}`}
|
||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||
>
|
||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||
</Link>
|
||
<Link
|
||
to={`/clients/${clientId}/consents`}
|
||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||
>
|
||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||
</Link>
|
||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||
</span>
|
||
</>
|
||
)}
|
||
</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",
|
||
)}
|
||
/>
|
||
</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",
|
||
"앱에 대한 간단한 설명을 입력하세요.",
|
||
)}
|
||
/>
|
||
</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)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.identity.logo_placeholder",
|
||
"https://example.com/logo.png",
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.identity.logo_help",
|
||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
||
{logoUrl ? (
|
||
<img
|
||
src={logoUrl}
|
||
alt={t(
|
||
"ui.dev.clients.general.identity.logo_preview",
|
||
"Logo Preview",
|
||
)}
|
||
className="h-full w-full object-contain"
|
||
/>
|
||
) : (
|
||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||
)}
|
||
</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")}
|
||
>
|
||
{t("ui.common.status.active", "활성")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={status === "inactive" ? "default" : "outline"}
|
||
onClick={() => handleStatusChange("inactive")}
|
||
>
|
||
{t("ui.common.status.inactive", "비활성")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 2. Scopes */}
|
||
<Card className="glass-panel">
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||
<div>
|
||
<CardTitle className="text-xl font-bold">
|
||
{t("ui.dev.clients.general.scopes.title", "Scopes")}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.subtitle",
|
||
"이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addScope}
|
||
className="gap-2"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{isCreate && (
|
||
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
||
<Label className="text-sm font-semibold">
|
||
{t("ui.dev.clients.general.redirect.label", "Redirect URIs")}{" "}
|
||
<span className="text-destructive">*</span>
|
||
</Label>
|
||
<Textarea
|
||
value={redirectUris}
|
||
onChange={(e) => setRedirectUris(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.redirect.placeholder",
|
||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||
)}
|
||
className="font-mono text-sm"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.redirect.help",
|
||
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.name",
|
||
"Scope Name",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.description",
|
||
"Description",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-center font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.scopes.table.mandatory",
|
||
"Mandatory",
|
||
)}
|
||
</th>
|
||
<th className="px-4 py-3 text-right font-bold">
|
||
{t("ui.dev.clients.general.scopes.table.delete", "Delete")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{scopes.map((s) => (
|
||
<tr
|
||
key={s.id}
|
||
className="hover:bg-muted/30 transition-colors"
|
||
>
|
||
<td className="px-4 py-3">
|
||
<Input
|
||
value={s.name}
|
||
onChange={(e) =>
|
||
updateScope(s.id, "name", e.target.value)
|
||
}
|
||
className="h-8 font-mono text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.scopes.name_placeholder",
|
||
"e.g. profile",
|
||
)}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Input
|
||
value={s.description}
|
||
onChange={(e) =>
|
||
updateScope(s.id, "description", e.target.value)
|
||
}
|
||
className="h-8 text-xs"
|
||
placeholder={t(
|
||
"ui.dev.clients.general.scopes.description_placeholder",
|
||
"권한에 대한 설명",
|
||
)}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
<div className="flex justify-center">
|
||
<Switch
|
||
checked={s.mandatory}
|
||
onCheckedChange={(checked) =>
|
||
updateScope(s.id, "mandatory", checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeScope(s.id)}
|
||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{scopes.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={4}
|
||
className="px-4 py-8 text-center text-muted-foreground"
|
||
>
|
||
{t(
|
||
"msg.dev.clients.general.scopes.empty",
|
||
"등록된 스코프가 없습니다.",
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 3. Security Settings */}
|
||
<Card className="glass-panel">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-xl font-bold">
|
||
{t("ui.dev.clients.general.security.title", "보안 설정")}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.security.subtitle",
|
||
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<label
|
||
className={cn(
|
||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||
securityProfile === "private"
|
||
? "border-primary bg-primary/5"
|
||
: "border-border bg-card hover:border-muted-foreground/40",
|
||
)}
|
||
>
|
||
<input
|
||
className="sr-only"
|
||
type="radio"
|
||
name="security-profile"
|
||
checked={securityProfile === "private"}
|
||
onChange={() => handleSecurityProfileChange("private")}
|
||
/>
|
||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||
<Shield className="h-4 w-4 text-primary" />
|
||
{t(
|
||
"ui.dev.clients.general.security.private",
|
||
"Server side App",
|
||
)}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.security.private_help",
|
||
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
|
||
)}
|
||
</span>
|
||
<span className="absolute right-4 top-4 text-primary">
|
||
{securityProfile === "private" ? "✓" : ""}
|
||
</span>
|
||
</label>
|
||
|
||
<label
|
||
className={cn(
|
||
"relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 p-4 transition",
|
||
securityProfile === "pkce"
|
||
? "border-primary bg-primary/5"
|
||
: "border-border bg-card hover:border-muted-foreground/40",
|
||
)}
|
||
>
|
||
<input
|
||
className="sr-only"
|
||
type="radio"
|
||
name="security-profile"
|
||
checked={securityProfile === "pkce"}
|
||
onChange={() => handleSecurityProfileChange("pkce")}
|
||
/>
|
||
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
|
||
<Sparkles className="h-4 w-4" />
|
||
{t("ui.dev.clients.general.security.pkce", "PKCE")}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.security.pkce_help",
|
||
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
|
||
)}
|
||
</span>
|
||
<span className="absolute right-4 top-4 text-primary">
|
||
{securityProfile === "pkce" ? "✓" : ""}
|
||
</span>
|
||
|
||
{securityProfile === "pkce" && (
|
||
<div
|
||
className="mt-4 pt-4 border-t border-primary/20 flex items-center justify-between"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="space-y-0.5">
|
||
<Label
|
||
className="text-xs font-bold cursor-pointer"
|
||
htmlFor="trusted-rp-toggle"
|
||
>
|
||
{t(
|
||
"ui.dev.clients.general.security.trusted_rp_enable",
|
||
"Trusted RP (자체 로그인 UI 사용)",
|
||
)}
|
||
</Label>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.security.trusted_rp_enable_help",
|
||
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
id="trusted-rp-toggle"
|
||
checked={headlessLoginEnabled}
|
||
onCheckedChange={handleHeadlessToggle}
|
||
/>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 4. Public Key Registration (Trusted RP) */}
|
||
{clientType === "pkce" && headlessLoginEnabled && (
|
||
<Card className="glass-panel border-primary/20">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.title",
|
||
"Public Key Registration",
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t(
|
||
"msg.dev.clients.general.public_key.subtitle",
|
||
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className={cn("rounded-xl border p-4", publicKeyStatusTone)}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label className="text-sm font-bold text-foreground">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.headless_toggle",
|
||
"Headless Login 허용 여부",
|
||
)}
|
||
</Label>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.headless_help",
|
||
"애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Badge
|
||
variant="default"
|
||
className="bg-primary/20 text-primary border-primary/30"
|
||
>
|
||
{t("ui.common.enabled", "Enabled")}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
<div className="space-y-3">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.request_object_alg",
|
||
"Request Object Signing Algorithm",
|
||
)}
|
||
<span className="text-destructive ml-1">*</span>
|
||
</Label>
|
||
<Input
|
||
value={requestObjectSigningAlg}
|
||
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
|
||
"예: RS256",
|
||
)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.request_object_alg_help",
|
||
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 rounded-xl border border-border bg-muted/5 p-4">
|
||
<div className="space-y-1 pb-2 border-b border-border/50">
|
||
<Label className="text-sm font-bold">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.source",
|
||
"Public Key Source",
|
||
)}
|
||
</Label>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.source_help",
|
||
"OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)",
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="radio"
|
||
name="jwksSource"
|
||
checked={jwksSource === "inline"}
|
||
onChange={() => setJwksSource("inline")}
|
||
className="accent-primary"
|
||
/>
|
||
<span>Inline Public Key</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="radio"
|
||
name="jwksSource"
|
||
checked={jwksSource === "uri"}
|
||
onChange={() => setJwksSource("uri")}
|
||
className="accent-primary"
|
||
/>
|
||
<span>JWKS URI</span>
|
||
</label>
|
||
</div>
|
||
|
||
{jwksSource === "uri" && (
|
||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.jwks_uri",
|
||
"JWKS URI",
|
||
)}
|
||
<span className="text-destructive ml-1">*</span>
|
||
</Label>
|
||
<Input
|
||
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>
|
||
)}
|
||
|
||
{jwksSource === "inline" && (
|
||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||
<Label className="text-sm font-semibold">
|
||
{t(
|
||
"ui.dev.clients.general.public_key.jwks_inline",
|
||
"JWKS 또는 OpenSSH 공개키",
|
||
)}
|
||
<span className="text-destructive ml-1">*</span>
|
||
</Label>
|
||
<Textarea
|
||
rows={8}
|
||
value={jwksText}
|
||
onChange={(e) => setJwksText(e.target.value)}
|
||
placeholder={t(
|
||
"ui.dev.clients.general.public_key.jwks_inline_placeholder",
|
||
"JWKS (JSON) 또는 'ssh-rsa AAA...' 형식의 공개키를 붙여넣으세요.",
|
||
)}
|
||
className="font-mono text-xs leading-tight"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(
|
||
"msg.dev.clients.general.public_key.jwks_inline_help",
|
||
"OIDC 표준인 JWKS(JSON) 형식을 권장하지만, SSH-RSA 공개키를 입력하면 자동으로 변환하여 저장합니다.",
|
||
)}
|
||
</p>
|
||
</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={
|
||
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;
|