1
0
forked from baron/baron-sso

개발자 등록 신청 입력 안내 및 역할 표기 개선

This commit is contained in:
2026-04-22 15:40:17 +09:00
parent 5d334069c7
commit 9e73059d2a
5 changed files with 218 additions and 18 deletions

View File

@@ -18,6 +18,9 @@ type DeveloperRequest struct {
TenantID string `gorm:"index;not null" json:"tenantId"` TenantID string `gorm:"index;not null" json:"tenantId"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Organization string `json:"organization"` Organization string `json:"organization"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
Reason string `json:"reason"` Reason string `json:"reason"`
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
AdminNotes string `json:"adminNotes"` AdminNotes string `json:"adminNotes"`

View File

@@ -2800,7 +2800,17 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
role := normalizeUserRole(profile.Role) role := normalizeUserRole(profile.Role)
if role == domain.RoleUser { if role == domain.RoleUser {
return errorJSON(c, fiber.StatusForbidden, "access denied") if profile.TenantID == nil || strings.TrimSpace(*profile.TenantID) == "" {
return c.JSON([]domain.Tenant{})
}
tenant, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
}
if tenant == nil {
return c.JSON([]domain.Tenant{})
}
return c.JSON([]domain.Tenant{*tenant})
} }
if role == domain.RoleSuperAdmin { if role == domain.RoleSuperAdmin {
@@ -2839,6 +2849,12 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
if profile == nil { if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
} }
if h.Auth != nil {
if enriched, err := h.Auth.GetEnrichedProfile(c); err == nil && enriched != nil {
profile = enriched
c.Locals("user_profile", enriched)
}
}
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
@@ -2857,11 +2873,25 @@ func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
} }
name := strings.TrimSpace(profile.Name)
if name == "" {
name = strings.TrimSpace(req.Name)
}
organization := strings.TrimSpace(req.Organization)
if h.TenantSvc != nil {
if tenant, err := h.TenantSvc.GetTenant(c.Context(), req.TenantID); err == nil && tenant != nil && strings.TrimSpace(tenant.Name) != "" {
organization = strings.TrimSpace(tenant.Name)
}
}
devReq := domain.DeveloperRequest{ devReq := domain.DeveloperRequest{
UserID: profile.ID, UserID: profile.ID,
TenantID: req.TenantID, TenantID: req.TenantID,
Name: req.Name, Name: name,
Organization: req.Organization, Organization: organization,
Email: profile.Email,
Phone: profile.Phone,
Role: normalizeUserRole(profile.Role),
Reason: req.Reason, Reason: req.Reason,
Status: domain.DeveloperRequestStatusPending, Status: domain.DeveloperRequestStatusPending,
} }

View File

