From 0f11173739f526f01e99bbc4fecf678b400bc795 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 9 Jun 2026 09:28:20 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EB=B6=80=EC=97=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 3 + backend/internal/handler/dev_handler.go | 155 ++++- backend/internal/service/developer_service.go | 9 +- devfront/src/app/routes.tsx | 2 + devfront/src/components/layout/AppLayout.tsx | 14 +- .../src/features/coverage/pageSmoke.test.tsx | 65 ++ .../developer-grants/DeveloperGrantsPage.tsx | 618 ++++++++++++++++++ devfront/src/lib/devApi.ts | 30 + devfront/src/locales/en.toml | 52 ++ devfront/src/locales/ko.toml | 52 ++ devfront/src/locales/template.toml | 53 ++ 11 files changed, 1050 insertions(+), 3 deletions(-) create mode 100644 devfront/src/features/developer-grants/DeveloperGrantsPage.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 63a1f033..df9fafac 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -861,6 +861,9 @@ func main() { dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest) dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest) dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval) + dev.Get("/developer-grants", devHandler.ListDeveloperGrants) + dev.Post("/developer-grants", devHandler.CreateDeveloperGrant) + dev.Post("/developer-grants/:id/revoke", devHandler.RevokeDeveloperGrant) // Webhook for Kratos courier (HTTP delivery) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 1f12384e..e5d771e5 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -4049,7 +4049,7 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { userID = "" } - requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status) + requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -4057,6 +4057,159 @@ func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { return c.JSON(requests) } +func (h *DevHandler) ListDeveloperGrants(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + tenantID := strings.TrimSpace(c.Query("tenantId")) + grants, err := h.DeveloperSvc.ListRequests(c.Context(), "", domain.DeveloperRequestStatusApproved, tenantID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(grants) +} + +func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + var reqBody struct { + UserID string `json:"userId"` + TenantID string `json:"tenantId"` + Reason string `json:"reason"` + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + userID := strings.TrimSpace(reqBody.UserID) + tenantID := strings.TrimSpace(reqBody.TenantID) + if userID == "" || tenantID == "" { + return errorJSON(c, fiber.StatusBadRequest, "userId and tenantId are required") + } + if h.KratosAdmin == nil || h.TenantSvc == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "required services are unavailable") + } + + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err != nil || identity == nil { + return errorJSON(c, fiber.StatusNotFound, "user not found") + } + tenant, err := h.TenantSvc.GetTenant(c.Context(), tenantID) + if err != nil || tenant == nil { + return errorJSON(c, fiber.StatusNotFound, "tenant not found") + } + + name := strings.TrimSpace(extractTraitString(identity.Traits, "name")) + if name == "" { + name = userID + } + organization := strings.TrimSpace(tenant.Name) + if organization == "" { + organization = tenantID + } + email := strings.TrimSpace(extractTraitString(identity.Traits, "email")) + phone := strings.TrimSpace(extractTraitString(identity.Traits, "phone")) + role := normalizeUserRole(extractTraitString(identity.Traits, "role")) + if role == "" { + role = domain.RoleUser + } + reason := strings.TrimSpace(reqBody.Reason) + if reason == "" { + reason = "직접 부여" + } + + existing, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + if existing != nil { + switch existing.Status { + case domain.DeveloperRequestStatusApproved: + h.ensureDeveloperGrantRelation(c, userID, tenantID) + return c.JSON(existing) + case domain.DeveloperRequestStatusPending: + if err := h.DeveloperSvc.ApproveRequest(c.Context(), existing.ID, reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + h.ensureDeveloperGrantRelation(c, userID, tenantID) + existing.Status = domain.DeveloperRequestStatusApproved + existing.AdminNotes = reqBody.AdminNotes + return c.JSON(existing) + } + } + + grant := domain.DeveloperRequest{ + UserID: userID, + TenantID: tenantID, + Name: name, + Organization: organization, + Email: email, + Phone: phone, + Role: role, + Reason: reason, + Status: domain.DeveloperRequestStatusApproved, + AdminNotes: strings.TrimSpace(reqBody.AdminNotes), + } + if err := h.DeveloperSvc.CreateGrant(c.Context(), grant); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + h.ensureDeveloperGrantRelation(c, userID, tenantID) + + return c.Status(fiber.StatusCreated).JSON(grant) +} + +func (h *DevHandler) RevokeDeveloperGrant(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") + } + + idStr := c.Params("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid grant id") + } + + var reqBody struct { + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch grant details") + } + if devReq.Status != domain.DeveloperRequestStatusApproved { + return errorJSON(c, fiber.StatusBadRequest, "only approved grants can be revoked") + } + + if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) + + return c.JSON(fiber.Map{"status": "ok"}) +} + func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go index 7dc57f70..cf6d0d49 100644 --- a/backend/internal/service/developer_service.go +++ b/backend/internal/service/developer_service.go @@ -30,6 +30,10 @@ func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.Develop return s.db.WithContext(ctx).Create(&req).Error } +func (s *DeveloperService) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error { + return s.db.WithContext(ctx).Create(&req).Error +} + func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) { var req domain.DeveloperRequest err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error @@ -51,7 +55,7 @@ func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain return &req, nil } -func (s *DeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) { +func (s *DeveloperService) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) { var requests []domain.DeveloperRequest query := s.db.WithContext(ctx) if userID != "" { @@ -60,6 +64,9 @@ func (s *DeveloperService) ListRequests(ctx context.Context, userID, status stri if status != "" { query = query.Where("status = ?", status) } + if tenantID != "" { + query = query.Where("tenant_id = ?", tenantID) + } err := query.Order("created_at DESC").Find(&requests).Error return requests, err } diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index b42bc4da..14626658 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -10,6 +10,7 @@ import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientRelationsPage from "../features/clients/ClientRelationsPage"; import ClientsPage from "../features/clients/ClientsPage"; import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage"; +import DeveloperGrantsPage from "../features/developer-grants/DeveloperGrantsPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import ProfilePage from "../features/profile/ProfilePage"; import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig"; @@ -26,6 +27,7 @@ const devFrontAppChildren: RouteObject[] = [ element: , }, { path: "developer-requests", element: }, + { path: "developer-grants", element: }, { path: "audit-logs", element: }, { path: "profile", element: }, ]; diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 3572cc18..be7cd1f1 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { ClipboardCheck, LayoutDashboard, LogOut, + KeyRound, Moon, NotebookTabs, ShieldHalf, @@ -39,7 +40,7 @@ import { Toaster } from "../ui/toaster"; const LOCALE_CHANGED_EVENT = "baron_locale_changed"; -const navItems: ShellSidebarNavItem[] = [ +const baseNavItems: ShellSidebarNavItem[] = [ { labelKey: "ui.dev.nav.overview", labelFallback: "Overview", @@ -350,6 +351,17 @@ function AppLayout() { auth.user?.profile as Record | undefined, ); const displayRoleKey = profile?.role || currentRole; + const navItems = displayRoleKey === "super_admin" + ? [ + ...baseNavItems, + { + labelKey: "ui.dev.nav.developer_grants", + labelFallback: "Developer Access Grants", + to: "/developer-grants", + icon: KeyRound, + }, + ] + : baseNavItems; const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx index f94072c1..7d653714 100644 --- a/devfront/src/features/coverage/pageSmoke.test.tsx +++ b/devfront/src/features/coverage/pageSmoke.test.tsx @@ -11,12 +11,16 @@ import ClientRelationsPage from "../clients/ClientRelationsPage"; import ClientsPage from "../clients/ClientsPage"; import { ClientFederationPage } from "../clients/routes/ClientFederationPage"; import DeveloperRequestPage from "../developer-request/DeveloperRequestPage"; +import DeveloperGrantsPage from "../developer-grants/DeveloperGrantsPage"; import GlobalOverviewPage from "../overview/GlobalOverviewPage"; import ProfilePage from "../profile/ProfilePage"; import { approveDeveloperRequest, cancelDeveloperRequestApproval, + createDeveloperGrant, + fetchDeveloperGrants, rejectDeveloperRequest, + revokeDeveloperGrant, } from "../../lib/devApi"; const authProfile = { @@ -195,6 +199,29 @@ vi.mock("../../lib/devApi", () => ({ }, ], })), + fetchDevUser: vi.fn(async () => ({ + id: "user-2", + email: "editor@example.com", + name: "Editor User", + phone: "010-1111-2222", + role: "user", + status: "active", + tenant: { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + status: "active", + description: "", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + tenantSlug: "hanmac", + companyCode: "HANMAC", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + })), addClientRelation: vi.fn(async () => ({ relation: "admins", subject: "User:user-2", @@ -290,6 +317,24 @@ vi.mock("../../lib/devApi", () => ({ updatedAt: "2026-05-01T00:00:00Z", }, ]), + fetchTenants: vi.fn(async () => ({ + items: [ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + status: "active", + description: "", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ], + limit: 1000, + offset: 0, + total: 1, + })), fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })), requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })), fetchDeveloperRequests: vi.fn(async () => [ @@ -319,9 +364,26 @@ vi.mock("../../lib/devApi", () => ({ updatedAt: "2026-05-02T00:00:00Z", }, ]), + fetchDeveloperGrants: vi.fn(async () => [ + { + id: 3, + userId: "user-5", + tenantId: "tenant-1", + name: "Granted User", + organization: "Hanmac", + email: "granted@example.com", + reason: "Direct grant", + status: "approved", + adminNotes: "Manual grant", + createdAt: "2026-05-03T00:00:00Z", + updatedAt: "2026-05-03T00:00:00Z", + }, + ]), approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })), rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })), cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })), + createDeveloperGrant: vi.fn(async () => ({ status: "approved" })), + revokeDeveloperGrant: vi.fn(async () => ({ status: "ok" })), })); vi.mock("../auth/authApi", () => ({ @@ -408,6 +470,9 @@ describe("devfront coverage smoke pages", () => { const requests = await renderPage(); expect(requests.textContent).toContain("Requester"); + const grants = await renderPage(); + expect(grants.textContent).toContain("개발자 권한 부여"); + const profile = await renderPage(); expect(profile.textContent).toContain("Dev Admin"); }); diff --git a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx new file mode 100644 index 00000000..ae904990 --- /dev/null +++ b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx @@ -0,0 +1,618 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { KeyRound, Plus, Search, ShieldCheck, X } from "lucide-react"; +import { useDeferredValue, useMemo, useState } from "react"; +import { useAuth } from "react-oidc-context"; +import { PageHeader } from "../../../../common/core/components/page"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import { Textarea } from "../../components/ui/textarea"; +import { toast } from "../../components/ui/use-toast"; +import { + createDeveloperGrant, + fetchDeveloperGrants, + fetchDevUsers, + fetchDevUser, + revokeDeveloperGrant, + type DevAssignableUser, +} from "../../lib/devApi"; +import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; +import { fetchMe } from "../auth/authApi"; + +function formatUserLabel(user: DevAssignableUser) { + const primary = user.name.trim() || user.email.trim(); + return `${primary} (${user.email.trim()})`; +} + +export default function DeveloperGrantsPage() { + const auth = useAuth(); + const queryClient = useQueryClient(); + const hasAccessToken = Boolean(auth.user?.access_token); + const userProfile = auth.user?.profile as Record | undefined; + const role = resolveProfileRole(userProfile); + + const { data: me, isLoading: isLoadingMe } = useQuery({ + queryKey: ["userMe"], + queryFn: fetchMe, + enabled: hasAccessToken, + }); + const profileRole = me?.role?.trim() || role; + const isSuperAdmin = profileRole === "super_admin"; + + const [userSearch, setUserSearch] = useState(""); + const deferredUserSearch = useDeferredValue(userSearch.trim()); + const [selectedUser, setSelectedUser] = useState( + null, + ); + const [grantNotes, setGrantNotes] = useState(""); + const [adminNotes, setAdminNotes] = useState>({}); + + const { + data: userSearchData, + isFetching: isUserSearchLoading, + } = useQuery({ + queryKey: ["developer-grant-users", deferredUserSearch], + queryFn: () => fetchDevUsers(deferredUserSearch, 10), + enabled: + hasAccessToken && + isSuperAdmin && + deferredUserSearch.length > 0 && + selectedUser == null, + }); + + const { + data: selectedUserDetail, + isFetching: isSelectedUserDetailLoading, + } = useQuery({ + queryKey: ["developer-grant-user", selectedUser?.id], + queryFn: () => fetchDevUser(selectedUser?.id || ""), + enabled: hasAccessToken && isSuperAdmin && selectedUser != null, + }); + + const { + data: grants, + isLoading: isLoadingGrants, + error: grantsError, + } = useQuery({ + queryKey: ["developer-grants"], + queryFn: () => fetchDeveloperGrants(), + enabled: hasAccessToken && isSuperAdmin, + }); + + const grantList = grants ?? []; + + const filteredGrantedUsers = useMemo(() => { + return [...grantList].sort((a, b) => { + const tenantCompare = a.organization.localeCompare(b.organization); + if (tenantCompare !== 0) { + return tenantCompare; + } + return a.name.localeCompare(b.name); + }); + }, [grantList]); + + const createGrantMutation = useMutation({ + mutationFn: createDeveloperGrant, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["developer-grants"] }); + toast( + t( + "msg.dev.grants.create_success", + "개발자 권한이 직접 부여되었습니다.", + ), + "success", + ); + setSelectedUser(null); + setUserSearch(""); + setGrantNotes(""); + }, + onError: (err: AxiosError<{ error?: string }> | Error) => { + toast( + (err as AxiosError<{ error?: string }>).response?.data?.error || + (err as Error).message || + t("msg.common.error", "오류가 발생했습니다."), + "error", + ); + }, + }); + + const revokeGrantMutation = useMutation({ + mutationFn: ({ + id, + adminNotes, + }: { + id: number; + adminNotes: string; + }) => revokeDeveloperGrant(id, adminNotes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["developer-grants"] }); + toast( + t("msg.dev.grants.revoke_success", "개발자 권한이 회수되었습니다."), + "success", + ); + }, + onError: (err: AxiosError<{ error?: string }> | Error) => { + toast( + (err as AxiosError<{ error?: string }>).response?.data?.error || + (err as Error).message || + t("msg.common.error", "오류가 발생했습니다."), + "error", + ); + }, + }); + + if (isLoadingMe) { + return ( +
+ {t("ui.common.loading", "Loading...")} +
+ ); + } + + if (!isSuperAdmin) { + return ( +
+ } + title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")} + description={t( + "msg.dev.grants.forbidden_desc", + "이 화면은 super admin만 사용할 수 있습니다.", + )} + /> + + + {t( + "msg.dev.grants.forbidden", + "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다.", + )} + + +
+ ); + } + + const handleGrant = () => { + if (!selectedUser) { + toast( + t( + "msg.dev.grants.user_required", + "부여할 사용자를 선택해주세요.", + ), + "error", + ); + return; + } + const tenantId = selectedUserDetail?.tenant?.id?.trim() || ""; + if (!tenantId) { + toast( + t( + "msg.dev.grants.tenant_required", + "선택한 사용자의 현재 테넌트 정보를 확인할 수 없습니다.", + ), + "error", + ); + return; + } + + createGrantMutation.mutate({ + userId: selectedUser.id, + tenantId, + reason: grantNotes.trim() || "직접 부여", + adminNotes: grantNotes.trim(), + }); + }; + + const handleSelectUser = (user: DevAssignableUser) => { + setSelectedUser(user); + setUserSearch(formatUserLabel(user)); + }; + + return ( +
+ } + title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")} + description={t( + "msg.dev.grants.description", + "사용자에게 개발자 권한을 직접 부여하고, 부여된 권한을 회수할 수 있습니다.", + )} + actions={ + + {t("msg.dev.grants.count", "총 {{count}}건", { + count: filteredGrantedUsers.length, + })} + + } + /> + + + + + {t("ui.dev.grants.form.title", "직접 부여")} + + + {t( + "msg.dev.grants.form.description", + "사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.", + )} + + + +
+ + +
+ + {t("ui.dev.grants.user_section", "사용자 선택")} + + + {t("ui.dev.grants.input_section", "입력")} + +
+ + {t( + "msg.dev.grants.user_section_description", + "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다.", + )} + +
+ +
+ +
+ + { + setSelectedUser(null); + setUserSearch(event.target.value); + }} + /> +
+ {selectedUser && ( +

+ {t( + "msg.dev.grants.selected_user", + "선택된 사용자: {{user}}", + { user: formatUserLabel(selectedUser) }, + )} +

+ )} +
+ + {userSearch.trim() !== "" && selectedUser == null && ( +
+ {isUserSearchLoading ? ( +
+ {t( + "msg.dev.grants.search_loading", + "사용자를 찾는 중입니다...", + )} +
+ ) : (userSearchData?.items ?? []).length > 0 ? ( + (userSearchData?.items ?? []).map((user) => ( + + )) + ) : ( +
+ {t( + "msg.dev.grants.search_empty", + "검색 결과가 없습니다.", + )} +
+ )} +
+ )} +
+
+ + + +
+ + {t( + "ui.dev.grants.selected_info", + "선택된 사용자 정보", + )} + + + {t("ui.dev.grants.read_only", "읽기 전용")} + +
+ + {t( + "msg.dev.grants.selected_info_description", + "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다.", + )} + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + +
+ + {t("ui.dev.grants.admin_notes", "부여 사유")} + +
+ + {t( + "msg.dev.grants.admin_notes_description", + "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다.", + )} + +
+ +