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 {
|
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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())
|
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{
|
return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{
|
||||||
Object: clientID,
|
Object: clientID,
|
||||||
Relation: req.Relation,
|
Relation: req.Relation,
|
||||||
@@ -1411,6 +1422,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
|
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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())
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ import {
|
|||||||
commonTableViewportClass,
|
commonTableViewportClass,
|
||||||
} from "../../../../common/ui/table";
|
} from "../../../../common/ui/table";
|
||||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "../../components/ui/avatar";
|
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +40,6 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import { Separator } from "../../components/ui/separator";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -57,6 +51,7 @@ import {
|
|||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
type ClientSummary,
|
type ClientSummary,
|
||||||
|
type DevAuditLog,
|
||||||
fetchClients,
|
fetchClients,
|
||||||
fetchDeveloperRequestStatus,
|
fetchDeveloperRequestStatus,
|
||||||
fetchDevStats,
|
fetchDevStats,
|
||||||
@@ -69,9 +64,143 @@ import { cn } from "../../lib/utils";
|
|||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
||||||
import { ClientLogo } from "./components/ClientLogo";
|
import { ClientLogo } from "./components/ClientLogo";
|
||||||
|
import {
|
||||||
|
formatAuditDateParts,
|
||||||
|
formatAuditValue,
|
||||||
|
parseAuditDetails,
|
||||||
|
} from "../../../../common/core/audit";
|
||||||
|
|
||||||
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
|
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() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -141,6 +270,61 @@ function ClientsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clients = data?.items || [];
|
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<
|
const clientSortResolvers = useMemo<
|
||||||
SortResolverMap<ClientSummary, ClientSortKey>
|
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 =
|
const isLoading =
|
||||||
isLoadingClients ||
|
isLoadingClients ||
|
||||||
isLoadingStats ||
|
isLoadingStats ||
|
||||||
|
isLoadingRecentAudit ||
|
||||||
isLoadingRequest ||
|
isLoadingRequest ||
|
||||||
(hasAccessToken && !profileRole && isLoadingMe);
|
(hasAccessToken && !profileRole && isLoadingMe);
|
||||||
|
|
||||||
@@ -706,82 +921,92 @@ function ClientsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
|
||||||
<CardTitle className="text-lg font-bold">
|
<div>
|
||||||
{t(
|
<CardTitle className="text-xl font-semibold">
|
||||||
"ui.dev.clients.help.title",
|
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
|
||||||
"Need help with OIDC configuration?",
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.help.subtitle",
|
"msg.dev.clients.recent_changes.description",
|
||||||
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
|
||||||
|
{ count: recentChangedClientCount },
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||||
<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(
|
{t(
|
||||||
"msg.dev.clients.help.docs_body",
|
"msg.dev.clients.recent_changes.permission_note",
|
||||||
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Button variant="secondary">
|
<Link to="/audit-logs">
|
||||||
{t("ui.dev.clients.help.view_guides", "View guides")}
|
{t("ui.common.audit.title", "Audit Logs")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center justify-between">
|
<CardContent className="space-y-3 pt-0">
|
||||||
<div className="flex items-center gap-3">
|
{recentClientChanges.length === 0 ? (
|
||||||
<Avatar>
|
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5 text-sm text-muted-foreground">
|
||||||
<AvatarImage
|
{t(
|
||||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
"msg.dev.clients.recent_changes.empty",
|
||||||
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>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
{item.detailLabels.length > 0 ? (
|
||||||
<span>
|
item.detailLabels.map((detail, index) => (
|
||||||
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
|
<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>
|
</span>
|
||||||
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
|
)}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<RequestAccessModal
|
<RequestAccessModal
|
||||||
isOpen={isRequestModalOpen}
|
isOpen={isRequestModalOpen}
|
||||||
|
|||||||
@@ -1365,6 +1365,15 @@ search_placeholder = "Search by app name or ID..."
|
|||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
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]
|
[ui.dev.clients.badge]
|
||||||
admin_session = "Admin Session"
|
admin_session = "Admin Session"
|
||||||
dev_session = "DevFront 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"
|
label = "Status Change"
|
||||||
description = "Change the active or inactive state of the RP."
|
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]
|
[ui.dev.clients.list]
|
||||||
title = "Connected Applications"
|
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]
|
[ui.dev.clients.registry]
|
||||||
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
|
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
|
||||||
subtitle = "Applications"
|
subtitle = "Applications"
|
||||||
|
|||||||
@@ -1365,6 +1365,15 @@ search_placeholder = "연동 앱 이름/ID로 검색..."
|
|||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
untitled = "Untitled"
|
||||||
|
|
||||||
|
[ui.dev.clients.recent_changes]
|
||||||
|
title = "최근 변경된 앱"
|
||||||
|
|
||||||
|
[msg.dev.clients.recent_changes]
|
||||||
|
description = "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다."
|
||||||
|
permission_note = "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다."
|
||||||
|
empty = "최근 변경 로그가 아직 없습니다."
|
||||||
|
no_detail = "변경 항목을 확인할 수 없습니다."
|
||||||
|
|
||||||
[ui.dev.clients.badge]
|
[ui.dev.clients.badge]
|
||||||
admin_session = "관리자 세션"
|
admin_session = "관리자 세션"
|
||||||
dev_session = "DevFront 세션"
|
dev_session = "DevFront 세션"
|
||||||
@@ -1632,25 +1641,9 @@ permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에
|
|||||||
label = "상태 변경"
|
label = "상태 변경"
|
||||||
description = "RP 활성/비활성 상태를 변경합니다."
|
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]
|
[ui.dev.clients.list]
|
||||||
title = "연동 앱 목록"
|
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]
|
[ui.dev.clients.registry]
|
||||||
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||||
subtitle = "연동 앱"
|
subtitle = "연동 앱"
|
||||||
|
|||||||
@@ -1421,6 +1421,15 @@ search_placeholder = ""
|
|||||||
tenant_scoped = ""
|
tenant_scoped = ""
|
||||||
untitled = ""
|
untitled = ""
|
||||||
|
|
||||||
|
[ui.dev.clients.recent_changes]
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[msg.dev.clients.recent_changes]
|
||||||
|
description = ""
|
||||||
|
permission_note = ""
|
||||||
|
empty = ""
|
||||||
|
no_detail = ""
|
||||||
|
|
||||||
[ui.dev.clients.badge]
|
[ui.dev.clients.badge]
|
||||||
admin_session = ""
|
admin_session = ""
|
||||||
dev_session = ""
|
dev_session = ""
|
||||||
@@ -1689,25 +1698,9 @@ label = ""
|
|||||||
description = ""
|
description = ""
|
||||||
permits_info = ""
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
|
||||||
docs_body = ""
|
|
||||||
docs_title = ""
|
|
||||||
subtitle = ""
|
|
||||||
title = ""
|
|
||||||
view_guides = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
|
||||||
avatar_alt = ""
|
|
||||||
email = ""
|
|
||||||
name = ""
|
|
||||||
role = ""
|
|
||||||
scope = ""
|
|
||||||
subtitle = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.registry]
|
[ui.dev.clients.registry]
|
||||||
description = ""
|
description = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
|
type AuditLog,
|
||||||
type Consent,
|
type Consent,
|
||||||
installDevApiMock,
|
installDevApiMock,
|
||||||
makeClient,
|
makeClient,
|
||||||
@@ -14,7 +15,7 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clients page loads correctly", async ({ page }) => {
|
test("clients page loads correctly", async ({ page }) => {
|
||||||
await seedAuth(page);
|
await seedAuth(page, "super_admin");
|
||||||
await installDevApiMock(page, {
|
await installDevApiMock(page, {
|
||||||
clients: [
|
clients: [
|
||||||
makeClient("client-playwright", {
|
makeClient("client-playwright", {
|
||||||
@@ -44,3 +45,52 @@ test("clients page loads correctly", async ({ page }) => {
|
|||||||
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
|
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
|
||||||
).toBeVisible();
|
).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