1
0
forked from baron/baron-sso

개발자 신청 API 단일화 및 RP 권한 자동 부여 구현

This commit is contained in:
2026-04-22 13:07:02 +09:00
parent 4dc274a5d7
commit 2216d9c4e4
9 changed files with 573 additions and 115 deletions

View File

@@ -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<string, unknown> | 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<Record<number, string>>({});
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 (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved",
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
</h1>
<p className="text-muted-foreground mt-1">
{isSuperAdmin
? t(
"msg.dev.request.admin_desc",
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
)
: t(
"msg.dev.request.user_desc",
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
)}
</p>
</div>
{!isSuperAdmin && !hasActiveRequest && (
<Button onClick={() => setIsRequestModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
</Button>
)}
</div>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.dev.request.list.title", "신청 내역")}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<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.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 ? 6 : 4}
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.userId}
</div>
</TableCell>
)}
<TableCell>{req.organization}</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={req.reason}>
{req.reason}
</div>
{req.adminNotes && (
<div className="mt-1 text-xs text-amber-600 bg-amber-50 dark:bg-amber-900/20 p-1.5 rounded">
<strong>Admin:</strong> {req.adminNotes}
</div>
)}
</TableCell>
<TableCell>
<StatusBadge status={req.status} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(req.createdAt).toLocaleDateString()}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
{req.status === "pending" ? (
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
<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={rejectMutation.isPending}
>
<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={approveMutation.isPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("ui.common.approve", "승인")}
</Button>
</div>
</div>
) : (
<span className="text-muted-foreground text-xs italic">
{req.status === "approved"
? t("ui.common.completed", "처리 완료")
: t("ui.common.rejected", "반려됨")}
</span>
)}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}
onClose={() => setIsRequestModalOpen(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
setIsRequestModalOpen(false);
}}
tenantId={tenantId || ""}
initialName={(userProfile?.name as string) || ""}
initialOrg={(userProfile?.companyCode as string) || ""}
/>
</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>
);
default:
return <Badge variant="muted">{status}</Badge>;
}
}
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 (
<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}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="org">
{t("ui.dev.request.modal.org", "소속")}
</Label>
<Input
id="org"
value={organization}
onChange={(e) => setOrganization(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}
</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"
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>
);
}