1
0
forked from baron/baron-sso

클라이언트 빈 목록 대응 개발자 신청 인라인 링크 및 모달 구현

This commit is contained in:
2026-04-22 11:42:02 +09:00
parent 4139bb7064
commit 4dc274a5d7
2 changed files with 249 additions and 11 deletions

View File

@@ -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>
);
}

View File

@@ -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;
}