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, type DevAssignableUser, fetchDeveloperGrants, fetchDevUser, fetchDevUsers, revokeDeveloperGrant, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { type DeveloperAccessPage, developerAccessPagesToLabel, getDeveloperAccessPageOptions, normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPages, } from "../developer-access/developerAccessPages"; 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 developerAccessPageOptions = getDeveloperAccessPageOptions(); const [userSearch, setUserSearch] = useState(""); const deferredUserSearch = useDeferredValue(userSearch.trim()); const [selectedUser, setSelectedUser] = useState( null, ); const [selectedAccessPages, setSelectedAccessPages] = useState< DeveloperAccessPage[] >(["all"]); 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(""); setSelectedAccessPages(["all"]); 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() || selectedUserDetail?.tenantSlug?.trim() || selectedUserDetail?.companyCode?.trim() || ""; createGrantMutation.mutate({ userId: selectedUser.id, tenantId, reason: grantNotes.trim() || "직접 부여", adminNotes: grantNotes.trim(), accessPages: normalizeDeveloperAccessPageSelection(selectedAccessPages), }); }; const handleSelectUser = (user: DevAssignableUser) => { setSelectedUser(user); setUserSearch(formatUserLabel(user)); setSelectedAccessPages(["all"]); }; const handleAccessPageToggle = (page: DeveloperAccessPage) => { setSelectedAccessPages((current) => { if (page === "all") { return ["all"]; } const withoutAll = current.filter((item) => item !== "all"); if (withoutAll.includes(page)) { const next = withoutAll.filter((item) => item !== page); return next.length > 0 ? next : ["all"]; } return normalizeDeveloperAccessPageSelection([...withoutAll, page]); }); }; 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", "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다.", )}
{developerAccessPageOptions.map((option) => { const checked = option.value === "all" ? selectedAccessPages.includes("all") : selectedAccessPages.includes(option.value); return ( ); })}

{t( "msg.dev.grants.pages_hint", "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.", )}

{t("ui.dev.grants.admin_notes", "부여 사유")}
{t( "msg.dev.grants.admin_notes_description", "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다.", )}