diff --git a/adminfront/package.json b/adminfront/package.json index 84419dea..0ff96d03 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -12,7 +12,8 @@ "preview": "vite preview", "test": "playwright test", "test:unit": "vitest run", - "test:ui": "playwright test --ui" + "test:ui": "playwright test --ui", + "i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.4", diff --git a/adminfront/src/components/ui/table.tsx b/adminfront/src/components/ui/table.tsx index b20952d6..62c33432 100644 --- a/adminfront/src/components/ui/table.tsx +++ b/adminfront/src/components/ui/table.tsx @@ -5,7 +5,7 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
-
+
+
@@ -103,8 +103,8 @@ function ApiKeyListPage() {
- - + +
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")} @@ -119,95 +119,102 @@ function ApiKeyListPage() {
{t("ui.common.badge.system", "System")}
- + {(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} -
- - - - {t("ui.admin.api_keys.list.table.name", "NAME")} - - - {t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")} - - - {t("ui.admin.api_keys.list.table.scopes", "SCOPES")} - - - {t("ui.admin.api_keys.list.table.last_used", "LAST USED")} - - - {t("ui.admin.api_keys.list.table.actions", "ACTIONS")} - - - - - {query.isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && items.length === 0 && ( - - - {t( - "msg.admin.api_keys.list.empty", - "등록된 API 키가 없습니다.", - )} - - - )} - {items.map((key) => ( - - -
- - {key.name} -
-
- - {key.client_id} - - -
- {key.scopes.map((scope) => ( - +
+
+ + + + {t("ui.admin.api_keys.list.table.name", "NAME")} + + + {t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")} + + + {t("ui.admin.api_keys.list.table.scopes", "SCOPES")} + + + {t("ui.admin.api_keys.list.table.last_used", "LAST USED")} + + + {t("ui.admin.api_keys.list.table.actions", "ACTIONS")} + + + + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && items.length === 0 && ( + + + {t( + "msg.admin.api_keys.list.empty", + "등록된 API 키가 없습니다.", + )} + + + )} + {items.map((key) => ( + + +
+ + {key.name} +
+
+ + {key.client_id} + + +
+ {key.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleString("ko-KR") + : t("ui.common.never", "Never")} + + + - -
- ))} -
-
+ + {t("ui.common.delete", "삭제")} + + + + ))} + + +
+
diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a10c5675..530c3a42 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -158,8 +158,8 @@ function AuditLogsPage() { } return ( -
-
+
+
{t("ui.admin.audit.breadcrumb.section", "Audit")} @@ -194,409 +194,421 @@ function AuditLogsPage() {
-
- - -
- - {t("ui.admin.audit.registry.title", "Audit registry")} - - - {t( - "msg.admin.audit.registry.count", - "로드된 로그 {{count}}건", - { count: logs.length }, + + +
+ + {t("ui.admin.audit.registry.title", "Audit registry")} + + + {t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", { + count: logs.length, + })} + +
+ + {t("ui.common.badge.command_only", "Command only")} + +
+ +
+
+ + setFilterDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder={t( + "ui.admin.audit.filters.placeholder", + "필터 추가 (예: status:failure)", )} - + className="w-full bg-transparent text-sm text-foreground outline-none" + /> +
- - {t("ui.common.badge.command_only", "Command only")} - - - -
-
- - setFilterDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleAddFilter(); + {filters.length === 0 ? ( + + {t("msg.admin.audit.filters.empty", "필터 없음")} + + ) : ( + filters.map((filter) => ( + + + {filter} + -
- {filters.length === 0 ? ( - - {t("msg.admin.audit.filters.empty", "필터 없음")} - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} -
- - - - - {t("ui.admin.audit.table.time", "TIME")} - - - {t("ui.admin.audit.table.actor", "ACTOR (ID)")} - - - {t("ui.admin.audit.table.request", "REQUEST")} - - - {t("ui.admin.audit.table.path", "PATH")} - - - {t("ui.admin.audit.table.status", "STATUS")} - - - {t("ui.admin.audit.table.action_target", "Action / Target")} - - - - - - {isLoading && ( + × + + + )) + )} + +
+
+
+ - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!isLoading && logs.length === 0 && ( - - + + {t("ui.admin.audit.table.time", "TIME")} + + + {t("ui.admin.audit.table.actor", "ACTOR (ID)")} + + + {t("ui.admin.audit.table.request", "REQUEST")} + + + {t("ui.admin.audit.table.path", "PATH")} + + + {t("ui.admin.audit.table.status", "STATUS")} + + {t( - "msg.admin.audit.empty", - "아직 수집된 감사 로그가 없습니다.", + "ui.admin.audit.table.action_target", + "Action / Target", )} - + + - )} - {logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = - details.action || - (details.method && details.path - ? `${details.method} ${details.path}` - : row.event_type); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean(expandedRows[rowKey]); - return ( - - - - {(() => { - const { date, time } = formatIsoDateTime( - row.timestamp, - ); - return ( -
-
{date}
-
{time}
-
- ); - })()} -
- -
- - {row.user_id || details.actor_id || "-"} - - {(row.user_id || details.actor_id) && ( - - )} -
-
- -
- - {formatCellValue(details.request_id)} - - {details.request_id && ( - - )} -
-
- -
- {formatCellValue(details.method)} -
-
- {formatCellValue(details.path)} -
-
- - - {row.status} - - - -
- {actionLabel} -
- {details.target && ( +
+ + {isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!isLoading && logs.length === 0 && ( + + + {t( + "msg.admin.audit.empty", + "아직 수집된 감사 로그가 없습니다.", + )} + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean(expandedRows[rowKey]); + return ( + + + + {(() => { + const { date, time } = formatIsoDateTime( + row.timestamp, + ); + return ( +
+
{date}
+
{time}
+
+ ); + })()} +
+
- - {t( - "ui.admin.audit.target", - "Target · {{target}}", - { - target: details.target, - }, - )} - - -
- )} -
- - - -
- {isExpanded && ( - - -
-
-
- {t( - "ui.admin.audit.details.request", - "Request", + + {row.user_id || details.actor_id || "-"} + + {(row.user_id || details.actor_id) && ( +
-
-
- {t("ui.admin.audit.details.actor", "Actor")} -
-
- {t( - "ui.admin.audit.details.actor_id", - "Actor ID · {{value}}", - { - value: - row.user_id || details.actor_id || "-", - }, - )} -
-
- {t( - "ui.admin.audit.details.tenant", - "Tenant · {{value}}", - { - value: formatCellValue(details.tenant_id), - }, - )} -
-
- {t( - "ui.admin.audit.details.device", - "Device · {{value}}", - { - value: formatCellValue(row.device_id), - }, - )} -
-
-
-
- {t("ui.admin.audit.details.result", "Result")} -
-
- {t( - "ui.admin.audit.details.error", - "Error · {{value}}", - { - value: formatCellValue(details.error), - }, - )} -
-
- {t( - "ui.admin.audit.details.before", - "Before · {{value}}", - { - value: formatCellValue(details.before), - }, - )} -
-
- {t( - "ui.admin.audit.details.after", - "After · {{value}}", - { - value: formatCellValue(details.after), - }, - )} -
-
+ onClick={() => + handleCopy( + row.user_id || details.actor_id || "", + ) + } + > + + + )}
+ +
+ + {formatCellValue(details.request_id)} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue(details.method)} +
+
+ {formatCellValue(details.path)} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + {t( + "ui.admin.audit.target", + "Target · {{target}}", + { + target: details.target, + }, + )} + + +
+ )} +
+ + + - )} - - ); - })} - -
-
- {hasNextPage ? ( - - ) : ( - - {t("msg.admin.audit.end", "End of audit feed")} - - )} + {isExpanded && ( + + +
+
+
+ {t( + "ui.admin.audit.details.request", + "Request", + )} +
+
+ {t( + "ui.admin.audit.details.request_id", + "Request ID · {{value}}", + { + value: formatCellValue( + details.request_id, + ), + }, + )} +
+
+ {t( + "ui.admin.audit.details.event_id", + "Event ID · {{value}}", + { + value: formatCellValue(row.event_id), + }, + )} +
+
+ {t( + "ui.admin.audit.details.ip", + "IP · {{value}}", + { + value: formatCellValue(row.ip_address), + }, + )} +
+
+ {t( + "ui.admin.audit.details.latency", + "Latency · {{value}}", + { + value: + details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-", + }, + )} +
+
+
+
+ {t("ui.admin.audit.details.actor", "Actor")} +
+
+ {t( + "ui.admin.audit.details.actor_id", + "Actor ID · {{value}}", + { + value: + row.user_id || + details.actor_id || + "-", + }, + )} +
+
+ {t( + "ui.admin.audit.details.tenant", + "Tenant · {{value}}", + { + value: formatCellValue( + details.tenant_id, + ), + }, + )} +
+
+ {t( + "ui.admin.audit.details.device", + "Device · {{value}}", + { + value: formatCellValue(row.device_id), + }, + )} +
+
+
+
+ {t( + "ui.admin.audit.details.result", + "Result", + )} +
+
+ {t( + "ui.admin.audit.details.error", + "Error · {{value}}", + { + value: formatCellValue(details.error), + }, + )} +
+
+ {t( + "ui.admin.audit.details.before", + "Before · {{value}}", + { + value: formatCellValue(details.before), + }, + )} +
+
+ {t( + "ui.admin.audit.details.after", + "After · {{value}}", + { + value: formatCellValue(details.after), + }, + )} +
+
+
+
+
+ )} + + ); + })} + +
-
- -
+
+
+ {hasNextPage ? ( + + ) : ( + + {t("msg.admin.audit.end", "End of audit feed")} + + )} +
+ +
); } diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 80841deb..33faca35 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -10,6 +10,7 @@ import { Users, } from "lucide-react"; import { useState } from "react"; +import { useAuth } from "react-oidc-context"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; @@ -52,6 +53,8 @@ import { t } from "../../../lib/i18n"; type DialogMode = "owner" | "admin"; export function TenantAdminsAndOwnersTab() { + const auth = useAuth(); + const currentUserId = auth.user?.profile.sub; const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); @@ -204,218 +207,266 @@ export function TenantAdminsAndOwnersTab() { ); return ( -
- {/* Owners Card */} - - -
- - - {t("ui.admin.tenants.owners.title", "테넌트 소유자")} - - - {t( - "msg.admin.tenants.owners.subtitle", - "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", - )} - -
- -
- -
- - - - - {t("ui.admin.tenants.owners.table_name", "이름")} - - - {t("ui.admin.tenants.owners.table_email", "이메일")} - - - {t("ui.admin.tenants.owners.table_actions", "액션")} - - - - - {ownersQuery.isLoading ? ( - - -
- - - ) : currentOwners.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.owners.empty", - "등록된 소유자가 없습니다.", - )} -

-
-
-
- ) : ( - currentOwners.map((owner) => ( - - -
-
- {owner.name.charAt(0)} -
- {owner.name} -
-
- - {owner.email} - - - - -
- )) +
+
+ {/* Owners Card */} + + +
+ + + {t("ui.admin.tenants.owners.title", "테넌트 소유자")} + + + {t( + "msg.admin.tenants.owners.subtitle", + "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", )} - -
-
-
-
+ +
+ + + +
+
+ + + + + {t("ui.admin.tenants.owners.table_name", "이름")} + + + {t("ui.admin.tenants.owners.table_email", "이메일")} + + + {t("ui.admin.tenants.owners.table_actions", "액션")} + + + + + {ownersQuery.isLoading ? ( + + +
+ + + ) : currentOwners.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.owners.empty", + "등록된 소유자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentOwners.map((owner) => ( + + +
+
+ {owner.name.charAt(0)} +
+ {owner.name} +
+
+ + {owner.email} + + + + +
+ )) + )} + +
+
+
+
+ - {/* Admins Card */} - - -
- - - {t("ui.admin.tenants.admins.title", "테넌트 관리자")} - - - {t( - "msg.admin.tenants.admins.subtitle", - "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", - )} - -
- -
- -
- - - - - {t("ui.admin.tenants.admins.table_name", "이름")} - - - {t("ui.admin.tenants.admins.table_email", "이메일")} - - - {t("ui.admin.tenants.admins.table_actions", "액션")} - - - - - {adminsQuery.isLoading ? ( - - -
- - - ) : currentAdmins.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.admins.empty", - "등록된 관리자가 없습니다.", - )} -

-
-
-
- ) : ( - currentAdmins.map((admin) => ( - - -
-
- {admin.name.charAt(0)} -
- {admin.name} -
-
- - {admin.email} - - - - -
- )) + {/* Admins Card */} + + +
+ + + {t("ui.admin.tenants.admins.title", "테넌트 관리자")} + + + {t( + "msg.admin.tenants.admins.subtitle", + "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", )} - -
-
-
-
+ +
+ + + +
+
+ + + + + {t("ui.admin.tenants.admins.table_name", "이름")} + + + {t("ui.admin.tenants.admins.table_email", "이메일")} + + + {t("ui.admin.tenants.admins.table_actions", "액션")} + + + + + {adminsQuery.isLoading ? ( + + +
+ + + ) : currentAdmins.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.admins.empty", + "등록된 관리자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentAdmins.map((admin) => ( + + +
+
+ {admin.name.charAt(0)} +
+ {admin.name} +
+
+ + {admin.email} + + + + +
+ )) + )} + +
+
+
+
+ +
{/* Common Dialog for adding users */} 0, }); + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const canAccessSchema = + profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const isFederationTab = location.pathname.includes("/federation"); const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); @@ -98,16 +106,18 @@ function TenantDetailPage() { > {t("ui.admin.tenants.detail.tab_organization", "조직 관리")} - - {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} - + {canAccessSchema && ( + + {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} + + )} {/* Outlet for nested routes */} diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index a2518662..511f6680 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -343,11 +343,11 @@ function TenantGroupsPage() { const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); return ( -
-
+
+
{/* 그룹 생성 폼 */} - - + + {" "} {t("ui.admin.groups.create.title", "새 그룹 생성")} @@ -359,7 +359,7 @@ function TenantGroupsPage() { )} - +
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} {currentGroup && ( - - + + {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { @@ -541,8 +545,8 @@ function TenantGroupsPage() { )} - -
+ +
- - - - - {t("ui.admin.groups.members.table.name", "이름")} - - - {t("ui.admin.groups.members.table.email", "이메일")} - - - {t("ui.admin.groups.members.table.remove", "제거")} - - - - - {currentGroup.members?.length === 0 && ( - - - {t("msg.admin.groups.members.empty", "멤버가 없습니다.")} - - - )} - {currentGroup.members?.map((user) => ( - - {user.name} - - {user.email} - - - - - - ))} - -
+
+
+ + + + + {t("ui.admin.groups.members.table.name", "이름")} + + + {t("ui.admin.groups.members.table.email", "이메일")} + + + {t("ui.admin.groups.members.table.remove", "제거")} + + + + + {currentGroup.members?.length === 0 && ( + + + {t( + "msg.admin.groups.members.empty", + "멤버가 없습니다.", + )} + + + )} + {currentGroup.members?.map((user) => ( + + + {user.name} + + + {user.email} + + + + + + ))} + +
+
+
)} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d1aa14e6..2d9705d0 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -116,8 +116,8 @@ function TenantListPage() { }; return ( -
-
+
+
{t("ui.admin.tenants.breadcrumb.section", "Tenants")} @@ -156,8 +156,8 @@ function TenantListPage() {
- - + +
{t("ui.admin.tenants.registry.title", "Tenant Registry")} @@ -172,120 +172,132 @@ function TenantListPage() { {t("ui.common.badge.admin_only", "Admin only")} - + {(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} - - - - - {t("ui.admin.tenants.table.name", "NAME")} - - - {t("ui.admin.tenants.table.type", "TYPE")} - - - {t("ui.admin.tenants.table.slug", "SLUG")} - - - {t("ui.admin.tenants.table.status", "STATUS")} - - - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.updated", "UPDATED")} - - - {t("ui.admin.tenants.table.actions", "ACTIONS")} - - - - - {query.isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && tenants.length === 0 && ( - - - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} - - - )} - {tenants.map((tenant) => ( - - {tenant.name} - - - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} - - - - {tenant.slug} - - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - {tenant.memberCount} - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - - -
-
+ + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.type", "TYPE")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.updated", "UPDATED")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && tenants.length === 0 && ( + + - - {t("ui.common.edit", "편집")} - - - - - - ))} - -
+ {t( + "msg.admin.tenants.empty", + "아직 등록된 테넌트가 없습니다.", + )} + + + )} + {tenants.map((tenant) => ( + + + {tenant.name} + + + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + {tenant.slug} + + + + {t( + `ui.common.status.${tenant.status}`, + tenant.status, + )} + + + + {tenant.memberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
+ ))} + + +
+
diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 1c95d705..221c9a90 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -14,10 +14,16 @@ import { } from "../../../components/ui/card"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; -import { fetchTenant, updateTenant } from "../../../lib/adminApi"; +import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -type SchemaFieldType = "text" | "number" | "boolean" | "date"; +type SchemaFieldType = + | "text" + | "number" + | "boolean" + | "date" + | "float" + | "datetime"; type SchemaField = { id: string; @@ -27,6 +33,7 @@ type SchemaField = { required: boolean; adminOnly: boolean; validation?: string; + unsigned?: boolean; }; function createFieldId() { @@ -40,6 +47,38 @@ export function TenantSchemaPage() { const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); + const { data: profile, isLoading: isProfileLoading } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const canAccess = + profile?.role === "super_admin" || profile?.role === "tenant_admin"; + + if (isProfileLoading) { + return ( +
+ {t("msg.common.loading", "로딩 중...")} +
+ ); + } + + if (!canAccess) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+

+ {t( + "msg.admin.tenants.schema.forbidden_desc", + "사용자 스키마 설정은 관리자만 접근할 수 있습니다.", + )} +

+
+ ); + } + if (!tenantId) { return (
@@ -66,13 +105,16 @@ export function TenantSchemaPage() { type: field?.type === "number" || field?.type === "boolean" || - field?.type === "date" + field?.type === "date" || + field?.type === "float" || + field?.type === "datetime" ? field.type : "text", required: Boolean(field?.required), adminOnly: Boolean(field?.adminOnly), validation: typeof field?.validation === "string" ? field.validation : "", + unsigned: Boolean(field?.unsigned), })), ); } @@ -114,6 +156,7 @@ export function TenantSchemaPage() { required: false, adminOnly: false, validation: "", + unsigned: false, }, ]); }; @@ -210,9 +253,13 @@ export function TenantSchemaPage() { nextType === "text" || nextType === "number" || nextType === "boolean" || - nextType === "date" + nextType === "date" || + nextType === "float" || + nextType === "datetime" ) { - updateField(index, { type: nextType }); + updateField(index, { + type: nextType as SchemaFieldType, + }); } }} > @@ -225,7 +272,13 @@ export function TenantSchemaPage() { + +
-
+
+ {(field.type === "number" || field.type === "float") && ( + + )}
- + +
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() { - - - - - - {t("ui.admin.tenants.sub.table.name", "NAME")} - - - {t("ui.admin.tenants.sub.table.slug", "SLUG")} - - - {t("ui.admin.tenants.sub.table.status", "STATUS")} - - - {t("ui.admin.tenants.sub.table.action", "ACTION")} - - - - - {subTenants.length === 0 && ( - - - {t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")} - - - )} - {subTenants.map((tenant) => ( - - {tenant.name} - - {tenant.slug} - - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - - - - ))} - -
+ +
+
+ + + + + {t("ui.admin.tenants.sub.table.name", "NAME")} + + + {t("ui.admin.tenants.sub.table.slug", "SLUG")} + + + {t("ui.admin.tenants.sub.table.status", "STATUS")} + + + {t("ui.admin.tenants.sub.table.action", "ACTION")} + + + + + {subTenants.length === 0 && ( + + + {t( + "msg.admin.tenants.sub.empty", + "하위 테넌트가 없습니다.", + )} + + + )} + {subTenants.map((tenant) => ( + + + {tenant.name} + + + {tenant.slug} + + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + + + + ))} + +
+
+
); diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index fe722db9..e2956a68 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -42,8 +42,8 @@ function TenantUsersPage() { const users = usersQuery.data?.items ?? []; return ( - - + + {t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", { @@ -51,66 +51,70 @@ function TenantUsersPage() { })} - - - - - - {t("ui.admin.tenants.members.table.name", "NAME")} - - - {t("ui.admin.tenants.members.table.email", "EMAIL")} - - - {t("ui.admin.tenants.members.table.role", "ROLE")} - - - {t("ui.admin.tenants.members.table.status", "STATUS")} - - - - - {users.length === 0 && ( - - - {t( - "msg.admin.tenants.members.empty", - "소속된 사용자가 없습니다.", - )} - - - )} - {users.map((user) => ( - - {user.name} - -
- - {user.email} -
-
- - - {t( - `ui.common.role.${user.role}`, - user.role.replace("_", " "), - )} - - - - - {t(`ui.common.status.${user.status}`, user.status)} - - -
- ))} -
-
+ +
+
+ + + + + {t("ui.admin.tenants.members.table.name", "NAME")} + + + {t("ui.admin.tenants.members.table.email", "EMAIL")} + + + {t("ui.admin.tenants.members.table.role", "ROLE")} + + + {t("ui.admin.tenants.members.table.status", "STATUS")} + + + + + {users.length === 0 && ( + + + {t( + "msg.admin.tenants.members.empty", + "소속된 사용자가 없습니다.", + )} + + + )} + {users.map((user) => ( + + {user.name} + +
+ + {user.email} +
+
+ + + {t( + `ui.common.role.${user.role}`, + user.role.replace("_", " "), + )} + + + + + {t(`ui.common.status.${user.status}`, user.status)} + + +
+ ))} +
+
+
+
); diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index c4c84dc4..7bfb4891 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() { return
Loading tenants and groups...
; return ( -
-
+
+

User Groups

@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {

-
+
{tenantList?.items.map((tenant) => ( ))} @@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) { }); return ( - - + +
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) { - - - - - 그룹명 - 설명 - 멤버 수 - 작업 - - - - {isLoading ? ( - - - Loading... - - - ) : groups?.length === 0 ? ( - - - 등록된 유저 그룹이 없습니다. - - - ) : ( - groups?.map((group) => ( - - -
- - - {group.name} - -
-
- {group.description || "-"} - {group.members?.length || 0} 명 - - - + +
+
+
+ + + 그룹명 + 설명 + 멤버 수 + 작업 - )) - )} - -
+ + + {isLoading ? ( + + + Loading... + + + ) : groups?.length === 0 ? ( + + + 등록된 유저 그룹이 없습니다. + + + ) : ( + groups?.map((group) => ( + + +
+ + + {group.name} + +
+
+ {group.description || "-"} + {group.members?.length || 0} 명 + + + +
+ )) + )} +
+ +
+
); diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index c56f8702..ad60e0d3 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -228,7 +228,7 @@ const MemberTable: React.FC<{ }> = ({ members, isLoading, onRefresh, showTenant }) => (
- + {t("ui.admin.users.table.name", "NAME")} @@ -929,9 +929,9 @@ function TenantUserGroupsTab() { const BaseIcon = getTenantIcon(currentBase.type); return ( -
- - +
+ +
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
-
+
)}
- -
- - - - {t("ui.admin.tenants.table.name", "NAME")} - - - {t("ui.admin.tenants.table.slug", "SLUG")} - - - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.status", "STATUS")} - - - {t("ui.admin.tenants.table.actions", "ACTIONS")} - - - - - - -
+ +
+
+ + + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + + +
+
+
diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 98b9f9c7..4bf60753 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -211,8 +211,8 @@ export function UserGroupDetailPage() { ); return ( -
-
+
+
-
+
{/* Members Management */} - - + +
{t("ui.admin.groups.detail.members_title", "구성원 관리")} @@ -347,88 +347,90 @@ export function UserGroupDetailPage() {
- -
- - - - - {t("ui.admin.users.list.table.name_email", "사용자")} - - - {t("ui.admin.groups.table.actions", "액션")} - - - - - {!currentGroup.members || - currentGroup.members.length === 0 ? ( + +
+
+
+ - - {t( - "msg.admin.groups.members.empty", - "구성원이 없습니다.", - )} - + + {t("ui.admin.users.list.table.name_email", "사용자")} + + + {t("ui.admin.groups.table.actions", "액션")} + - ) : ( - currentGroup.members.map((member) => ( - - -
-
- {member.name.charAt(0)} -
-
-

- {member.name} -

-

- {member.email} -

-
-
-
- - +
+ + {!currentGroup.members || + currentGroup.members.length === 0 ? ( + + + {t( + "msg.admin.groups.members.empty", + "구성원이 없습니다.", + )} - )) - )} - -
+ ) : ( + currentGroup.members.map((member) => ( + + +
+
+ {member.name.charAt(0)} +
+
+

+ {member.name} +

+

+ {member.email} +

+
+
+
+ + + +
+ )) + )} + + +
{/* Roles/Permissions Management (Keto Based) */} - - + +
{t("ui.admin.groups.detail.permissions_title", "권한 관리")} @@ -530,86 +532,88 @@ export function UserGroupDetailPage() { - -
- - - - - {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} - - - {t("ui.admin.users.detail.form.role", "역할")} - - - {t("ui.admin.groups.table.actions", "액션")} - - - - - {isRolesLoading ? ( + +
+
+
+ - -
- + + {t("ui.admin.users.detail.form.tenant", "대상 테넌트")} + + + {t("ui.admin.users.detail.form.role", "역할")} + + + {t("ui.admin.groups.table.actions", "액션")} + - ) : !groupRoles || groupRoles.length === 0 ? ( - - - {t( - "msg.admin.groups.roles.empty", - "할당된 역할이 없습니다.", - )} - - - ) : ( - groupRoles.map((role, idx) => ( - - -
- {role.tenantName || role.tenantId} -
-
- - - {role.relation} - - - - + + + {isRolesLoading ? ( + + +
- )) - )} - -
+ ) : !groupRoles || groupRoles.length === 0 ? ( + + + {t( + "msg.admin.groups.roles.empty", + "할당된 역할이 없습니다.", + )} + + + ) : ( + groupRoles.map((role, idx) => ( + + +
+ {role.tenantName || role.tenantId} +
+
+ + + {role.relation} + + + + + +
+ )) + )} + + +
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 6278c917..9842cfa9 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -4,6 +4,10 @@ import { ArrowLeft, BadgeCheck, Building2, + Copy, + Dices, + Eye, + EyeOff, Loader2, Save, Users, @@ -15,6 +19,7 @@ import { useForm, } from "react-hook-form"; import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { Card, @@ -36,6 +41,19 @@ import { } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; +// Utility for secure password generation +function generateSecurePassword(length = 16) { + const charset = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-="; + let retVal = ""; + const values = new Uint32Array(length); + crypto.getRandomValues(values); + for (let i = 0; i < length; i++) { + retVal += charset.charAt(values[i] % charset.length); + } + return retVal; +} + type UserSchemaField = { key: string; label?: string; @@ -148,6 +166,7 @@ function UserDetailPage() { const queryClient = useQueryClient(); const [error, setError] = React.useState(null); const [successMsg, setSuccessMsg] = React.useState(null); + const [showPassword, setShowPassword] = React.useState(false); const { data: profile } = useQuery({ queryKey: ["me"], @@ -175,6 +194,7 @@ function UserDetailPage() { handleSubmit, reset, watch, + setValue, formState: { errors }, } = useForm({ defaultValues: { @@ -194,6 +214,28 @@ function UserDetailPage() { const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const handleGeneratePassword = () => { + const newPass = generateSecurePassword(); + setValue("password", newPass); + setShowPassword(true); + toast.success( + t( + "msg.admin.users.detail.password_generated", + "안전한 비밀번호가 생성되었습니다.", + ), + ); + }; + + const handleCopyPassword = () => { + const pass = watch("password"); + if (pass) { + navigator.clipboard.writeText(pass); + toast.success( + t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."), + ); + } + }; + React.useEffect(() => { if (user) { reset({ @@ -556,15 +598,49 @@ function UserDetailPage() { "비밀번호 변경", )} - +
+
+ + +
+ + +

{t( "msg.admin.users.detail.security.password_hint", diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index ebe23fbc..ced361c1 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -254,8 +254,8 @@ function UserListPage() { }; return ( -

-
+
+
{t("ui.admin.users.list.breadcrumb.section", "Users")} @@ -353,8 +353,8 @@ function UserListPage() {
- - + +
{t("ui.admin.users.list.registry.title", "User Registry")} @@ -368,8 +368,8 @@ function UserListPage() {
- -
+ +
{(errorMsg || fallbackError) && ( -
+
{errorMsg ?? fallbackError}
)} -
- - - - - 0 && - selectedUserIds.length === items.length - } - onChange={toggleSelectAll} - /> - - - {t("ui.admin.users.list.table.name_email", "NAME / EMAIL")} - - - {t("ui.admin.users.list.table.role", "ROLE")} - - - {t("ui.admin.users.list.table.status", "STATUS")} - - - {t( - "ui.admin.users.list.table.tenant_dept", - "TENANT / DEPT", - )} - - {/* Dynamic Columns from Schema */} - {userSchema.map( - (field) => - visibleColumns[field.key] !== false && ( - - {field.label} - - ), - )} - - {t("ui.admin.users.list.table.created", "CREATED")} - - - {t("ui.admin.users.list.table.actions", "ACTIONS")} - - - - - {query.isLoading && ( +
+
+
+ - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!query.isLoading && items.length === 0 && ( - - - {t("msg.admin.users.list.empty", "검색 결과가 없습니다.")} - - - )} - {items.map((user) => ( - - + toggleSelectUser(user.id)} - /> - - -
-
- -
-
- {user.name} - - {user.email} - -
-
-
- - - {t(`ui.admin.role.${user.role}`, user.role)} - - - - 0 && + selectedUserIds.length === items.length } - > - {t(`ui.common.status.${user.status}`, user.status)} - - - -
- - {user.tenant?.name || user.companyCode || "-"} - - - {user.department || "-"} - -
-
- {/* Dynamic Metadata Cells */} + onChange={toggleSelectAll} + /> + + + {t( + "ui.admin.users.list.table.name_email", + "NAME / EMAIL", + )} + + + {t("ui.admin.users.list.table.role", "ROLE")} + + + {t("ui.admin.users.list.table.status", "STATUS")} + + + {t( + "ui.admin.users.list.table.tenant_dept", + "TENANT / DEPT", + )} + + {/* Dynamic Columns from Schema */} {userSchema.map( (field) => visibleColumns[field.key] !== false && ( - - {String(user.metadata?.[field.key] ?? "-")} - + + {field.label} + ), )} - - {new Date(user.createdAt).toLocaleDateString()} - - -
- - -
-
+ + {t("ui.admin.users.list.table.created", "CREATED")} + + + {t("ui.admin.users.list.table.actions", "ACTIONS")} +
- ))} - -
+ + + {query.isLoading && ( + + + {t("msg.common.loading", "로딩 중...")} + + + )} + {!query.isLoading && items.length === 0 && ( + + + {t( + "msg.admin.users.list.empty", + "검색 결과가 없습니다.", + )} + + + )} + {items.map((user) => ( + + + toggleSelectUser(user.id)} + /> + + +
+
+ +
+
+ {user.name} + + {user.email} + +
+
+
+ + + {t(`ui.admin.role.${user.role}`, user.role)} + + + + + {t(`ui.common.status.${user.status}`, user.status)} + + + +
+ + {user.tenant?.name || user.companyCode || "-"} + + + {user.department || "-"} + +
+
+ {/* Dynamic Metadata Cells */} + {userSchema.map( + (field) => + visibleColumns[field.key] !== false && ( + + {String(user.metadata?.[field.key] ?? "-")} + + ), + )} + + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+ +
{/* Bulk Action Bar */} @@ -607,6 +615,9 @@ function UserListPage() { + selectedUserIds.includes(u.id), + )} onSuccess={() => { query.refetch(); setSelectedUserIds([]); @@ -639,7 +650,7 @@ function UserListPage() { {/* Pagination */} {totalPages > 1 && ( -
+
)} + + {schemaWarnings && ( +
+
+ + {t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")} +
+
+ {schemaWarnings.missing.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_missing", + "대상 테넌트의 필수 필드가 누락되어 있습니다:", + )}{" "} + {schemaWarnings.missing.join(", ")} +

+ )} + {schemaWarnings.incompatible.length > 0 && ( +

+ {t( + "msg.admin.users.bulk.schema_incompatible", + "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:", + )}{" "} + {schemaWarnings.incompatible.join(", ")} +

+ )} +
+ +
+ )}
@@ -203,7 +314,11 @@ export function UserBulkMoveGroupModal({