1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/developer-request/DeveloperRequestPage.tsx

682 lines
25 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ClipboardCheck,
Clock,
Plus,
ShieldAlert,
X,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { PageHeader } from "../../../../common/core/components/page";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
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 {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
fetchDeveloperRequests,
fetchMyTenants,
rejectDeveloperRequest,
requestDeveloperAccess,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import {
type DeveloperAccessPage,
developerAccessPageOptions,
normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages";
export default function DeveloperRequestPage() {
const auth = useAuth();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
const { data: requests, isLoading } = useQuery({
queryKey: ["developer-requests"],
queryFn: () => fetchDeveloperRequests(),
enabled: !!auth.user?.access_token,
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: !!auth.user?.access_token,
});
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
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 cancelApprovalMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
cancelDeveloperRequestApproval(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
queryClient.invalidateQueries({ queryKey: ["developer-request"] });
alert(t("msg.dev.request.cancelled", "승인이 취소되었습니다."));
},
});
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] });
};
const handleCancelApproval = (id: number) => {
if (!adminNotes[id]) {
alert(
t(
"msg.dev.request.need_cancel_notes",
"승인 취소 사유를 입력해주세요.",
),
);
return;
}
cancelApprovalMutation.mutate({ id, adminNotes: adminNotes[id] });
};
if (isLoading) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
const hasActiveRequest = requests?.some((r) => r.status === "pending");
const approvedRequestCount =
requests?.filter((request) => request.status === "approved").length ?? 0;
const isActionPending =
approveMutation.isPending ||
rejectMutation.isPending ||
cancelApprovalMutation.isPending;
return (
<div className="space-y-6">
<PageHeader
icon={<ClipboardCheck size={20} />}
title={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
description={
isSuperAdmin
? t(
"msg.dev.request.admin_desc",
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
)
: t(
"msg.dev.request.user_desc",
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
)
}
actions={
!isSuperAdmin && !hasActiveRequest ? (
<Button onClick={() => setIsRequestModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
</Button>
) : null
}
/>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.dev.request.list.title", "신청 내역")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.request.list.approved_count",
"총 {{count}}명의 사용자가 승인되었습니다.",
{ count: approvedRequestCount },
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
{isSuperAdmin && (
<TableHead>
{t("ui.dev.request.table.user", "사용자")}
</TableHead>
)}
<TableHead>
{t("ui.dev.request.table.org", "소속")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.reason", "신청 사유")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.pages", "권한 페이지")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.status", "상태")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.date", "신청일")}
</TableHead>
{isSuperAdmin && (
<TableHead className="text-right">
{t("ui.dev.request.table.actions", "관리")}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{!requests || requests.length === 0 ? (
<TableRow>
<TableCell
colSpan={isSuperAdmin ? 7 : 5}
className="h-32 text-center text-muted-foreground"
>
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
</TableCell>
</TableRow>
) : (
requests.map((req) => (
<TableRow key={req.id}>
{isSuperAdmin && (
<TableCell className="font-medium">
<div>{req.name}</div>
<div className="text-xs text-muted-foreground">
{req.email || req.userId}
</div>
{(req.phone || req.role) && (
<div className="mt-1 text-xs text-muted-foreground">
{[req.phone, req.role]
.filter(Boolean)
.join(" / ")}
</div>
)}
</TableCell>
)}
<TableCell>
{req.organization?.trim() ||
t("ui.common.na", "없음")}
</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={req.reason}>
{req.reason}
</div>
{req.adminNotes && (
<div className="mt-1 rounded bg-amber-50 p-1.5 text-xs text-amber-600 dark:bg-amber-900/20">
<strong>Admin:</strong> {req.adminNotes}
</div>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{req.accessPages?.length ? (
normalizeDeveloperAccessPages(
req.accessPages,
).map((page) => (
<Badge key={page} variant="outline">
{developerAccessPageOptions.find(
(option) => option.value === page,
)?.label ?? page}
</Badge>
))
) : (
<Badge variant="secondary">
{t("ui.common.na", "없음")}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<StatusBadge status={req.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(req.createdAt).toLocaleDateString()}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
{req.status === "pending" ? (
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
<Input
placeholder={t(
"ui.dev.request.admin_notes_placeholder",
"메모 입력 (선택)...",
)}
className="h-8 text-xs"
value={adminNotes[req.id] || ""}
onChange={(e) =>
setAdminNotes({
...adminNotes,
[req.id]: e.target.value,
})
}
/>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleReject(req.id)}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t("ui.common.reject", "반려")}
</Button>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleApprove(req.id)}
disabled={isActionPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("ui.common.approve", "승인")}
</Button>
</div>
</div>
) : req.status === "approved" ? (
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
<Input
placeholder={t(
"ui.dev.request.cancel_notes_placeholder",
"승인 취소 사유 입력...",
)}
className="h-8 text-xs"
value={adminNotes[req.id] || ""}
onChange={(e) =>
setAdminNotes({
...adminNotes,
[req.id]: e.target.value,
})
}
/>
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleCancelApproval(req.id)}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t(
"ui.dev.request.cancel_approval",
"승인 취소",
)}
</Button>
</div>
) : (
<span className="text-xs italic text-muted-foreground">
{req.status === "cancelled"
? t(
"ui.dev.request.status.cancelled",
"승인 취소됨",
)
: t("ui.common.rejected", "반려됨")}
</span>
)}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}
onClose={() => setIsRequestModalOpen(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
setIsRequestModalOpen(false);
}}
tenantId={tenantId || ""}
initialName={profileName}
initialOrg={organizationName}
initialEmail={profileEmail}
initialPhone={profilePhone}
initialRole={profileRoleLabel}
/>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "pending":
return (
<Badge variant="warning" className="gap-1">
<Clock className="h-3 w-3" />
{t("ui.dev.request.status.pending", "대기 중")}
</Badge>
);
case "approved":
return (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{t("ui.dev.request.status.approved", "승인됨")}
</Badge>
);
case "rejected":
return (
<Badge variant="muted" className="gap-1">
<ShieldAlert className="h-3 w-3" />
{t("ui.dev.request.status.rejected", "반려됨")}
</Badge>
);
case "cancelled":
return (
<Badge variant="muted" className="gap-1">
<XCircle className="h-3 w-3" />
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
</Badge>
);
default:
return <Badge variant="muted">{status}</Badge>;
}
}
interface RequestAccessModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
tenantId: string;
initialName: string;
initialOrg: string;
initialEmail: string;
initialPhone: string;
initialRole: string;
}
function RequestAccessModal({
isOpen,
onClose,
onSuccess,
tenantId,
initialName,
initialOrg,
initialEmail,
initialPhone,
initialRole,
}: RequestAccessModalProps) {
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
"all",
]);
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
setAccessPages(["all"]);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({
mutationFn: requestDeveloperAccess,
onSuccess: () => {
onSuccess();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
name,
organization,
reason,
tenantId,
accessPages: normalizeDeveloperAccessPageSelection(accessPages),
});
};
const handleAccessPageToggle = (page: DeveloperAccessPage) => {
setAccessPages((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]);
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div>
<h2 className="text-xl font-bold tracking-tight">
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(
"msg.dev.request.modal.desc",
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">
{t("ui.dev.request.modal.name", "성함")}
</Label>
<Input
id="name"
value={name}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="org">
{t("ui.dev.request.modal.org", "소속")}
</Label>
<Input
id="org"
value={organizationDisplay}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.dev.request.modal.email", "이메일")}
</Label>
<Input
id="email"
value={initialEmail}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">
{t("ui.dev.request.modal.phone", "전화번호")}
</Label>
<Input
id="phone"
value={initialPhone}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="role">
{t("ui.dev.request.modal.role", "역할")}
</Label>
<Input
id="role"
value={initialRole}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-3">
<Label>
{t("ui.dev.request.modal.pages", "권한 페이지")}{" "}
<span className="text-destructive">*</span>
</Label>
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
{developerAccessPageOptions.map((option) => {
const checked =
option.value === "all"
? accessPages.includes("all")
: accessPages.includes(option.value);
return (
<label
key={option.value}
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
>
<input
type="checkbox"
checked={checked}
onChange={() => handleAccessPageToggle(option.value)}
/>
<span className="font-medium">{option.label}</span>
</label>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.request.modal.pages_hint",
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
)}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t(
"ui.dev.request.modal.reason_placeholder",
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
)}
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
required
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="submit"
disabled={mutation.isPending}
className="px-8 font-bold"
>
{mutation.isPending
? t("ui.common.submitting", "제출 중...")
: t("ui.common.submit", "신청하기")}
</Button>
</div>
</form>
</div>
</div>
);
}