1
0
forked from baron/baron-sso

개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선

This commit is contained in:
2026-04-22 14:39:06 +09:00
parent 2216d9c4e4
commit 685923a03e
12 changed files with 382 additions and 44 deletions

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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"

View File

@@ -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이 재발급되었습니다."

View File

@@ -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 = ""