forked from baron/baron-sso
개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선
This commit is contained in:
@@ -529,12 +529,16 @@ function ClientGeneralPage() {
|
||||
onError: (err) => {
|
||||
const axiosError = err as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.save_forbidden",
|
||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
),
|
||||
"error",
|
||||
alert(
|
||||
isCreate
|
||||
? t(
|
||||
"msg.dev.clients.general.create_forbidden",
|
||||
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.general.save_forbidden",
|
||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,6 @@ function ClientsPage() {
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
|
||||
const canCreateClient = role !== "user" && role !== "tenant_member";
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingClients,
|
||||
@@ -76,6 +74,7 @@ function ClientsPage() {
|
||||
});
|
||||
|
||||
const {
|
||||
data: requestStatus,
|
||||
isLoading: isLoadingRequest,
|
||||
refetch: refetchRequest,
|
||||
} = useQuery({
|
||||
@@ -84,6 +83,16 @@ function ClientsPage() {
|
||||
enabled: hasAccessToken && role === "user",
|
||||
});
|
||||
|
||||
const canCreateClient =
|
||||
(role !== "user" && role !== "tenant_member") ||
|
||||
requestStatus?.status === "approved";
|
||||
const isDeveloperRequestPending = requestStatus?.status === "pending";
|
||||
const canRequestDeveloperAccess =
|
||||
role === "user" &&
|
||||
!isLoadingRequest &&
|
||||
!canCreateClient &&
|
||||
!isDeveloperRequestPending;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
@@ -106,6 +115,8 @@ function ClientsPage() {
|
||||
const totalClients = statsData?.total_clients ?? clients.length;
|
||||
const activeSessions = statsData?.active_sessions ?? 0;
|
||||
const authFailures = statsData?.auth_failures_24h ?? 0;
|
||||
const hasFilterResult = filteredClients.length > 0;
|
||||
const isFilteredOut = clients.length > 0 && !hasFilterResult;
|
||||
|
||||
type StatTone = "up" | "down" | "stable";
|
||||
type StatItem = {
|
||||
@@ -378,7 +389,7 @@ function ClientsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredClients.length === 0 && (
|
||||
{!hasFilterResult && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
@@ -386,19 +397,58 @@ function ClientsPage() {
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.empty",
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
)}
|
||||
{isFilteredOut
|
||||
? t(
|
||||
"msg.dev.clients.empty_filtered",
|
||||
"조건에 맞는 연동 앱이 없습니다.",
|
||||
)
|
||||
: canCreateClient
|
||||
? t(
|
||||
"msg.dev.clients.empty_can_create",
|
||||
"아직 등록된 연동 앱이 없습니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.empty",
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
)}
|
||||
{isFilteredOut
|
||||
? t(
|
||||
"msg.dev.clients.empty_filtered_detail",
|
||||
"검색어나 필터 조건을 변경해 보세요.",
|
||||
)
|
||||
: canCreateClient
|
||||
? t(
|
||||
"msg.dev.clients.empty_can_create_detail",
|
||||
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending_detail",
|
||||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
)}
|
||||
</p>
|
||||
{role === "user" && (
|
||||
{!isFilteredOut && canCreateClient && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
{t("ui.dev.clients.new", "연동 앱 추가")}
|
||||
</button>
|
||||
)}
|
||||
{!isFilteredOut && canRequestDeveloperAccess && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
approveDeveloperRequest,
|
||||
cancelDeveloperRequestApproval,
|
||||
fetchDeveloperRequests,
|
||||
rejectDeveloperRequest,
|
||||
requestDeveloperAccess,
|
||||
@@ -72,6 +73,16 @@ export default function DeveloperRequestPage() {
|
||||
},
|
||||
});
|
||||
|
||||
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] || "" });
|
||||
};
|
||||
@@ -84,6 +95,14 @@ export default function DeveloperRequestPage() {
|
||||
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">
|
||||
@@ -95,6 +114,10 @@ export default function DeveloperRequestPage() {
|
||||
const hasActiveRequest = requests?.some(
|
||||
(r) => r.status === "pending" || r.status === "approved",
|
||||
);
|
||||
const isActionPending =
|
||||
approveMutation.isPending ||
|
||||
rejectMutation.isPending ||
|
||||
cancelApprovalMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -211,7 +234,7 @@ export default function DeveloperRequestPage() {
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleReject(req.id)}
|
||||
disabled={rejectMutation.isPending}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.reject", "반려")}
|
||||
@@ -220,17 +243,44 @@ export default function DeveloperRequestPage() {
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleApprove(req.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.approve", "승인")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : req.status === "approved" ? (
|
||||
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
|
||||
<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-muted-foreground text-xs italic">
|
||||
{req.status === "approved"
|
||||
? t("ui.common.completed", "처리 완료")
|
||||
{req.status === "cancelled"
|
||||
? t("ui.dev.request.status.cancelled", "승인 취소됨")
|
||||
: t("ui.common.rejected", "반려됨")}
|
||||
</span>
|
||||
)}
|
||||
@@ -282,6 +332,13 @@ function StatusBadge({ status }: { status: string }) {
|
||||
{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>;
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ export type DeveloperRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "none";
|
||||
|
||||
export type DeveloperRequest = {
|
||||
@@ -461,3 +462,14 @@ export async function rejectDeveloperRequest(
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelDeveloperRequestApproval(
|
||||
id: number,
|
||||
adminNotes: string,
|
||||
) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/cancel-approval`,
|
||||
{ adminNotes },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -334,6 +334,12 @@ delete_error = "Failed to delete: {{error}}"
|
||||
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||
empty = "No RPs are available."
|
||||
empty_detail = "RPs will appear here when a relationship is assigned to your account."
|
||||
empty_can_create = "No linked apps have been registered yet."
|
||||
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
|
||||
empty_filtered = "No linked apps match the current filters."
|
||||
empty_filtered_detail = "Try changing the search text or filters."
|
||||
empty_pending = "Your developer access request is under review."
|
||||
empty_pending_detail = "You can add linked apps after a super admin approves it."
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = "No consents found."
|
||||
@@ -353,6 +359,7 @@ missing_id = "Client ID is required."
|
||||
redirect_saved = "Redirect URIs saved."
|
||||
rotate_confirm = "Rotate Confirm"
|
||||
rotate_error = "Rotate Error"
|
||||
create_forbidden = "You do not have permission to create this RP. Ask an administrator to grant developer access."
|
||||
save_error = "Save Error"
|
||||
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
|
||||
secret_rotated = "Secret Rotated"
|
||||
|
||||
@@ -331,6 +331,12 @@ delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은
|
||||
delete_error = "삭제 실패: {{error}}"
|
||||
empty = "조회 가능한 RP가 없습니다."
|
||||
empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다."
|
||||
empty_can_create = "아직 등록된 연동 앱이 없습니다."
|
||||
empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다."
|
||||
empty_filtered = "조건에 맞는 연동 앱이 없습니다."
|
||||
empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요."
|
||||
empty_pending = "개발자 권한 신청을 검토 중입니다."
|
||||
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
|
||||
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
|
||||
loading = "앱 정보를 불러오는 중..."
|
||||
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
|
||||
@@ -353,6 +359,7 @@ missing_id = "Client ID가 필요합니다."
|
||||
redirect_saved = "Redirect URIs가 저장되었습니다."
|
||||
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
|
||||
rotate_error = "재발급 실패: {{error}}"
|
||||
create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요."
|
||||
save_error = "저장 실패: {{error}}"
|
||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||
secret_rotated = "Client Secret이 재발급되었습니다."
|
||||
|
||||
@@ -334,6 +334,12 @@ delete_error = ""
|
||||
delete_confirm = ""
|
||||
empty = ""
|
||||
empty_detail = ""
|
||||
empty_can_create = ""
|
||||
empty_can_create_detail = ""
|
||||
empty_filtered = ""
|
||||
empty_filtered_detail = ""
|
||||
empty_pending = ""
|
||||
empty_pending_detail = ""
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = ""
|
||||
@@ -353,6 +359,7 @@ missing_id = ""
|
||||
redirect_saved = ""
|
||||
rotate_confirm = ""
|
||||
rotate_error = ""
|
||||
create_forbidden = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
secret_rotated = ""
|
||||
|
||||
Reference in New Issue
Block a user