forked from baron/baron-sso
code-check 오류 수정
This commit is contained in:
@@ -56,6 +56,16 @@ import {
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
type UserType = "hanmac" | "external" | "personal";
|
||||
|
||||
|
||||
@@ -79,6 +79,16 @@ import {
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
};
|
||||
|
||||
@@ -119,6 +119,7 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
test("should export and import tenant CSV without organization/user combined import", async ({
|
||||
page,
|
||||
browserName,
|
||||
}, testInfo) => {
|
||||
let exportRequested = false;
|
||||
let exportUrl = "";
|
||||
@@ -213,9 +214,12 @@ test.describe("Tenants Management", () => {
|
||||
/갱신 1|Updated 1/i,
|
||||
);
|
||||
expect(importRequested).toBe(true);
|
||||
if (testInfo.project.name !== "webkit") {
|
||||
expect(importBody).toContain('filename="tenants.csv"');
|
||||
if (browserName !== "webkit") {
|
||||
if (testInfo.project.name !== "webkit") {
|
||||
expect(importBody).toContain("tenant-alpha-id");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({
|
||||
|
||||
@@ -192,7 +192,7 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) {
|
||||
t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
"client_name": "Updated Name",
|
||||
})
|
||||
|
||||
// Case 1: Same tenant
|
||||
// Case 1: Same tenant but no direct edit_config permission
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Case 2: Different tenant
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
|
||||
|
||||
@@ -348,7 +348,9 @@ function AuditLogsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{actionLabel}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetValue}</span>
|
||||
@@ -401,16 +403,21 @@ function AuditLogsPage() {
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID: {formatValue(details.request_id)}
|
||||
Request ID:{" "}
|
||||
{formatValue(details.request_id)}
|
||||
</div>
|
||||
<div>
|
||||
Method: {formatValue(details.method)}
|
||||
</div>
|
||||
<div>Method: {formatValue(details.method)}</div>
|
||||
<div>Path: {formatValue(details.path)}</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>Before: {formatValue(details.before)}</div>
|
||||
<div>
|
||||
Before: {formatValue(details.before)}
|
||||
</div>
|
||||
<div>After: {formatValue(details.after)}</div>
|
||||
<div>Error: {formatValue(details.error)}</div>
|
||||
</div>
|
||||
|
||||
@@ -218,10 +218,12 @@ function ClientDetailsPage() {
|
||||
const clientSecret = hasClientSecret
|
||||
? client?.clientSecret || secretPlaceholder
|
||||
: t("ui.common.na", "N/A");
|
||||
const displaySecret =
|
||||
!hasClientSecret
|
||||
? t("msg.dev.clients.details.secret_not_applicable", "PKCE 앱에는 Client Secret이 없습니다.")
|
||||
: clientSecret === secretPlaceholder
|
||||
const displaySecret = !hasClientSecret
|
||||
? t(
|
||||
"msg.dev.clients.details.secret_not_applicable",
|
||||
"PKCE 앱에는 Client Secret이 없습니다.",
|
||||
)
|
||||
: clientSecret === secretPlaceholder
|
||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||
: clientSecret;
|
||||
|
||||
|
||||
@@ -463,9 +463,7 @@ function ClientGeneralPage() {
|
||||
(item.relation === "admins" || item.relation === "config_editor"),
|
||||
) === true;
|
||||
const isGeneralSettingsReadOnly =
|
||||
!isCreate &&
|
||||
relationData != null &&
|
||||
!canEditExistingClientGeneralSettings;
|
||||
!isCreate && relationData != null && !canEditExistingClientGeneralSettings;
|
||||
const trimmedLogoUrl = logoUrl.trim();
|
||||
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||
|
||||
@@ -312,10 +312,13 @@ function ClientRelationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfoToggle = (event: React.MouseEvent, relation: RelationOption) => {
|
||||
const handleInfoToggle = (
|
||||
event: React.MouseEvent,
|
||||
relation: RelationOption,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setInfoRelation(prev => (prev === relation ? null : relation));
|
||||
setInfoRelation((prev) => (prev === relation ? null : relation));
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
@@ -401,7 +404,10 @@ function ClientRelationsPage() {
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("msg.dev.clients.relationships.loading", "Loading relationships...")}
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Loading relationships...",
|
||||
)}
|
||||
</div>
|
||||
) : isRelationshipViewForbidden ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
@@ -510,7 +516,7 @@ function ClientRelationsPage() {
|
||||
selectedUserExistingRelations.has(relation);
|
||||
const isSelected = selectedRelations.includes(relation);
|
||||
const isInfoVisible = infoRelation === relation;
|
||||
|
||||
|
||||
return (
|
||||
<div key={relation} className="relative">
|
||||
<label
|
||||
@@ -568,7 +574,7 @@ function ClientRelationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
||||
{isInfoVisible && (
|
||||
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
|
||||
@@ -673,7 +679,9 @@ function ClientRelationsPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<span>{relationLabel(item.relation as RelationOption)}</span>
|
||||
<span>
|
||||
{relationLabel(item.relation as RelationOption)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full p-0.5 transition-colors ${
|
||||
@@ -681,14 +689,21 @@ function ClientRelationsPage() {
|
||||
? "text-primary"
|
||||
: "text-muted-foreground/60 hover:text-primary"
|
||||
}`}
|
||||
onClick={(e) => handleInfoToggle(e, item.relation as RelationOption)}
|
||||
onClick={(e) =>
|
||||
handleInfoToggle(
|
||||
e,
|
||||
item.relation as RelationOption,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{infoRelation === item.relation && (
|
||||
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
|
||||
{relationPermitsInfo(item.relation as RelationOption)}
|
||||
{relationPermitsInfo(
|
||||
item.relation as RelationOption,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -191,11 +191,17 @@ delete_confirm = ""
|
||||
delete_success = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
import_empty = ""
|
||||
import_error = ""
|
||||
import_result = ""
|
||||
missing_id = ""
|
||||
not_found = ""
|
||||
remove_sub_confirm = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = ""
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = ""
|
||||
empty = ""
|
||||
@@ -255,9 +261,13 @@ parsed_count = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
appointment_required = ""
|
||||
error = ""
|
||||
external_tenant_required = ""
|
||||
password_required = ""
|
||||
personal_tenant_failed = ""
|
||||
success = ""
|
||||
tenant_resolve_failed = ""
|
||||
|
||||
[msg.admin.users.create.account]
|
||||
subtitle = ""
|
||||
@@ -269,6 +279,7 @@ field_required = ""
|
||||
name_required = ""
|
||||
password_auto_help = ""
|
||||
password_manual_help = ""
|
||||
picker_description = ""
|
||||
role_help = ""
|
||||
|
||||
[msg.admin.users.create.password_generated]
|
||||
@@ -291,7 +302,9 @@ password_hint = ""
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = ""
|
||||
empty = ""
|
||||
export_error = ""
|
||||
fetch_error = ""
|
||||
status_error = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.users.list.columns]
|
||||
@@ -991,6 +1004,9 @@ user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
csv_template = ""
|
||||
export = ""
|
||||
import = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
@@ -1120,6 +1136,15 @@ search_placeholder = ""
|
||||
title = ""
|
||||
tree_search_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = ""
|
||||
confirm = ""
|
||||
create_new = ""
|
||||
fixed_id = ""
|
||||
match = ""
|
||||
no_candidates = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = ""
|
||||
name = ""
|
||||
@@ -1128,6 +1153,7 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
id = ""
|
||||
members = ""
|
||||
name = ""
|
||||
slug = ""
|
||||
@@ -1227,6 +1253,7 @@ empty = ""
|
||||
fetch_error = ""
|
||||
search_placeholder = ""
|
||||
subtitle = ""
|
||||
toggle_status = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
|
||||
@@ -221,6 +221,39 @@ function parseClientId(pathname: string): string {
|
||||
}
|
||||
|
||||
export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
const readMockRole = async () =>
|
||||
(
|
||||
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
|
||||
"rp_admin"
|
||||
).trim();
|
||||
|
||||
const buildSelfConfigEditorRelation = (): ClientRelation => ({
|
||||
relation: "config_editor",
|
||||
subject: "User:playwright-user",
|
||||
subjectType: "User",
|
||||
subjectId: "playwright-user",
|
||||
userName: "Playwright User",
|
||||
userEmail: "playwright@example.com",
|
||||
userLoginId: "playwright@example.com",
|
||||
});
|
||||
|
||||
const shouldGrantDefaultEditRelation = (role: string) =>
|
||||
role === "rp_admin" || role === "tenant_admin" || role === "super_admin";
|
||||
|
||||
const resolveClientRelations = async (clientId: string) => {
|
||||
const explicitRelations = state.relations?.[clientId];
|
||||
if (explicitRelations) {
|
||||
return explicitRelations;
|
||||
}
|
||||
|
||||
const role = await readMockRole();
|
||||
if (!shouldGrantDefaultEditRelation(role)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [buildSelfConfigEditorRelation()];
|
||||
};
|
||||
|
||||
const appendAuditLog = (
|
||||
eventType: string,
|
||||
action: string,
|
||||
@@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
|
||||
state.clients.push(created);
|
||||
if (!state.relations) {
|
||||
state.relations = {};
|
||||
}
|
||||
state.relations[created.id] = [buildSelfConfigEditorRelation()];
|
||||
appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id);
|
||||
return json(route, {
|
||||
client: created,
|
||||
@@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
return json(route, {
|
||||
items: state.relations?.[clientId] ?? [],
|
||||
items: await resolveClientRelations(clientId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [{ "handler": "noop" }]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1204,6 +1204,15 @@ title = "Tenant Members ({{count}})"
|
||||
total = "Total"
|
||||
total_label = "Total"
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = "Candidates"
|
||||
confirm = "Confirm Import"
|
||||
create_new = "Create New"
|
||||
fixed_id = "Fixed ID"
|
||||
match = "Matched Tenant"
|
||||
no_candidates = "No matching tenants found."
|
||||
title = "Import Preview"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
@@ -1339,6 +1348,7 @@ name = "Name"
|
||||
name_placeholder = "Name Placeholder"
|
||||
password = "Password"
|
||||
password_placeholder = "********"
|
||||
picker_description = "Search and select a tenant."
|
||||
phone = "Phone number"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "Position"
|
||||
|
||||
@@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?"
|
||||
delete_success = "테넌트가 삭제되었습니다."
|
||||
empty = "아직 등록된 테넌트가 없습니다."
|
||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||
import_empty = "임포트 파일에 테넌트 행이 없습니다."
|
||||
import_error = "테넌트 임포트에 실패했습니다: {{error}}"
|
||||
import_result = "{{count}}개의 테넌트 행을 처리했습니다."
|
||||
missing_id = "테넌트 ID가 없습니다."
|
||||
not_found = "테넌트를 찾을 수 없습니다."
|
||||
remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?"
|
||||
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요."
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = "관리자가 추가되었습니다."
|
||||
empty = "등록된 관리자가 없습니다."
|
||||
@@ -1645,6 +1651,8 @@ delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
||||
export_error = "사용자 내보내기에 실패했습니다: {{error}}"
|
||||
status_error = "사용자 상태 변경에 실패했습니다: {{error}}"
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
@@ -1658,6 +1666,15 @@ title = "테넌트 구성원 ({{count}})"
|
||||
total = "전체"
|
||||
total_label = "전체"
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = "후보"
|
||||
confirm = "임포트 확정"
|
||||
create_new = "새로 생성"
|
||||
fixed_id = "고정 ID"
|
||||
match = "매칭된 테넌트"
|
||||
no_candidates = "매칭 가능한 테넌트가 없습니다."
|
||||
title = "임포트 미리보기"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
@@ -1793,6 +1810,7 @@ name = "이름"
|
||||
name_placeholder = "홍길동"
|
||||
password = "비밀번호"
|
||||
password_placeholder = "********"
|
||||
picker_description = "배정할 테넌트를 검색해서 선택하세요."
|
||||
phone = "전화번호"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "직급"
|
||||
|
||||
@@ -1517,9 +1517,17 @@ import_partial_success = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = ""
|
||||
import_empty = ""
|
||||
import_error = ""
|
||||
import_result = ""
|
||||
|
||||
[msg.admin.tenants.import_preview]
|
||||
description = ""
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = ""
|
||||
export_error = ""
|
||||
status_error = ""
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = ""
|
||||
@@ -1604,6 +1612,15 @@ search_placeholder = ""
|
||||
title = ""
|
||||
tree_search_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.import_preview]
|
||||
candidates = ""
|
||||
confirm = ""
|
||||
create_new = ""
|
||||
fixed_id = ""
|
||||
match = ""
|
||||
no_candidates = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = ""
|
||||
name = ""
|
||||
@@ -1668,6 +1685,7 @@ name = ""
|
||||
name_placeholder = ""
|
||||
password = ""
|
||||
password_placeholder = ""
|
||||
picker_description = ""
|
||||
phone = ""
|
||||
phone_placeholder = ""
|
||||
position = ""
|
||||
|
||||
@@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}"
|
||||
mkdir -p reports
|
||||
rm -rf adminfront/node_modules
|
||||
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
is_port_available() {
|
||||
local port="$1"
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const port = Number(process.argv[1]);
|
||||
const server = net.createServer();
|
||||
server.once("error", () => process.exit(1));
|
||||
server.once("listening", () => server.close(() => process.exit(0)));
|
||||
server.listen(port, "127.0.0.1");
|
||||
' "$port"
|
||||
}
|
||||
|
||||
find_available_port() {
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
process.stdout.write(String(address.port));
|
||||
server.close();
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
fi
|
||||
|
||||
set +e
|
||||
(
|
||||
@@ -67,6 +99,11 @@ fi
|
||||
|
||||
set +e
|
||||
port="${PORT:-5180}"
|
||||
if ! is_port_available "$port"; then
|
||||
fallback_port="$(find_available_port)"
|
||||
echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port"
|
||||
port="$fallback_port"
|
||||
fi
|
||||
echo "==> adminfront using PORT=$port"
|
||||
(
|
||||
cd adminfront
|
||||
|
||||
Reference in New Issue
Block a user