1
0
forked from baron/baron-sso

변경 앱 이력 조회 박스 추가

This commit is contained in:
2026-05-27 13:44:12 +09:00
parent 955d0fb6da
commit 73ba79b015
6 changed files with 401 additions and 125 deletions

View File

@@ -1357,6 +1357,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
}
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
@@ -1403,6 +1404,16 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "ADD_RELATION",
"target_id": clientID,
"tenant_id": tenantID,
"after": map[string]any{
"relation": req.Relation,
"subject": req.Subject,
},
})
return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{
Object: clientID,
Relation: req.Relation,
@@ -1411,6 +1422,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
}
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
@@ -1444,6 +1456,16 @@ func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "REMOVE_RELATION",
"target_id": clientID,
"tenant_id": tenantID,
"before": map[string]any{
"relation": relation,
"subject": subject,
},
})
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -29,11 +29,6 @@ import {
commonTableViewportClass,
} from "../../../../common/ui/table";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -45,7 +40,6 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
@@ -57,6 +51,7 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
type DevAuditLog,
fetchClients,
fetchDeveloperRequestStatus,
fetchDevStats,
@@ -69,9 +64,143 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
} from "../../../../common/core/audit";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
const recentClientFieldLabels: Record<string, string> = {
name: "이름",
type: "유형",
status: "상태",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
redirect_uri_count: "Redirect URI 수",
scope_count: "Scope 수",
relation: "관계",
subject: "대상",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return "클라이언트 생성";
case "UPDATE_CLIENT":
return "설정 변경";
case "UPDATE_CLIENT_STATUS":
return "상태 변경";
case "ROTATE_SECRET":
return "클라이언트 시크릿 재발급";
case "ADD_RELATION":
return "관계 추가";
case "REMOVE_RELATION":
return "관계 삭제";
case "DELETE_CLIENT":
return "클라이언트 삭제";
default:
return action;
}
}
function buildRecentClientChangeDetails(
action: string,
details: ReturnType<typeof parseAuditDetails>,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [{ label: "클라이언트 시크릿", value: "재발급" }];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [{ label: "관계", value: formatAuditValue(source.relation) }]
: []),
...(source.subject
? [{ label: "대상", value: formatAuditValue(source.subject) }]
: []),
];
}
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)]),
);
const changes = keys
.map((key) => {
const beforeValue = before[key];
const afterValue = after[key];
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
return null;
}
}
const label = recentClientFieldLabels[key] ?? key;
if (action === "CREATE_CLIENT") {
if (afterValue === undefined) {
return null;
}
return { label, value: formatAuditValue(afterValue) };
}
if (action === "DELETE_CLIENT") {
if (beforeValue === undefined) {
return null;
}
return { label, value: formatAuditValue(beforeValue) };
}
if (beforeValue === undefined && afterValue === undefined) {
return null;
}
if (beforeValue === undefined) {
return { label, value: formatAuditValue(afterValue) };
}
if (afterValue === undefined) {
return { label, value: formatAuditValue(beforeValue) };
}
return {
label,
value: `${formatAuditValue(beforeValue)}${formatAuditValue(afterValue)}`,
};
})
.filter((item): item is { label: string; value: string } => Boolean(item));
return changes.slice(0, 3);
}
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -141,6 +270,61 @@ function ClientsPage() {
});
const clients = data?.items || [];
const visibleClientIds = useMemo(
() => clients.map((client) => client.id).filter(Boolean),
[clients],
);
const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({
queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")],
queryFn: async () => {
const globalLogs = await fetchDevAuditLogs(50);
if (globalLogs.items.length > 0 || profileRole === "super_admin") {
return globalLogs;
}
if (visibleClientIds.length === 0) {
return globalLogs;
}
const perClientLogs = await Promise.all(
visibleClientIds.slice(0, 20).map(async (clientId) => {
try {
const result = await fetchDevAuditLogs(5, undefined, {
client_id: clientId,
});
return result.items;
} catch {
return [];
}
}),
);
const merged = perClientLogs
.flat()
.filter(
(item, index, self) =>
self.findIndex((candidate) => candidate.event_id === item.event_id) ===
index,
)
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
)
.slice(0, 50);
return {
items: merged,
limit: 50,
cursor: globalLogs.cursor,
next_cursor: globalLogs.next_cursor,
};
},
enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole),
retry: false,
});
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
>(
@@ -238,9 +422,40 @@ function ClientsPage() {
},
];
const recentClientChanges = useMemo<RecentClientChange[]>(() => {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return (recentAuditData?.items || [])
.map((item: DevAuditLog) => {
const details = parseAuditDetails(item.details);
const action = details.action || "";
const clientId = String(details.target_id || "");
if (!recentClientActions.has(action) || !clientId) {
return null;
}
return {
eventId: item.event_id,
clientId,
clientName: clientNameById.get(clientId) || clientId,
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
};
})
.filter((item): item is RecentClientChange => Boolean(item))
.slice(0, 4);
}, [clients, recentAuditData?.items]);
const recentChangedClientCount = useMemo(() => {
return new Set(recentClientChanges.map((item) => item.clientId)).size;
}, [recentClientChanges]);
const isLoading =
isLoadingClients ||
isLoadingStats ||
isLoadingRecentAudit ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
@@ -706,82 +921,92 @@ function ClientsPage() {
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold">
{t(
"ui.dev.clients.help.title",
"Need help with OIDC configuration?",
)}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.help.subtitle",
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.recent_changes.description",
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
{ count: recentChangedClientCount },
)}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<BookOpenText className="h-6 w-6" />
</div>
<div>
<p className="font-semibold">
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.help.docs_body",
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
)}
</p>
</div>
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t(
"msg.dev.clients.recent_changes.permission_note",
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
)}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/audit-logs">
{t("ui.common.audit.title", "Audit Logs")}
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{recentClientChanges.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
<Button variant="secondary">
{t("ui.dev.clients.help.view_guides", "View guides")}
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">
{t("ui.dev.clients.owner.title", "Owner")}
</CardTitle>
<CardDescription>
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
</p>
</div>
</div>
<Separator className="mx-4 hidden h-10 w-px md:block" />
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
<span>
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
</span>
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
</div>
</CardContent>
</Card>
</div>
) : (
recentClientChanges.map((item) => {
const { date, time } = formatAuditDateParts(item.timestamp);
return (
<div
key={item.eventId}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card/40 p-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/clients/${item.clientId}`}
className="font-semibold transition-colors hover:text-primary"
>
{item.clientName}
</Link>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.clientId}
</code>
<Badge variant="muted">{item.actionLabel}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail, index) => (
<Badge key={`${item.eventId}-${index}`} variant="outline">
{detail.label}: {detail.value}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.no_detail",
"변경 항목을 확인할 수 없습니다.",
)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{date} {time}
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${item.clientId}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
);
})
)}
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}

View File

@@ -1365,6 +1365,15 @@ search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
title = "Recently Changed Apps"
[msg.dev.clients.recent_changes]
description = "{{count}} applications have change history."
permission_note = "You need the 'Audit Log Viewer' relationship to see recently changed apps."
empty = "No recent change logs yet."
no_detail = "Unable to inspect the changed fields."
[ui.dev.clients.badge]
admin_session = "Admin Session"
dev_session = "DevFront Session"
@@ -1633,25 +1642,9 @@ permits_info = "Can view DevFront audit logs for all configuration changes and o
label = "Status Change"
description = "Change the active or inactive state of the RP."
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
docs_title = "Docs & Examples"
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
title = "Connected Applications"
[ui.dev.clients.owner]
avatar_alt = "ops user"
email = "admin@brsw.kr"
name = "AI Admin Bot"
role = "Role: Tenant Admin"
scope = "Scope: TENANT-12"
subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
subtitle = "Applications"

View File

@@ -1365,6 +1365,15 @@ search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
title = "최근 변경된 앱"
[msg.dev.clients.recent_changes]
description = "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다."
permission_note = "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다."
empty = "최근 변경 로그가 아직 없습니다."
no_detail = "변경 항목을 확인할 수 없습니다."
[ui.dev.clients.badge]
admin_session = "관리자 세션"
dev_session = "DevFront 세션"
@@ -1632,25 +1641,9 @@ permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에
label = "상태 변경"
description = "RP 활성/비활성 상태를 변경합니다."
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
docs_title = "Docs & Examples"
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
title = "연동 앱 목록"
[ui.dev.clients.owner]
avatar_alt = "ops user"
email = "admin@brsw.kr"
name = "AI Admin Bot"
role = "Role: Tenant Admin"
scope = "Scope: TENANT-12"
subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
subtitle = "연동 앱"

View File

@@ -1421,6 +1421,15 @@ search_placeholder = ""
tenant_scoped = ""
untitled = ""
[ui.dev.clients.recent_changes]
title = ""
[msg.dev.clients.recent_changes]
description = ""
permission_note = ""
empty = ""
no_detail = ""
[ui.dev.clients.badge]
admin_session = ""
dev_session = ""
@@ -1689,25 +1698,9 @@ label = ""
description = ""
permits_info = ""
[ui.dev.clients.help]
docs_body = ""
docs_title = ""
subtitle = ""
title = ""
view_guides = ""
[ui.dev.clients.list]
title = ""
[ui.dev.clients.owner]
avatar_alt = ""
email = ""
name = ""
role = ""
scope = ""
subtitle = ""
title = ""
[ui.dev.clients.registry]
description = ""
subtitle = ""

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import {
type AuditLog,
type Consent,
installDevApiMock,
makeClient,
@@ -14,7 +15,7 @@ test.afterEach(async ({ page }, testInfo) => {
});
test("clients page loads correctly", async ({ page }) => {
await seedAuth(page);
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-playwright", {
@@ -44,3 +45,52 @@ test("clients page loads correctly", async ({ page }) => {
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
).toBeVisible();
});
test("clients page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-recent", {
name: "Recent RP",
}),
],
consents: [] as Consent[],
auditLogs: [
{
event_id: "evt-1",
timestamp: "2026-03-03T09:00:00.000Z",
user_id: "actor-1",
event_type: "CLIENT_RELATION_CREATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "ADD_RELATION",
target_id: "client-recent",
relation: "config_editor",
subject: "User:user-2",
}),
},
{
event_id: "evt-2",
timestamp: "2026-03-03T08:59:00.000Z",
user_id: "actor-2",
event_type: "CLIENT_ROTATE_SECRET",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "ROTATE_SECRET",
target_id: "client-recent",
}),
},
] as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(page.getByText("최근 변경된 RP")).toBeVisible();
await expect(page.getByText("클라이언트 시크릿 재발급")).toBeVisible();
await expect(page.getByText("관계 추가")).toBeVisible();
await expect(page.getByText("Recent RP")).toBeVisible();
});