forked from baron/baron-sso
클라이언트 빈 목록 대응 개발자 신청 인라인 링크 및 모달 구현
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
BookOpenText,
|
||||
Clock,
|
||||
Filter,
|
||||
Plus,
|
||||
Search,
|
||||
ServerCog,
|
||||
ShieldHalf,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,7 +39,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
type DeveloperRequest,
|
||||
fetchClients,
|
||||
fetchDevStats,
|
||||
fetchDeveloperRequestStatus,
|
||||
requestDeveloperAccess,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -45,9 +55,10 @@ function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const role = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
|
||||
const canCreateClient = role !== "user" && role !== "tenant_member";
|
||||
|
||||
const {
|
||||
@@ -66,10 +77,21 @@ function ClientsPage() {
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const {
|
||||
data: requestStatus,
|
||||
isLoading: isLoadingRequest,
|
||||
refetch: refetchRequest,
|
||||
} = useQuery({
|
||||
queryKey: ["developer-request", tenantId],
|
||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||
enabled: hasAccessToken && role === "user",
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
|
||||
const clients = data?.items || [];
|
||||
|
||||
@@ -128,7 +150,7 @@ function ClientsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = isLoadingClients || isLoadingStats;
|
||||
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
||||
|
||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||
return (
|
||||
@@ -154,6 +176,8 @@ function ClientsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const devStatus = (requestStatus as DeveloperRequest)?.status || "none";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card className="glass-panel">
|
||||
@@ -372,12 +396,44 @@ function ClientsPage() {
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
)}
|
||||
</p>
|
||||
{role === "user" && devStatus === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => setIsRequestModalOpen(true)}
|
||||
>
|
||||
{t(
|
||||
"ui.dev.welcome.btn_request",
|
||||
"개발자 등록 신청하기",
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
{role === "user" && devStatus === "pending" && (
|
||||
<p className="text-warning flex items-center justify-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("ui.dev.request.pending.title", "심사 진행 중")}
|
||||
</p>
|
||||
)}
|
||||
{role === "user" && devStatus === "rejected" && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-destructive font-bold hover:underline"
|
||||
onClick={() => setIsRequestModalOpen(true)}
|
||||
>
|
||||
{t(
|
||||
"ui.dev.request.rejected.btn_retry",
|
||||
"신청 반려 (다시 신청하기)",
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -552,6 +608,145 @@ function ClientsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<RequestAccessModal
|
||||
isOpen={isRequestModalOpen}
|
||||
onClose={() => setIsRequestModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetchRequest();
|
||||
setIsRequestModalOpen(false);
|
||||
}}
|
||||
tenantId={tenantId || ""}
|
||||
initialName={(userProfile?.name as string) || ""}
|
||||
initialOrg={(userProfile?.companyCode as string) || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,3 +386,46 @@ export async function fetchMyTenants() {
|
||||
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Developer Request API ---
|
||||
export type DeveloperRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "none";
|
||||
|
||||
export type DeveloperRequest = {
|
||||
id: number;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
status: DeveloperRequestStatus;
|
||||
adminNotes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchDeveloperRequestStatus(tenantId?: string) {
|
||||
const { data } = await apiClient.get<DeveloperRequest | { status: "none" }>(
|
||||
"/dev/developer-request",
|
||||
{
|
||||
params: { tenantId },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function requestDeveloperAccess(payload: {
|
||||
name: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
tenantId: string;
|
||||
}) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
"/dev/developer-request",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user