From 3e335eb9cfc0270f8ed7dc016db786c054f035e3 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Mar 2026 13:40:50 +0900 Subject: [PATCH] feat: add schema tab access control and user password generator --- .../tenants/routes/TenantDetailPage.tsx | 32 ++++--- .../tenants/routes/TenantSchemaPage.tsx | 34 ++++++- .../src/features/users/UserDetailPage.tsx | 94 +++++++++++++++++-- 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 44bad3fc..ac77a3ac 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; -import { fetchTenant } from "../../../lib/adminApi"; +import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; function TenantDetailPage() { @@ -16,6 +16,14 @@ function TenantDetailPage() { enabled: tenantId.length > 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/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 1c95d705..f9a8d04a 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -14,7 +14,7 @@ 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"; @@ -40,6 +40,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 (
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",