forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -51,14 +51,17 @@ ensure_frontend_dependencies() {
|
||||
if [ -n "$WORKSPACE_ROOT" ]; then
|
||||
WORKSPACE_DIR="$WORKSPACE_ROOT"
|
||||
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
|
||||
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="pnpm-lock.yaml"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
|
||||
else
|
||||
WORKSPACE_DIR="."
|
||||
LOCK_FILE="package-lock.json"
|
||||
COMMON_PACKAGE_FILE="/workspace/common/package.json"
|
||||
INSTALL_CMD="npm ci"
|
||||
fi
|
||||
|
||||
@@ -100,9 +103,9 @@ ensure_frontend_dependencies() {
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
deps_stamp="node_modules/.baron-deps-hash"
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
@@ -111,9 +114,9 @@ ensure_frontend_dependencies() {
|
||||
echo "Installing frontend dependencies..."
|
||||
acquire_install_lock
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
|
||||
else
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
|
||||
fi
|
||||
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
|
||||
if [ "$installed_hash" = "$deps_hash" ]; then
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Edit3,
|
||||
Filter,
|
||||
Save,
|
||||
Search,
|
||||
ShieldHalf,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
@@ -34,11 +37,208 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import {
|
||||
fetchClient,
|
||||
fetchConsents,
|
||||
fetchRPUserMetadata,
|
||||
revokeConsent,
|
||||
updateRPUserMetadata,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
type RPClaimValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "date"
|
||||
| "datetime";
|
||||
type CustomClaimPermission = "admin_only" | "user_and_admin";
|
||||
|
||||
type RPClaimSchema = {
|
||||
key: string;
|
||||
value: string;
|
||||
valueType: RPClaimValueType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
};
|
||||
|
||||
type MetadataDraftRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
valueType: RPClaimValueType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
schemaBacked: boolean;
|
||||
};
|
||||
|
||||
function isCustomClaimPermission(
|
||||
value: unknown,
|
||||
): value is CustomClaimPermission {
|
||||
return value === "admin_only" || value === "user_and_admin";
|
||||
}
|
||||
|
||||
function readPermissionMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
fallback: CustomClaimPermission,
|
||||
field: "readPermission" | "writePermission",
|
||||
) {
|
||||
const raw = metadata?.[`${key}_permissions`];
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = (raw as Record<string, unknown>)[field];
|
||||
return isCustomClaimPermission(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function metadataToDraftRows(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
schemas: RPClaimSchema[],
|
||||
): MetadataDraftRow[] {
|
||||
if (schemas.length > 0) {
|
||||
return schemas.map((schema) => ({
|
||||
id: `rp-claim-${schema.key}`,
|
||||
key: schema.key,
|
||||
value: metadataValueToInputString(
|
||||
metadata?.[schema.key],
|
||||
schema.value,
|
||||
schema.valueType,
|
||||
),
|
||||
valueType: schema.valueType,
|
||||
readPermission: readPermissionMetadata(
|
||||
metadata,
|
||||
schema.key,
|
||||
schema.readPermission,
|
||||
"readPermission",
|
||||
),
|
||||
writePermission: readPermissionMetadata(
|
||||
metadata,
|
||||
schema.key,
|
||||
schema.writePermission,
|
||||
"writePermission",
|
||||
),
|
||||
schemaBacked: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return Object.entries(metadata ?? {})
|
||||
.filter(([key]) => !key.endsWith("_permissions"))
|
||||
.map(([key, value], index) => ({
|
||||
id: `${key}-${index}`,
|
||||
key,
|
||||
value: metadataValueToString(value, ""),
|
||||
valueType: "text",
|
||||
readPermission: readPermissionMetadata(
|
||||
metadata,
|
||||
key,
|
||||
"admin_only",
|
||||
"readPermission",
|
||||
),
|
||||
writePermission: readPermissionMetadata(
|
||||
metadata,
|
||||
key,
|
||||
"admin_only",
|
||||
"writePermission",
|
||||
),
|
||||
schemaBacked: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function draftRowsToMetadata(rows: MetadataDraftRow[]) {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim();
|
||||
if (!key) continue;
|
||||
metadata[key] = row.value.trim();
|
||||
metadata[`${key}_permissions`] = {
|
||||
readPermission: row.readPermission,
|
||||
writePermission: row.writePermission,
|
||||
};
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function isRPClaimValueType(value: string): value is RPClaimValueType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "array" ||
|
||||
value === "object" ||
|
||||
value === "date" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
function metadataValueToString(value: unknown, fallback: string) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function metadataValueToInputString(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
valueType: RPClaimValueType,
|
||||
) {
|
||||
const text = metadataValueToString(value, fallback);
|
||||
if (valueType === "date") {
|
||||
return text.slice(0, 10);
|
||||
}
|
||||
if (valueType === "datetime") {
|
||||
return text.slice(0, 16);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function readRPClaimSchemas(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): RPClaimSchema[] {
|
||||
const rawClaims = metadata?.id_token_claims;
|
||||
if (!Array.isArray(rawClaims)) return [];
|
||||
|
||||
return rawClaims
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.namespace !== "rp_claims") return null;
|
||||
const key = typeof record.key === "string" ? record.key.trim() : "";
|
||||
if (!key) return null;
|
||||
const rawValueType =
|
||||
typeof record.valueType === "string" ? record.valueType : "text";
|
||||
const valueType = isRPClaimValueType(rawValueType)
|
||||
? rawValueType
|
||||
: "text";
|
||||
return {
|
||||
key,
|
||||
value: metadataValueToString(record.value, ""),
|
||||
valueType,
|
||||
readPermission: isCustomClaimPermission(record.readPermission)
|
||||
? record.readPermission
|
||||
: "admin_only",
|
||||
writePermission: isCustomClaimPermission(record.writePermission)
|
||||
? record.writePermission
|
||||
: "admin_only",
|
||||
};
|
||||
})
|
||||
.filter((item): item is RPClaimSchema => item !== null);
|
||||
}
|
||||
|
||||
function rpClaimInputType(valueType: RPClaimValueType) {
|
||||
if (valueType === "date") return "date";
|
||||
if (valueType === "datetime") return "datetime-local";
|
||||
if (valueType === "number") return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function ClientConsentsPage() {
|
||||
const params = useParams();
|
||||
const clientId = params.id ?? "";
|
||||
@@ -47,12 +247,20 @@ function ClientConsentsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
const [editingSubject, setEditingSubject] = useState("");
|
||||
const [metadataDraftRows, setMetadataDraftRows] = useState<
|
||||
MetadataDraftRow[]
|
||||
>([]);
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
const rpClaimSchemas = useMemo(
|
||||
() => readRPClaimSchemas(clientData?.client.metadata),
|
||||
[clientData?.client.metadata],
|
||||
);
|
||||
const {
|
||||
data: consentsData,
|
||||
isLoading,
|
||||
@@ -63,6 +271,7 @@ function ClientConsentsPage() {
|
||||
queryFn: () => fetchConsents(subject, clientId, "all"),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
const rows = consentsData?.items ?? [];
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (payload: { subject: string }) =>
|
||||
revokeConsent(payload.subject, clientId),
|
||||
@@ -70,6 +279,34 @@ function ClientConsentsPage() {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
const selectedRow =
|
||||
rows.find((row) => row.subject === editingSubject) ?? undefined;
|
||||
const metadataQuery = useQuery({
|
||||
queryKey: ["rp-user-metadata", clientId, editingSubject],
|
||||
queryFn: () => fetchRPUserMetadata(clientId, editingSubject),
|
||||
enabled: clientId.length > 0 && editingSubject.length > 0,
|
||||
});
|
||||
const metadataMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateRPUserMetadata(
|
||||
clientId,
|
||||
editingSubject,
|
||||
draftRowsToMetadata(metadataDraftRows),
|
||||
),
|
||||
onSuccess: () => {
|
||||
setEditingSubject("");
|
||||
setMetadataDraftRows([]);
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataQuery.data) {
|
||||
setMetadataDraftRows(
|
||||
metadataToDraftRows(metadataQuery.data.metadata, rpClaimSchemas),
|
||||
);
|
||||
}
|
||||
}, [metadataQuery.data, rpClaimSchemas]);
|
||||
|
||||
const handleRevoke = (sub: string) => {
|
||||
if (
|
||||
@@ -84,7 +321,6 @@ function ClientConsentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const rows = consentsData?.items ?? [];
|
||||
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
|
||||
const filteredRows = rows.filter((row) => {
|
||||
const matchStatus =
|
||||
@@ -169,6 +405,39 @@ function ClientConsentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openMetadataEditor = (row: (typeof rows)[number]) => {
|
||||
setEditingSubject(row.subject);
|
||||
setMetadataDraftRows(metadataToDraftRows(row.rpMetadata, rpClaimSchemas));
|
||||
};
|
||||
|
||||
const updateMetadataDraftRow = (
|
||||
id: string,
|
||||
patch: Partial<MetadataDraftRow>,
|
||||
) => {
|
||||
setMetadataDraftRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const addMetadataDraftRow = () => {
|
||||
setMetadataDraftRows((current) => [
|
||||
...current,
|
||||
{
|
||||
id: `rp-metadata-${Date.now()}`,
|
||||
key: "",
|
||||
value: "",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
schemaBacked: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeMetadataDraftRow = (id: string) => {
|
||||
setMetadataDraftRows((current) => current.filter((row) => row.id !== id));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
const axiosError = error as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
@@ -450,6 +719,9 @@ function ClientConsentsPage() {
|
||||
"Last Authenticated / Revoked",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.consents.table.rp_claims", "RP Claims")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.consents.table.action", "Action")}
|
||||
</TableHead>
|
||||
@@ -459,7 +731,7 @@ function ClientConsentsPage() {
|
||||
{filteredRows.length === 0 && !isLoading && !error ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -552,17 +824,57 @@ function ClientConsentsPage() {
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.rpMetadata &&
|
||||
Object.keys(row.rpMetadata).some(
|
||||
(key) => !key.endsWith("_permissions"),
|
||||
) ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(row.rpMetadata)
|
||||
.filter(([key]) => !key.endsWith("_permissions"))
|
||||
.map(([key, value]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="muted"
|
||||
className="border bg-muted/40 font-mono text-[11px] text-foreground"
|
||||
>
|
||||
{key}:{" "}
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("ui.common.na", "N/A")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === "active" && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRevoke(row.subject)}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="gap-2"
|
||||
onClick={() => openMetadataEditor(row)}
|
||||
>
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
<Edit3 className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.rp_claims.edit",
|
||||
"사용자 Claim 설정",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{row.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRevoke(row.subject)}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{t("ui.dev.clients.consents.revoke", "Revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -597,6 +909,182 @@ function ClientConsentsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{editingSubject && (
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{t(
|
||||
"ui.dev.clients.consents.rp_claims.title",
|
||||
"RP Custom Claims",
|
||||
)}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{selectedRow?.userName || editingSubject}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{rpClaimSchemas.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={addMetadataDraftRow}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setEditingSubject("");
|
||||
setMetadataDraftRows([]);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={metadataMutation.isPending}
|
||||
onClick={() => metadataMutation.mutate()}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.rp_claims.save", "Claim 저장")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{metadataQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중...")}
|
||||
</p>
|
||||
) : metadataDraftRows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.consents.rp_claims.empty",
|
||||
"등록된 RP custom claim 값이 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
metadataDraftRows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
|
||||
>
|
||||
{row.schemaBacked ? (
|
||||
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
||||
{row.key}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
key: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.rp_claims.key_placeholder",
|
||||
"claim_key",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.rp_claims.value_placeholder",
|
||||
"claim value",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
value={row.readPermission}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
readPermission: event.target
|
||||
.value as CustomClaimPermission,
|
||||
})
|
||||
}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t(
|
||||
"ui.dev.clients.consents.rp_claims.read_permission",
|
||||
"읽기 권한",
|
||||
)}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
value={row.writePermission}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
writePermission: event.target
|
||||
.value as CustomClaimPermission,
|
||||
})
|
||||
}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t(
|
||||
"ui.dev.clients.consents.rp_claims.write_permission",
|
||||
"쓰기 권한",
|
||||
)}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
{row.schemaBacked ? (
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
|
||||
>
|
||||
{row.valueType}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMetadataDraftRow(row.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
29
devfront/src/features/clients/ClientDetailTabs.test.tsx
Normal file
29
devfront/src/features/clients/ClientDetailTabs.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string) =>
|
||||
({
|
||||
"ui.dev.clients.details.tab.connection": "연동 설정",
|
||||
"ui.dev.clients.details.tab.user_claims": "사용자 Claim",
|
||||
"ui.dev.clients.details.tab.settings": "설정",
|
||||
"ui.dev.clients.details.tab.relationships": "관계",
|
||||
})[key] ??
|
||||
fallback ??
|
||||
key,
|
||||
}));
|
||||
|
||||
describe("ClientDetailTabs", () => {
|
||||
it("exposes the RP user custom claim screen as a first-class tab", () => {
|
||||
const html = renderToString(
|
||||
<MemoryRouter>
|
||||
<ClientDetailTabs activeTab="connection" clientId="client-a" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(html).toContain("사용자 Claim");
|
||||
expect(html).toContain('href="/clients/client-a/consents"');
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,14 @@ interface ClientDetailTabsProps {
|
||||
const tabOrder: Array<{
|
||||
key: ClientDetailTab;
|
||||
href: (clientId: string) => string;
|
||||
labelKey?: string;
|
||||
}> = [
|
||||
{ key: "connection", href: (clientId) => `/clients/${clientId}` },
|
||||
{ key: "consents", href: (clientId) => `/clients/${clientId}/consents` },
|
||||
{
|
||||
key: "consents",
|
||||
href: (clientId) => `/clients/${clientId}/consents`,
|
||||
labelKey: "ui.dev.clients.details.tab.user_claims",
|
||||
},
|
||||
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
|
||||
{
|
||||
key: "relationships",
|
||||
@@ -30,12 +35,13 @@ export function ClientDetailTabs({
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{tabOrder.map((tab) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
const labelKey = tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
|
||||
return isActive ? (
|
||||
<span
|
||||
key={tab.key}
|
||||
className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary"
|
||||
>
|
||||
{t(`ui.dev.clients.details.tab.${tab.key}`)}
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
@@ -45,7 +51,7 @@ export function ClientDetailTabs({
|
||||
"whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ui.dev.clients.details.tab.${tab.key}`)}
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Plus,
|
||||
@@ -56,6 +55,7 @@ import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
||||
|
||||
interface ScopeItem {
|
||||
id: string;
|
||||
@@ -65,8 +65,16 @@ interface ScopeItem {
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
type ClaimNamespace = "top_level" | "rp_claims";
|
||||
type ClaimValueType = "text" | "number" | "boolean" | "array" | "object";
|
||||
type ClaimNamespace = "rp_claims";
|
||||
type ClaimValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "date"
|
||||
| "datetime";
|
||||
type CustomClaimPermission = "admin_only" | "user_and_admin";
|
||||
|
||||
interface IdTokenClaimItem {
|
||||
id: string;
|
||||
@@ -74,6 +82,8 @@ interface IdTokenClaimItem {
|
||||
key: string;
|
||||
value: string;
|
||||
valueType: ClaimValueType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
}
|
||||
|
||||
type SecurityProfile = "private" | "pkce";
|
||||
@@ -129,7 +139,7 @@ function readMetadataString(
|
||||
}
|
||||
|
||||
function isClaimNamespace(value: string): value is ClaimNamespace {
|
||||
return value === "top_level" || value === "rp_claims";
|
||||
return value === "rp_claims";
|
||||
}
|
||||
|
||||
function isClaimValueType(value: string): value is ClaimValueType {
|
||||
@@ -138,17 +148,27 @@ function isClaimValueType(value: string): value is ClaimValueType {
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "array" ||
|
||||
value === "object"
|
||||
value === "object" ||
|
||||
value === "date" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
function isCustomClaimPermission(
|
||||
value: unknown,
|
||||
): value is CustomClaimPermission {
|
||||
return value === "admin_only" || value === "user_and_admin";
|
||||
}
|
||||
|
||||
function createIdTokenClaimItem(id: string): IdTokenClaimItem {
|
||||
return {
|
||||
id,
|
||||
namespace: "top_level",
|
||||
namespace: "rp_claims",
|
||||
key: "",
|
||||
value: "",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,7 +191,10 @@ function readIdTokenClaimsMetadata(
|
||||
typeof record.namespace === "string" &&
|
||||
isClaimNamespace(record.namespace)
|
||||
? record.namespace
|
||||
: "top_level";
|
||||
: null;
|
||||
if (namespaceValue === null) {
|
||||
return null;
|
||||
}
|
||||
const keyValue = typeof record.key === "string" ? record.key : "";
|
||||
const rawValue = record.value;
|
||||
const valueValue =
|
||||
@@ -192,6 +215,12 @@ function readIdTokenClaimsMetadata(
|
||||
key: keyValue,
|
||||
value: valueValue,
|
||||
valueType: valueTypeValue,
|
||||
readPermission: isCustomClaimPermission(record.readPermission)
|
||||
? record.readPermission
|
||||
: "admin_only",
|
||||
writePermission: isCustomClaimPermission(record.writePermission)
|
||||
? record.writePermission
|
||||
: "admin_only",
|
||||
};
|
||||
})
|
||||
.filter((item): item is IdTokenClaimItem => item !== null);
|
||||
@@ -253,8 +282,7 @@ function buildIdTokenClaimsPreview(
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = item.namespace === "rp_claims" ? rpClaims : preview;
|
||||
target[key] = normalizeClaimPreviewValue(item.value, item.valueType);
|
||||
rpClaims[key] = normalizeClaimPreviewValue(item.value, item.valueType);
|
||||
}
|
||||
|
||||
if (Object.keys(rpClaims).length > 0) {
|
||||
@@ -841,16 +869,6 @@ function ClientGeneralPage() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (claim.key === "rp_claims" && claim.namespace === "top_level") {
|
||||
claimValidationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.reserved_key",
|
||||
"`rp_claims`는 예약된 namespace 키입니다.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const keySignature = `${claim.namespace}:${claim.key}`;
|
||||
if (seenClaimKeys.has(keySignature)) {
|
||||
claimValidationErrors.push(
|
||||
@@ -858,16 +876,10 @@ function ClientGeneralPage() {
|
||||
"msg.dev.clients.general.id_token_claims.duplicate_key",
|
||||
"중복된 claim key가 있습니다: {{namespace}}.{{key}}",
|
||||
{
|
||||
namespace:
|
||||
claim.namespace === "rp_claims"
|
||||
? t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||||
"top-level",
|
||||
),
|
||||
namespace: t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
),
|
||||
key: claim.key,
|
||||
},
|
||||
),
|
||||
@@ -1951,26 +1963,12 @@ function ClientGeneralPage() {
|
||||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAllowedTenants.map((tenant) => (
|
||||
<Badge
|
||||
<AllowedTenantBadge
|
||||
key={tenant.id}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
tenant={tenant}
|
||||
onRemove={() => toggleAllowedTenant(tenant.id)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
))}
|
||||
{allowedTenantIds
|
||||
.filter(
|
||||
@@ -1980,23 +1978,12 @@ function ClientGeneralPage() {
|
||||
),
|
||||
)
|
||||
.map((tenantId) => (
|
||||
<Badge
|
||||
<AllowedTenantBadge
|
||||
key={tenantId}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenantId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenantId)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
tenant={{ id: tenantId, name: tenantId }}
|
||||
onRemove={() => toggleAllowedTenant(tenantId)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -2070,6 +2057,18 @@ function ClientGeneralPage() {
|
||||
"Value Type",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.read_permission",
|
||||
"Read",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.write_permission",
|
||||
"Write",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.value",
|
||||
@@ -2106,35 +2105,15 @@ function ClientGeneralPage() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.namespace}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"namespace",
|
||||
e.target.value as ClaimNamespace,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_label",
|
||||
"Claim namespace",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
|
||||
>
|
||||
<option value="top_level">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_top_level",
|
||||
"top-level",
|
||||
)}
|
||||
</option>
|
||||
<option value="rp_claims">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
|
||||
"rp_claims",
|
||||
)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
@@ -2183,6 +2162,80 @@ function ClientGeneralPage() {
|
||||
"Object",
|
||||
)}
|
||||
</option>
|
||||
<option value="date">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_date",
|
||||
"Date",
|
||||
)}
|
||||
</option>
|
||||
<option value="datetime">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.value_type_datetime",
|
||||
"Datetime",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.readPermission}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"readPermission",
|
||||
e.target.value as CustomClaimPermission,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.read_permission_label",
|
||||
"읽기 권한",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.writePermission}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"writePermission",
|
||||
e.target.value as CustomClaimPermission,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.write_permission_label",
|
||||
"쓰기 권한",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
@@ -2219,7 +2272,7 @@ function ClientGeneralPage() {
|
||||
{idTokenClaims.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
colSpan={7}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -2235,7 +2288,7 @@ function ClientGeneralPage() {
|
||||
<p className="text-xs leading-6 text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.id_token_claims.hint",
|
||||
"top-level은 일반 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
|
||||
"RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AllowedTenantBadge } from "./AllowedTenantBadge";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderAllowedTenantBadge() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<AllowedTenantBadge
|
||||
tenant={{
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
}}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AllowedTenantBadge", () => {
|
||||
it("renders tenant name, slug, full UUID, and copies the UUID", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const container = renderAllowedTenantBadge();
|
||||
const badge = container.querySelector(
|
||||
'[data-testid="allowed-tenant-11111111-2222-3333-4444-555555555555"]',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("한맥기술");
|
||||
expect(container.textContent).toContain("hanmac");
|
||||
expect(container.textContent).toContain(
|
||||
"11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
const copyButton = container.querySelector(
|
||||
'[data-testid="allowed-tenant-copy-11111111-2222-3333-4444-555555555555"]',
|
||||
);
|
||||
expect(copyButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { CopyButton } from "../../../components/ui/copy-button";
|
||||
import type { MyTenantSummary, TenantSummary } from "../../../lib/devApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type AllowedTenant = Pick<TenantSummary | MyTenantSummary, "id" | "name"> & {
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
type AllowedTenantBadgeProps = {
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
tenant: AllowedTenant;
|
||||
};
|
||||
|
||||
export function AllowedTenantBadge({
|
||||
disabled = false,
|
||||
onRemove,
|
||||
tenant,
|
||||
}: AllowedTenantBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex max-w-full items-center gap-2 px-3 py-1.5"
|
||||
data-testid={`allowed-tenant-${tenant.id}`}
|
||||
data-tenant-id={tenant.id}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||||
{tenant.slug ? (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="max-w-64 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{tenant.id}
|
||||
</span>
|
||||
<CopyButton
|
||||
aria-label="테넌트 UUID 복사"
|
||||
className="h-6 w-6 shrink-0"
|
||||
data-testid={`allowed-tenant-copy-${tenant.id}`}
|
||||
size="icon"
|
||||
value={tenant.id}
|
||||
variant="ghost"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground transition hover:text-destructive"
|
||||
data-testid={`allowed-tenant-remove-${tenant.id}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ const clientSummary = {
|
||||
key: "employee_id",
|
||||
value: "E001",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -171,6 +173,18 @@ vi.mock("../../lib/devApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchRPUserMetadata: vi.fn(async () => ({
|
||||
clientId: "client-a",
|
||||
userId: "user-1",
|
||||
metadata: {
|
||||
approvalLevel: "A",
|
||||
},
|
||||
})),
|
||||
updateRPUserMetadata: vi.fn(async (_clientId: string, userId: string, metadata: Record<string, unknown>) => ({
|
||||
clientId: "client-a",
|
||||
userId,
|
||||
metadata,
|
||||
})),
|
||||
fetchDevUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
@@ -208,6 +222,10 @@ vi.mock("../../lib/devApi", () => ({
|
||||
status: "active",
|
||||
tenantId: "tenant-1",
|
||||
tenantName: "Hanmac",
|
||||
rpMetadata: {
|
||||
approvalLevel: "A",
|
||||
reviewedAt: "2026-06-09T09:30:00+09:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
@@ -406,12 +424,18 @@ describe("devfront coverage smoke pages", () => {
|
||||
entry: "/clients/client-a/settings",
|
||||
});
|
||||
expect(settings.textContent).toContain("Console App");
|
||||
expect(settings.textContent).not.toContain("top-level");
|
||||
expect(settings.textContent).toContain("Date");
|
||||
expect(settings.textContent).toContain("Datetime");
|
||||
expect(settings.textContent).toContain("관리자만 가능");
|
||||
|
||||
const consents = await renderPage(<ClientConsentsPage />, {
|
||||
path: "/clients/:id/consents",
|
||||
entry: "/clients/client-a/consents",
|
||||
});
|
||||
expect(consents.textContent).toContain("Consent User");
|
||||
expect(consents.textContent).toContain("approvalLevel");
|
||||
expect(consents.textContent).toContain("A");
|
||||
|
||||
const federation = await renderPage(<ClientFederationPage />, {
|
||||
path: "/clients/:id/federation",
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("devApi", () => {
|
||||
fetchTenants,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchRPUserMetadata,
|
||||
fetchDevUsers,
|
||||
fetchConsents,
|
||||
fetchDevAuditLogs,
|
||||
@@ -45,6 +46,7 @@ describe("devApi", () => {
|
||||
await fetchTenants(25, 50, "tenant-parent");
|
||||
await fetchClient("client-a");
|
||||
await fetchClientRelations("client-a");
|
||||
await fetchRPUserMetadata("client-a", "user-a");
|
||||
await fetchDevUsers("admin", 5, "client-a");
|
||||
await fetchConsents("user-a", "client-a", "active");
|
||||
await fetchDevAuditLogs(10, "cursor-a", {
|
||||
@@ -70,6 +72,9 @@ describe("devApi", () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/relations",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/users/user-a/metadata",
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
|
||||
params: { search: "admin", limit: 5, clientId: "client-a" },
|
||||
});
|
||||
@@ -119,6 +124,7 @@ describe("devApi", () => {
|
||||
const {
|
||||
addClientRelation,
|
||||
removeClientRelation,
|
||||
updateRPUserMetadata,
|
||||
updateClientStatus,
|
||||
createClient,
|
||||
updateClient,
|
||||
@@ -145,6 +151,7 @@ describe("devApi", () => {
|
||||
userId: "user-a",
|
||||
});
|
||||
await removeClientRelation("client-a", "admins", "User:user-a");
|
||||
await updateRPUserMetadata("client-a", "user-a", { approvalLevel: "A" });
|
||||
await updateClientStatus("client-a", "inactive");
|
||||
await createClient({ id: "client-a", name: "Console App" });
|
||||
await updateClient("client-a", { name: "Console App Updated" });
|
||||
@@ -181,6 +188,10 @@ describe("devApi", () => {
|
||||
params: { relation: "admins", subject: "User:user-a" },
|
||||
},
|
||||
);
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/users/user-a/metadata",
|
||||
{ metadata: { approvalLevel: "A" } },
|
||||
);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||
"/dev/clients/client-a/status",
|
||||
{
|
||||
|
||||
@@ -210,12 +210,19 @@ export type ConsentSummary = {
|
||||
status: "active" | "revoked";
|
||||
tenantId?: string;
|
||||
tenantName?: string;
|
||||
rpMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ConsentListResponse = {
|
||||
items: ConsentSummary[];
|
||||
};
|
||||
|
||||
export type RPUserMetadataResponse = {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// --- Federation / IdP Config Types ---
|
||||
export type ProviderType = "oidc" | "saml";
|
||||
|
||||
@@ -297,6 +304,25 @@ export async function fetchClientRelations(clientId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRPUserMetadata(clientId: string, userId: string) {
|
||||
const { data } = await apiClient.get<RPUserMetadataResponse>(
|
||||
`/dev/clients/${clientId}/users/${userId}/metadata`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateRPUserMetadata(
|
||||
clientId: string,
|
||||
userId: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) {
|
||||
const { data } = await apiClient.put<RPUserMetadataResponse>(
|
||||
`/dev/clients/${clientId}/users/${userId}/metadata`,
|
||||
{ metadata },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDevUsers(
|
||||
search: string,
|
||||
limit = 10,
|
||||
|
||||
@@ -449,7 +449,7 @@ tenant = "Tenant access claim"
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "Separate shared claims from RP-specific extension claims."
|
||||
empty = "No ID Token claims have been added yet."
|
||||
hint = "Use top-level for shared claims and rp_claims for RP-specific extension claims. Arrays accept JSON or comma-separated values, and objects accept JSON."
|
||||
hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON."
|
||||
preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
|
||||
key_required = "Enter a claim key."
|
||||
reserved_key = "`rp_claims` is a reserved namespace key."
|
||||
@@ -1427,6 +1427,9 @@ status_revoked = "Revoked"
|
||||
subject = "Subject"
|
||||
title = "User Consent Grants"
|
||||
|
||||
[ui.dev.clients.consents.rp_claims]
|
||||
edit = "User Claim Settings"
|
||||
|
||||
[ui.dev.clients.consents.breadcrumb]
|
||||
clients = "Clients"
|
||||
current = "User Consent Grants"
|
||||
@@ -1480,6 +1483,7 @@ connection = "Federation"
|
||||
consents = "Consent & Users"
|
||||
settings = "Settings"
|
||||
relationships = "Relationships"
|
||||
user_claims = "User Claims"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "Create Application"
|
||||
|
||||
@@ -449,7 +449,7 @@ tenant = "소속 테넌트 정보 접근"
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다."
|
||||
empty = "아직 추가된 ID Token claim이 없습니다."
|
||||
hint = "top-level은 공통 claim에, rp_claims는 RP 전용 확장 claim에 사용합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
|
||||
hint = "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다."
|
||||
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
|
||||
key_required = "Claim key를 입력해야 합니다."
|
||||
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
|
||||
@@ -1426,6 +1426,9 @@ status_revoked = "철회됨"
|
||||
subject = "사용자"
|
||||
title = "사용자 동의 내역"
|
||||
|
||||
[ui.dev.clients.consents.rp_claims]
|
||||
edit = "사용자 Claim 설정"
|
||||
|
||||
[ui.dev.clients.consents.breadcrumb]
|
||||
clients = "연동 앱"
|
||||
current = "동의 및 사용자"
|
||||
@@ -1479,6 +1482,7 @@ connection = "연동 설정"
|
||||
consents = "동의 및 사용자"
|
||||
settings = "설정"
|
||||
relationships = "관계"
|
||||
user_claims = "사용자 Claim"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "앱 생성"
|
||||
|
||||
124
devfront/tests/devfront-client-tenant-access.spec.ts
Normal file
124
devfront/tests/devfront-client-tenant-access.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo, test } from "@playwright/test";
|
||||
import {
|
||||
type Consent,
|
||||
installDevApiMock,
|
||||
makeClient,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
const existingTenantId = "11111111-1111-4111-8111-111111111111";
|
||||
const addedTenantId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
async function captureTenantAccessEvidence(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
) {
|
||||
await captureEvidence(page, testInfo, name);
|
||||
const evidenceDir = path.join(process.cwd(), "e2e-evidence");
|
||||
await mkdir(evidenceDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `${name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("DevFront client tenant access settings", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await seedAuth(page);
|
||||
});
|
||||
|
||||
test("adds and removes allowed tenants with UUID copy evidence", async ({
|
||||
context,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
const state = {
|
||||
clients: [
|
||||
makeClient("client-tenant-access", {
|
||||
name: "Tenant Access App",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
metadata: {
|
||||
tenant_access_restricted: true,
|
||||
allowed_tenants: [existingTenantId],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
tenants: [
|
||||
{
|
||||
id: existingTenantId,
|
||||
name: "Alpha Tenant",
|
||||
slug: "alpha",
|
||||
description: "Existing allowed tenant",
|
||||
type: "organization",
|
||||
},
|
||||
{
|
||||
id: addedTenantId,
|
||||
name: "Beta Tenant",
|
||||
slug: "beta",
|
||||
description: "Tenant added during E2E",
|
||||
type: "organization",
|
||||
},
|
||||
],
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-tenant-access/settings");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /테넌트 접근 제한|Tenant access/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`allowed-tenant-${existingTenantId}`),
|
||||
).toContainText(existingTenantId);
|
||||
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(existingTenantId);
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그로 검색|tenant name or slug/i)
|
||||
.fill("beta");
|
||||
await page.getByRole("button", { name: /Beta Tenant/i }).click();
|
||||
await expect(
|
||||
page.getByTestId(`allowed-tenant-${addedTenantId}`),
|
||||
).toContainText(addedTenantId);
|
||||
await captureTenantAccessEvidence(
|
||||
page,
|
||||
testInfo,
|
||||
"tenant-access-allowed-tenant-added",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.allowed_tenants)
|
||||
.toEqual([existingTenantId, addedTenantId]);
|
||||
|
||||
await page.getByTestId(`allowed-tenant-remove-${addedTenantId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`allowed-tenant-${addedTenantId}`),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId(`allowed-tenant-${existingTenantId}`),
|
||||
).toContainText(existingTenantId);
|
||||
await captureTenantAccessEvidence(
|
||||
page,
|
||||
testInfo,
|
||||
"tenant-access-allowed-tenant-deleted",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.allowed_tenants)
|
||||
.toEqual([existingTenantId]);
|
||||
});
|
||||
});
|
||||
@@ -144,29 +144,31 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
|
||||
await page.goto("/clients/client-claims/settings");
|
||||
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
|
||||
await page.getByPlaceholder(/e\.g\. locale|예: locale/i).fill("locale");
|
||||
await expect(page.getByText("rp_claims").first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel(/Claim namespace|Claim 네임스페이스/i),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
|
||||
.first()
|
||||
.selectOption("top_level");
|
||||
.getByPlaceholder(/e\.g\. locale|예: locale/i)
|
||||
.fill("contract_date");
|
||||
await page
|
||||
.getByLabel(/Claim value type|Claim 값 타입/i)
|
||||
.first()
|
||||
.selectOption("text");
|
||||
.selectOption("date");
|
||||
await page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.first()
|
||||
.fill("ko-KR");
|
||||
.fill("2026-06-09");
|
||||
await page
|
||||
.getByLabel(/읽기 권한|Read permission/i)
|
||||
.first()
|
||||
.selectOption("user_and_admin");
|
||||
|
||||
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
|
||||
await page
|
||||
.getByPlaceholder(/e\.g\. locale|예: locale/i)
|
||||
.nth(1)
|
||||
.fill("tier");
|
||||
await page
|
||||
.getByLabel(/Claim namespace|Claim 네임스페이스/i)
|
||||
.nth(1)
|
||||
.selectOption("rp_claims");
|
||||
await page
|
||||
.getByLabel(/Claim value type|Claim 값 타입/i)
|
||||
.nth(1)
|
||||
@@ -210,7 +212,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
| undefined
|
||||
)?.[0]?.namespace,
|
||||
)
|
||||
.toBe("top_level");
|
||||
.toBe("rp_claims");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
@@ -225,7 +227,50 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
| undefined
|
||||
)?.[0]?.key,
|
||||
)
|
||||
.toBe("locale");
|
||||
.toBe("contract_date");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
value?: string;
|
||||
valueType?: string;
|
||||
readPermission?: string;
|
||||
writePermission?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0]?.valueType,
|
||||
)
|
||||
.toBe("date");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
readPermission?: string;
|
||||
writePermission?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0]?.readPermission,
|
||||
)
|
||||
.toBe("user_and_admin");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.id_token_claims as
|
||||
| Array<{
|
||||
readPermission?: string;
|
||||
writePermission?: string;
|
||||
}>
|
||||
| undefined
|
||||
)?.[0]?.writePermission,
|
||||
)
|
||||
.toBe("admin_only");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
@@ -263,7 +308,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByPlaceholder(/e\.g\. locale|예: locale/i).first(),
|
||||
).toHaveValue("locale");
|
||||
).toHaveValue("contract_date");
|
||||
await expect(
|
||||
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
|
||||
).toHaveValue("tier");
|
||||
@@ -274,7 +319,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
.first(),
|
||||
).toHaveValue("ko-KR");
|
||||
).toHaveValue("2026-06-09");
|
||||
await expect(
|
||||
page
|
||||
.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
|
||||
|
||||
@@ -23,7 +23,27 @@ test.describe("DevFront consents", () => {
|
||||
|
||||
test("consent list and revoke flow", async ({ page }) => {
|
||||
const state = {
|
||||
clients: [makeClient("client-consent", { name: "Consent app" })],
|
||||
clients: [
|
||||
makeClient("client-consent", {
|
||||
name: "Consent app",
|
||||
metadata: {
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "contract_date",
|
||||
valueType: "date",
|
||||
value: "2026-06-09",
|
||||
},
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: "approved_at",
|
||||
valueType: "datetime",
|
||||
value: "2026-06-09T09:30",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [
|
||||
{
|
||||
subject: "user-1",
|
||||
@@ -36,6 +56,13 @@ test.describe("DevFront consents", () => {
|
||||
status: "active",
|
||||
tenantId: "tenant-a",
|
||||
tenantName: "Tenant A",
|
||||
rpMetadata: {
|
||||
approvalLevel: "A",
|
||||
approvalLevel_permissions: {
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
},
|
||||
},
|
||||
] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
@@ -45,6 +72,36 @@ test.describe("DevFront consents", () => {
|
||||
await page.goto("/clients/client-consent/consents");
|
||||
await expect(page.getByText("Alice")).toBeVisible();
|
||||
await expect(page.getByText("Tenant A")).toBeVisible();
|
||||
await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /Claims|Claim/i }).click();
|
||||
await expect(page.getByText("RP Custom Claims")).toBeVisible();
|
||||
await expect(page.getByText("contract_date")).toBeVisible();
|
||||
await expect(page.getByText("approved_at")).toBeVisible();
|
||||
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
|
||||
await page.locator('input[type="date"]').fill("2026-06-10");
|
||||
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
|
||||
await page
|
||||
.getByLabel(/쓰기 권한|Write permission/i)
|
||||
.first()
|
||||
.selectOption("user_and_admin");
|
||||
await page.getByRole("button", { name: /Claim 저장|Save Claim/i }).click();
|
||||
await expect
|
||||
.poll(() => state.consents[0]?.rpMetadata?.contract_date)
|
||||
.toBe("2026-06-10");
|
||||
await expect
|
||||
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
|
||||
.toBe("2026-06-09T10:30");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.consents[0]?.rpMetadata?.contract_date_permissions as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.writePermission,
|
||||
)
|
||||
.toBe("user_and_admin");
|
||||
|
||||
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
|
||||
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
|
||||
|
||||
@@ -51,6 +51,7 @@ export type Consent = {
|
||||
status: "active" | "revoked";
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
rpMetadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DeveloperRequestStatus = "pending" | "approved" | "rejected";
|
||||
@@ -89,6 +90,14 @@ export type DevAssignableUser = {
|
||||
loginId?: string;
|
||||
};
|
||||
|
||||
export type DevTenantSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
@@ -106,6 +115,7 @@ export type DevApiMockState = {
|
||||
developerRequests?: DeveloperRequest[];
|
||||
relations?: Record<string, ClientRelation[]>;
|
||||
users?: DevAssignableUser[];
|
||||
tenants?: DevTenantSummary[];
|
||||
auditLogsByCursor?: Record<
|
||||
string,
|
||||
{ items: AuditLog[]; next_cursor?: string }
|
||||
@@ -397,9 +407,12 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
||||
return json(route, [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
]);
|
||||
return json(
|
||||
route,
|
||||
state.tenants ?? [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/stats" && method === "GET") {
|
||||
@@ -602,6 +615,50 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.includes("/users/") &&
|
||||
pathname.endsWith("/metadata") &&
|
||||
method === "GET"
|
||||
) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const clientId = parts[4] ?? "";
|
||||
const userId = parts[6] ?? "";
|
||||
const target = state.consents.find(
|
||||
(row) => row.clientId === clientId && row.subject === userId,
|
||||
);
|
||||
return json(route, {
|
||||
clientId,
|
||||
userId,
|
||||
metadata: target?.rpMetadata ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.includes("/users/") &&
|
||||
pathname.endsWith("/metadata") &&
|
||||
method === "PUT"
|
||||
) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const clientId = parts[4] ?? "";
|
||||
const userId = parts[6] ?? "";
|
||||
const payload = (request.postDataJSON() as {
|
||||
metadata?: Record<string, unknown>;
|
||||
}) || { metadata: {} };
|
||||
const target = state.consents.find(
|
||||
(row) => row.clientId === clientId && row.subject === userId,
|
||||
);
|
||||
if (target) {
|
||||
target.rpMetadata = payload.metadata ?? {};
|
||||
}
|
||||
return json(route, {
|
||||
clientId,
|
||||
userId,
|
||||
metadata: payload.metadata ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/v1/dev/clients/") && method === "PUT") {
|
||||
const clientId = parseClientId(pathname);
|
||||
const payload = (request.postDataJSON() as {
|
||||
|
||||
Reference in New Issue
Block a user