diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5fdd5e69..1984c339 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -290,7 +290,7 @@ func main() { auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache - adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo, developerService) + adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index b334746a..42ee1815 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,28 +1,23 @@ package handler import ( - "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" - "log/slog" "runtime" - "strconv" "time" "github.com/gofiber/fiber/v2" ) type AdminHandler struct { - Keto service.KetoService - KetoOutbox repository.KetoOutboxRepository - DeveloperSvc *service.DeveloperService + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository } -func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository, developerSvc *service.DeveloperService) *AdminHandler { +func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { return &AdminHandler{ - Keto: keto, - KetoOutbox: ketoOutbox, - DeveloperSvc: developerSvc, + Keto: keto, + KetoOutbox: ketoOutbox, } } @@ -49,80 +44,3 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(stats) } - -func (h *AdminHandler) ListDeveloperRequests(c *fiber.Ctx) error { - status := c.Query("status") - requests, err := h.DeveloperSvc.ListRequests(c.Context(), status) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, err.Error()) - } - return c.JSON(requests) -} - -func (h *AdminHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return errorJSON(c, fiber.StatusBadRequest, "invalid request id") - } - - var reqBody struct { - AdminNotes string `json:"adminNotes"` - } - if err := c.BodyParser(&reqBody); err != nil { - return errorJSON(c, fiber.StatusBadRequest, "invalid request body") - } - - // 1. Get request to know userID and tenantID - devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details") - } - - // 2. Approve in DB - if err := h.DeveloperSvc.ApproveRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { - return errorJSON(c, fiber.StatusInternalServerError, err.Error()) - } - - // 3. Grant Keto Permissions via Outbox - if h.KetoOutbox != nil { - subject := "User:" + devReq.UserID - permissions := []string{"view_dev_console", "grant_dev_permissions"} - - for _, relation := range permissions { - err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: devReq.TenantID, - Relation: relation, - Subject: subject, - Action: domain.KetoOutboxActionCreate, - }) - if err != nil { - slog.Warn("failed to create keto outbox for developer approval", "relation", relation, "userID", devReq.UserID, "error", err) - } - } - } - - return c.JSON(fiber.Map{"status": "ok"}) -} - -func (h *AdminHandler) RejectDeveloperRequest(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - return errorJSON(c, fiber.StatusBadRequest, "invalid request id") - } - - var reqBody struct { - AdminNotes string `json:"adminNotes"` - } - if err := c.BodyParser(&reqBody); err != nil { - return errorJSON(c, fiber.StatusBadRequest, "invalid request body") - } - - if err := h.DeveloperSvc.RejectRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { - return errorJSON(c, fiber.StatusInternalServerError, err.Error()) - } - - return c.JSON(fiber.Map{"status": "ok"}) -} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 5282b667..4a3ce284 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -15,6 +15,7 @@ import ( "log/slog" "net/http" "os" + "strconv" "strings" "time" @@ -2865,3 +2866,105 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { return c.JSON(status) } + +func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + + role := normalizeUserRole(profile.Role) + status := c.Query("status") + + userID := profile.ID + if role == domain.RoleSuperAdmin { + // Super Admin can see everyone's requests + userID = "" + } + + requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(requests) +} + +func (h *DevHandler) ApproveDeveloperRequest(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 request 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 request details") + } + + if err := h.DeveloperSvc.ApproveRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + // Grant Keto Permissions + if h.KetoOutbox != nil { + subject := "User:" + devReq.UserID + permissions := []string{"view_dev_console", "grant_dev_permissions"} + + for _, relation := range permissions { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: devReq.TenantID, + Relation: relation, + Subject: subject, + Action: domain.KetoOutboxActionCreate, + }) + } + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *DevHandler) RejectDeveloperRequest(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 request id") + } + + var reqBody struct { + AdminNotes string `json:"adminNotes"` + } + if err := c.BodyParser(&reqBody); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if err := h.DeveloperSvc.RejectRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} diff --git a/backend/internal/service/developer_service.go b/backend/internal/service/developer_service.go index 8097b023..6040e83a 100644 --- a/backend/internal/service/developer_service.go +++ b/backend/internal/service/developer_service.go @@ -48,9 +48,12 @@ func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain return &req, nil } -func (s *DeveloperService) ListRequests(ctx context.Context, status string) ([]domain.DeveloperRequest, error) { +func (s *DeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) { var requests []domain.DeveloperRequest query := s.db.WithContext(ctx) + if userID != "" { + query = query.Where("user_id = ?", userID) + } if status != "" { query = query.Where("status = ?", status) } diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index 18272285..03cd6148 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -9,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage"; 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 ProfilePage from "../features/profile/ProfilePage"; export const router = createBrowserRouter( @@ -38,6 +39,7 @@ export const router = createBrowserRouter( path: "clients/:id/relationships", element: , }, + { path: "developer-requests", 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 9dcaa72c..be1da833 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { BadgeCheck, ChevronDown, + ClipboardCheck, LogOut, Moon, NotebookTabs, @@ -29,6 +30,12 @@ const navItems = [ to: "/clients", icon: ShieldHalf, }, + { + labelKey: "ui.dev.nav.developer_request", + labelFallback: "개발자 권한 신청", + to: "/developer-requests", + icon: ClipboardCheck, + }, { labelKey: "ui.dev.nav.audit_logs", labelFallback: "Audit Logs", diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 1f75d072..e3a10a75 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -2,7 +2,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { BookOpenText, - Clock, Filter, Plus, Search, @@ -41,7 +40,6 @@ import { } from "../../components/ui/table"; import { Textarea } from "../../components/ui/textarea"; import { - type DeveloperRequest, fetchClients, fetchDevStats, fetchDeveloperRequestStatus, @@ -78,7 +76,6 @@ function ClientsPage() { }); const { - data: requestStatus, isLoading: isLoadingRequest, refetch: refetchRequest, } = useQuery({ @@ -176,8 +173,6 @@ function ClientsPage() { ); } - const devStatus = (requestStatus as DeveloperRequest)?.status || "none"; - return (
@@ -403,11 +398,11 @@ function ClientsPage() { "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", )}

