1
0
forked from baron/baron-sso

최근 변경된 앱 대시보드 추가

This commit is contained in:
2026-06-01 15:16:21 +09:00
parent c4487b9334
commit d40e443d48
7 changed files with 1034 additions and 649 deletions

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react";
import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
@@ -44,10 +44,7 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
type DevAuditLog,
fetchDevUser,
fetchClients,
fetchDevAuditLogs,
fetchDeveloperRequestStatus,
fetchDevStats,
fetchMyTenants,
@@ -59,198 +56,10 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
} from "../../../../common/core/audit";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
actorName: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientChangesInitialCount = 5;
const recentClientChangesBatchSize = 5;
const clientListPreviewCount = 5;
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
const recentChangeGuideItems = [
{
titleKey: "ui.dev.clients.recent_changes.guide.create",
titleFallback: "앱 생성",
descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc",
descriptionFallback:
"새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.settings",
titleFallback: "설정 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc",
descriptionFallback:
"앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.status",
titleFallback: "상태 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc",
descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.relation",
titleFallback: "관계 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc",
descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.secret",
titleFallback: "클라이언트 시크릿 재발급",
descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc",
descriptionFallback: "시크릿 재발급 이력이 보입니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.delete",
titleFallback: "앱 삭제",
descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc",
descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.",
},
] as const;
const recentClientFieldLabels: Record<string, string> = {
name: "이름",
type: "유형",
status: "상태",
scopes: "스코프",
tenant_access_restricted: "테넌트 접근 제한",
allowed_tenants: "허용 테넌트",
id_token_claims: "커스텀 클레임",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
headless_login_enabled: "헤드리스 로그인",
headless_token_endpoint_auth_method: "헤드리스 인증 방식",
headless_jwks_uri: "헤드리스 JWKS URI",
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();
@@ -315,11 +124,7 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] =
useState(false);
const [isClientListExpanded, setIsClientListExpanded] = useState(false);
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
useState(recentClientChangesInitialCount);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
@@ -327,61 +132,6 @@ 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>
@@ -488,105 +238,9 @@ 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,
actorId: resolveAuditActor(item, details),
actorName: "",
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
};
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}, [clients, recentAuditData?.items]);
const recentClientActorIds = useMemo(() => {
return Array.from(
new Set(
recentClientChanges
.map((item) => item.actorId.trim())
.filter((actorId) => actorId && actorId !== "-"),
),
);
}, [recentClientChanges]);
const { data: recentClientActors } = useQuery({
queryKey: ["recent-client-actors", recentClientActorIds],
queryFn: async () => {
const entries = await Promise.all(
recentClientActorIds.map(async (actorId) => {
try {
const user = await fetchDevUser(actorId);
return [actorId, user.name || actorId] as const;
} catch {
return [actorId, actorId] as const;
}
}),
);
return Object.fromEntries(entries);
},
enabled: recentClientActorIds.length > 0,
});
const recentClientChangesWithActors = useMemo(() => {
return recentClientChanges.map((item) => ({
...item,
actorName: recentClientActors?.[item.actorId] || item.actorId,
}));
}, [recentClientActors, recentClientChanges]);
const recentChangedClientCount = useMemo(() => {
return new Set(recentClientChangesWithActors.map((item) => item.clientId))
.size;
}, [recentClientChangesWithActors]);
const visibleRecentClientChanges = useMemo(() => {
return recentClientChangesWithActors.slice(
0,
visibleRecentClientChangesCount,
);
}, [recentClientChangesWithActors, visibleRecentClientChangesCount]);
const hasMoreRecentClientChanges =
recentClientChangesWithActors.length > visibleRecentClientChanges.length;
useEffect(() => {
if (
visibleRecentClientChangesCount > recentClientChangesWithActors.length
) {
setVisibleRecentClientChangesCount(
Math.max(
recentClientChangesInitialCount,
recentClientChangesWithActors.length,
),
);
}
}, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]);
const isLoading =
isLoadingClients ||
isLoadingStats ||
isLoadingRecentAudit ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
@@ -1084,164 +738,6 @@ function ClientsPage() {
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
<div>
<div className="flex items-center gap-1.5">
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-8 w-8 translate-y-px text-muted-foreground hover:text-primary"
aria-label={t(
"ui.dev.clients.recent_changes.guide_button",
"최근 변경 항목 안내 열기",
)}
aria-expanded={isRecentChangesGuideOpen}
onClick={() =>
setIsRecentChangesGuideOpen((current) => !current)
}
>
<Info className="h-4 w-4" />
</Button>
</div>
<CardDescription>
{t(
"msg.dev.clients.recent_changes.description",
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
{ count: recentChangedClientCount },
)}
</CardDescription>
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t(
"msg.dev.clients.recent_changes.permission_note",
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
)}
</p>
{isRecentChangesGuideOpen && (
<div className="mt-3 rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">
{t(
"ui.dev.clients.recent_changes.guide_title",
"최근 변경 항목 안내",
)}
</p>
<div className="mt-3 space-y-3">
{recentChangeGuideItems.map((item) => (
<div key={item.titleKey} className="space-y-1">
<p className="text-sm font-medium text-foreground">
{t(item.titleKey, item.titleFallback)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t(item.descriptionKey, item.descriptionFallback)}
</p>
</div>
))}
<p className="text-xs leading-5 text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.guide.audit_only",
"동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.",
)}
</p>
</div>
</div>
)}
</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">
{visibleRecentClientChanges.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>
) : (
visibleRecentClientChanges.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>
<span className="font-semibold">{item.actorName}</span>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.actorId}
</code>
<Badge variant="muted">{item.actionLabel}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail) => (
<Badge
key={`${item.eventId}-${detail.label}`}
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>
);
})
)}
{hasMoreRecentClientChanges ? (
<div className="pt-2 text-center">
<Button
type="button"
variant="outline"
onClick={() =>
setVisibleRecentClientChangesCount((current) =>
Math.min(
current + recentClientChangesBatchSize,
recentClientChangesWithActors.length,
),
)
}
>
{t("ui.common.load_more", "더보기")}
</Button>
</div>
) : null}
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}
onClose={() => setIsRequestModalOpen(false)}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
import {
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
type AuditDetails,
type CommonAuditLog,
} from "../../../../common/core/audit";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
export type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: 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: "상태",
scopes: "스코프",
tenant_access_restricted: "테넌트 접근 제한",
allowed_tenants: "허용 테넌트",
id_token_claims: "커스텀 클레임",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
headless_login_enabled: "헤드리스 로그인",
headless_token_endpoint_auth_method: "헤드리스 인증 방식",
headless_jwks_uri: "헤드리스 JWKS URI",
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);
}
export 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;
}
}
export function buildRecentClientChangeDetails(
action: string,
details: AuditDetails,
) {
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);
}
export function buildRecentClientChanges(
auditLogs: DevAuditLog[],
clients: ClientSummary[],
) {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return auditLogs
.map((item) => {
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,
actorId: resolveAuditActor(
item as Pick<CommonAuditLog, "user_id">,
details,
),
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange;
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}

View File

@@ -544,6 +544,10 @@ new_client = "Configure redirect URIs, grant types, and authentication methods."
empty = "Review the relying parties this account can access."
none = "No connected applications to display."
[msg.dev.dashboard.recent_changes]
description = "Review trends for changed or deleted applications on the dashboard."
empty = "There are no recent change logs yet."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1741,6 +1745,20 @@ title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.recent_changes]
deleted_group = "Deleted RPs"
aria = "Recent application changes"
period = "Recent change aggregation period"
series = "Changes {{changes}} / Actors {{actors}}"
title = "Recently Changed Applications"
y_axis = "Y axis: change count"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "Changed apps"
deleted_clients = "Deleted RPs"
latest_change = "Latest change"
total_changes = "Recent changes"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -544,6 +544,10 @@ new_client = "redirect URI, grant type, 인증 방식을 설정합니다."
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.recent_changes]
description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다."
empty = "최근 변경 로그가 아직 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1740,6 +1744,20 @@ title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.recent_changes]
deleted_group = "삭제된 RP"
aria = "최근 변경된 앱 현황"
period = "최근 변경 집계 단위"
series = "변경 {{changes}} / 작업자 {{actors}}"
title = "최근 변경된 앱"
y_axis = "Y축: 변경 수"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "변경된 앱 수"
deleted_clients = "삭제된 RP 수"
latest_change = "마지막 변경일"
total_changes = "최근 변경 건수"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -582,6 +582,10 @@ new_client = ""
empty = ""
none = ""
[msg.dev.dashboard.recent_changes]
description = ""
empty = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1797,6 +1801,20 @@ title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.recent_changes]
deleted_group = ""
aria = ""
period = ""
series = ""
title = ""
y_axis = ""
[ui.dev.dashboard.recent_changes.summary]
changed_clients = ""
deleted_clients = ""
latest_change = ""
total_changes = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""

View File

@@ -47,7 +47,7 @@ test("clients page loads correctly", async ({ page }) => {
).toBeVisible();
});
test("clients page shows recent RP changes", async ({ page }) => {
test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
@@ -89,7 +89,7 @@ test("clients page shows recent RP changes", async ({ page }) => {
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -153,7 +153,7 @@ test("clients page shows only five apps by default and expands with more button"
).toHaveCount(0);
});
test("clients page shows user-delete relation cleanup in recent changes", async ({
test("overview page shows user-delete relation cleanup in recent changes", async ({
page,
}) => {
await seedAuth(page, "super_admin");
@@ -195,7 +195,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -210,7 +210,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async
).toBeVisible();
});
test("clients page expands recent changes with more button", async ({
test("clients page no longer shows recent changes card", async ({
page,
}) => {
await seedAuth(page, "super_admin");
@@ -246,23 +246,8 @@ test("clients page expands recent changes with more button", async ({
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByRole("link", { name: "Recent App 1", exact: true }),
page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 5", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", { name: "더 보기" });
await expect(moreButton).toBeVisible();
await moreButton.click();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).toBeVisible();
await expect(moreButton).toHaveCount(0);
});