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 type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
|
Clock,
|
||||||
Filter,
|
Filter,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -36,7 +39,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} 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 { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -45,9 +55,10 @@ function ClientsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
const role = resolveProfileRole(
|
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||||
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 canCreateClient = role !== "user" && role !== "tenant_member";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -66,10 +77,21 @@ function ClientsPage() {
|
|||||||
enabled: hasAccessToken,
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||||
|
|
||||||
const clients = data?.items || [];
|
const clients = data?.items || [];
|
||||||
|
|
||||||
@@ -128,7 +150,7 @@ function ClientsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isLoading = isLoadingClients || isLoadingStats;
|
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
||||||
|
|
||||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +176,8 @@ function ClientsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const devStatus = (requestStatus as DeveloperRequest)?.status || "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
@@ -372,12 +396,44 @@ function ClientsPage() {
|
|||||||
"조회 가능한 RP가 없습니다.",
|
"조회 가능한 RP가 없습니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">
|
<div className="text-sm space-y-2">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.empty_detail",
|
"msg.dev.clients.empty_detail",
|
||||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
||||||
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -552,6 +608,145 @@ function ClientsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,3 +386,46 @@ export async function fetchMyTenants() {
|
|||||||
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
||||||
return data;
|
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