- {role === "user" && devStatus === "none" && ( + {role === "user" && ( )} - {role === "user" && devStatus === "pending" && ( -

- - {t("ui.dev.request.pending.title", "심사 진행 중")} -

- )} - {role === "user" && devStatus === "rejected" && ( - - )}
diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx new file mode 100644 index 00000000..b2a6971f --- /dev/null +++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx @@ -0,0 +1,416 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2, + Clock, + Plus, + ShieldAlert, + X, + XCircle, +} from "lucide-react"; +import { useState } from "react"; +import { useAuth } from "react-oidc-context"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + 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 { + approveDeveloperRequest, + fetchDeveloperRequests, + rejectDeveloperRequest, + requestDeveloperAccess, +} from "../../lib/devApi"; +import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; + +export default function DeveloperRequestPage() { + const auth = useAuth(); + const queryClient = useQueryClient(); + const userProfile = auth.user?.profile as Record | undefined; + const role = resolveProfileRole(userProfile); + const isSuperAdmin = role === "super_admin"; + const tenantId = userProfile?.tenant_id as string | undefined; + + const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); + const [adminNotes, setAdminNotes] = useState>({}); + + const { data: requests, isLoading } = useQuery({ + queryKey: ["developer-requests"], + queryFn: () => fetchDeveloperRequests(), + enabled: !!auth.user?.access_token, + }); + + const approveMutation = useMutation({ + mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) => + approveDeveloperRequest(id, adminNotes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["developer-requests"] }); + alert(t("msg.dev.request.approved", "승인되었습니다.")); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) => + rejectDeveloperRequest(id, adminNotes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["developer-requests"] }); + alert(t("msg.dev.request.rejected", "반려되었습니다.")); + }, + }); + + const handleApprove = (id: number) => { + approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" }); + }; + + const handleReject = (id: number) => { + if (!adminNotes[id]) { + alert(t("msg.dev.request.need_notes", "반려 사유를 입력해주세요.")); + return; + } + rejectMutation.mutate({ id, adminNotes: adminNotes[id] }); + }; + + if (isLoading) { + return ( +
+ {t("ui.common.loading", "Loading...")} +
+ ); + } + + const hasActiveRequest = requests?.some( + (r) => r.status === "pending" || r.status === "approved", + ); + + return ( +
+
+
+

+ {t("ui.dev.nav.developer_request", "개발자 권한 신청")} +

+

+ {isSuperAdmin + ? t( + "msg.dev.request.admin_desc", + "사용자들의 개발자 권한 신청 내역을 관리합니다.", + ) + : t( + "msg.dev.request.user_desc", + "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.", + )} +

+
+ {!isSuperAdmin && !hasActiveRequest && ( + + )} +
+ + + + + {t("ui.dev.request.list.title", "신청 내역")} + + + + + + + {isSuperAdmin && ( + {t("ui.dev.request.table.user", "사용자")} + )} + {t("ui.dev.request.table.org", "소속")} + + {t("ui.dev.request.table.reason", "신청 사유")} + + {t("ui.dev.request.table.status", "상태")} + {t("ui.dev.request.table.date", "신청일")} + {isSuperAdmin && ( + + {t("ui.dev.request.table.actions", "관리")} + + )} + + + + {!requests || requests.length === 0 ? ( + + + {t("msg.dev.request.empty", "신청 내역이 없습니다.")} + + + ) : ( + requests.map((req) => ( + + {isSuperAdmin && ( + +
{req.name}
+
+ {req.userId} +
+
+ )} + {req.organization} + +
+ {req.reason} +
+ {req.adminNotes && ( +
+ Admin: {req.adminNotes} +
+ )} +
+ + + + + {new Date(req.createdAt).toLocaleDateString()} + + {isSuperAdmin && ( + + {req.status === "pending" ? ( +
+ + setAdminNotes({ + ...adminNotes, + [req.id]: e.target.value, + }) + } + /> +
+ + +
+
+ ) : ( + + {req.status === "approved" + ? t("ui.common.completed", "처리 완료") + : t("ui.common.rejected", "반려됨")} + + )} +
+ )} +
+ )) + )} +
+
+
+
+ + setIsRequestModalOpen(false)} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: ["developer-requests"] }); + setIsRequestModalOpen(false); + }} + tenantId={tenantId || ""} + initialName={(userProfile?.name as string) || ""} + initialOrg={(userProfile?.companyCode as string) || ""} + /> +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + switch (status) { + case "pending": + return ( + + + {t("ui.dev.request.status.pending", "대기 중")} + + ); + case "approved": + return ( + + + {t("ui.dev.request.status.approved", "승인됨")} + + ); + case "rejected": + return ( + + + {t("ui.dev.request.status.rejected", "반려됨")} + + ); + default: + return {status}; + } +} + + +interface RequestAccessModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + tenantId: string; + initialName: string; + initialOrg: string; +} + +function RequestAccessModal({ + isOpen, + onClose, + onSuccess, + tenantId, + initialName, + initialOrg, +}: RequestAccessModalProps) { + const [name, setName] = useState(initialName); + const [organization, setOrganization] = useState(initialOrg); + const [reason, setReason] = useState(""); + + const mutation = useMutation({ + mutationFn: requestDeveloperAccess, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate({ + name, + organization, + reason, + tenantId, + }); + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

+ {t("ui.dev.request.modal.title", "개발자 등록 신청")} +

+

+ {t( + "msg.dev.request.modal.desc", + "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.", + )} +

+
+ +
+ +
+
+
+ + setName(e.target.value)} + required + /> +
+
+ + setOrganization(e.target.value)} + required + /> +
+
+ +