forked from baron/baron-sso
변경 앱 이력 조회 박스 추가
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "연동 앱"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user