@@ -9,7 +9,7 @@ import {
ShieldHalf, ShieldHalf,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
@@ -43,11 +43,13 @@ import {
fetchClients, fetchClients,
fetchDevStats, fetchDevStats,
fetchDeveloperRequestStatus, fetchDeveloperRequestStatus,
fetchMyTenants,
requestDeveloperAccess, requestDeveloperAccess,
} from "../../lib/devApi"; } 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";
import { fetchMe } from "../auth/authApi";
function ClientsPage() { function ClientsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,6 +58,7 @@ function ClientsPage() {
const userProfile = auth.user?.profile as Record<string, unknown> | undefined; const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile); const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined; const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const { const {
data, data,
@@ -82,6 +85,16 @@ function ClientsPage() {
queryFn: () => fetchDeveloperRequestStatus(tenantId), queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && role === "user", enabled: hasAccessToken && role === "user",
}); });
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: hasAccessToken,
});
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const canCreateClient = const canCreateClient =
(role !== "user" && role !== "tenant_member") || (role !== "user" && role !== "tenant_member") ||
@@ -117,6 +130,19 @@ function ClientsPage() {
const authFailures = statsData?.auth_failures_24h ?? 0; const authFailures = statsData?.auth_failures_24h ?? 0;
const hasFilterResult = filteredClients.length > 0; const hasFilterResult = filteredClients.length > 0;
const isFilteredOut = clients.length > 0 && !hasFilterResult; const isFilteredOut = clients.length > 0 && !hasFilterResult;
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role || role;
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
type StatTone = "up" | "down" | "stable"; type StatTone = "up" | "down" | "stable";
type StatItem = { type StatItem = {
@@ -644,8 +670,11 @@ function ClientsPage() {
setIsRequestModalOpen(false); setIsRequestModalOpen(false);
}} }}
tenantId={tenantId || ""} tenantId={tenantId || ""}
initialName={(userProfile?.name as string) || ""} initialName={profileName}
initialOrg={(userProfile?.companyCode as string) || ""} initialOrg={organizationName}
initialEmail={profileEmail}
initialPhone={profilePhone}
initialRole={profileRoleLabel}
/> />
</div> </div>
); );
@@ -658,6 +687,9 @@ interface RequestAccessModalProps {
tenantId: string; tenantId: string;
initialName: string; initialName: string;
initialOrg: string; initialOrg: string;
initialEmail: string;
initialPhone: string;
initialRole: string;
} }
function RequestAccessModal({ function RequestAccessModal({
@@ -667,11 +699,20 @@ function RequestAccessModal({
tenantId, tenantId,
initialName, initialName,
initialOrg, initialOrg,
initialEmail,
initialPhone,
initialRole,
}: RequestAccessModalProps) { }: RequestAccessModalProps) {
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg); const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: requestDeveloperAccess, mutationFn: requestDeveloperAccess,
onSuccess: () => { onSuccess: () => {
@@ -725,7 +766,8 @@ function RequestAccessModal({
<Input <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required required
/> />
</div> </div>
@@ -736,13 +778,50 @@ function RequestAccessModal({
<Input <Input
id="org" id="org"
value={organization} value={organization}
onChange={(e) => setOrganization(e.target.value)} readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required required
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.dev.request.modal.email", "이메일")}
</Label>
<Input
id="email"
value={initialEmail}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">
{t("ui.dev.request.modal.phone", "전화번호")}
</Label>
<Input
id="phone"
value={initialPhone}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="role">
{t("ui.dev.request.modal.role", "역할")}
</Label>
<Input
id="role"
value={initialRole}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="reason"> <Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")} {t("ui.dev.request.modal.reason", "신청 사유")}{" "}
<span className="text-destructive">*</span>
</Label> </Label>
<Textarea <Textarea
id="reason" id="reason"
@@ -752,7 +831,7 @@ function RequestAccessModal({
"ui.dev.request.modal.reason_placeholder", "ui.dev.request.modal.reason_placeholder",
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.", "예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
)} )}
className="min-h-[120px] resize-none" className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
required required
/> />
</div> </div>

View File

@@ -7,7 +7,7 @@ import {
X, X,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@@ -32,11 +32,13 @@ import {
approveDeveloperRequest, approveDeveloperRequest,
cancelDeveloperRequestApproval, cancelDeveloperRequestApproval,
fetchDeveloperRequests, fetchDeveloperRequests,
fetchMyTenants,
rejectDeveloperRequest, rejectDeveloperRequest,
requestDeveloperAccess, requestDeveloperAccess,
} from "../../lib/devApi"; } from "../../lib/devApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
export default function DeveloperRequestPage() { export default function DeveloperRequestPage() {
const auth = useAuth(); const auth = useAuth();
@@ -45,6 +47,7 @@ export default function DeveloperRequestPage() {
const role = resolveProfileRole(userProfile); const role = resolveProfileRole(userProfile);
const isSuperAdmin = role === "super_admin"; const isSuperAdmin = role === "super_admin";
const tenantId = userProfile?.tenant_id as string | undefined; const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({}); const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
@@ -54,6 +57,30 @@ export default function DeveloperRequestPage() {
queryFn: () => fetchDeveloperRequests(), queryFn: () => fetchDeveloperRequests(),
enabled: !!auth.user?.access_token, enabled: !!auth.user?.access_token,
}); });
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: !!auth.user?.access_token,
});
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: !!auth.user?.access_token,
});
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role || role;
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) => mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
@@ -189,8 +216,13 @@ export default function DeveloperRequestPage() {
<TableCell className="font-medium"> <TableCell className="font-medium">
<div>{req.name}</div> <div>{req.name}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{req.userId} {req.email || req.userId}
</div> </div>
{(req.phone || req.role) && (
<div className="mt-1 text-xs text-muted-foreground">
{[req.phone, req.role].filter(Boolean).join(" / ")}
</div>
)}
</TableCell> </TableCell>
)} )}
<TableCell>{req.organization}</TableCell> <TableCell>{req.organization}</TableCell>
@@ -302,8 +334,11 @@ export default function DeveloperRequestPage() {
setIsRequestModalOpen(false); setIsRequestModalOpen(false);
}} }}
tenantId={tenantId || ""} tenantId={tenantId || ""}
initialName={(userProfile?.name as string) || ""} initialName={profileName}
initialOrg={(userProfile?.companyCode as string) || ""} initialOrg={organizationName}
initialEmail={profileEmail}
initialPhone={profilePhone}
initialRole={profileRoleLabel}
/> />
</div> </div>
); );
@@ -352,6 +387,9 @@ interface RequestAccessModalProps {
tenantId: string; tenantId: string;
initialName: string; initialName: string;
initialOrg: string; initialOrg: string;
initialEmail: string;
initialPhone: string;
initialRole: string;
} }
function RequestAccessModal({ function RequestAccessModal({
@@ -361,11 +399,20 @@ function RequestAccessModal({
tenantId, tenantId,
initialName, initialName,
initialOrg, initialOrg,
initialEmail,
initialPhone,
initialRole,
}: RequestAccessModalProps) { }: RequestAccessModalProps) {
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg); const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: requestDeveloperAccess, mutationFn: requestDeveloperAccess,
onSuccess: () => { onSuccess: () => {
@@ -419,7 +466,8 @@ function RequestAccessModal({
<Input <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required required
/> />
</div> </div>
@@ -430,13 +478,50 @@ function RequestAccessModal({
<Input <Input
id="org" id="org"
value={organization} value={organization}
onChange={(e) => setOrganization(e.target.value)} readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required required
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.dev.request.modal.email", "이메일")}
</Label>
<Input
id="email"
value={initialEmail}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">
{t("ui.dev.request.modal.phone", "전화번호")}
</Label>
<Input
id="phone"
value={initialPhone}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="role">
{t("ui.dev.request.modal.role", "역할")}
</Label>
<Input
id="role"
value={initialRole}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="reason"> <Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")} {t("ui.dev.request.modal.reason", "신청 사유")}{" "}
<span className="text-destructive">*</span>
</Label> </Label>
<Textarea <Textarea
id="reason" id="reason"
@@ -446,7 +531,7 @@ function RequestAccessModal({
"ui.dev.request.modal.reason_placeholder", "ui.dev.request.modal.reason_placeholder",
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.", "예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
)} )}
className="min-h-[120px] resize-none" className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
required required
/> />
</div> </div>

View File

@@ -401,6 +401,9 @@ export type DeveloperRequest = {
tenantId: string; tenantId: string;
name: string; name: string;
organization: string; organization: string;
email?: string;
phone?: string;
role?: string;
reason: string; reason: string;
status: DeveloperRequestStatus; status: DeveloperRequestStatus;
adminNotes?: string; adminNotes?: string;