forked from baron/baron-sso
개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선
This commit is contained in:
@@ -727,6 +727,11 @@ func main() {
|
|||||||
// [New] Developer Registration Flow
|
// [New] Developer Registration Flow
|
||||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||||
dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus)
|
dev.Get("/developer-request", devHandler.GetDeveloperRequestStatus)
|
||||||
|
dev.Get("/developer-request/status", devHandler.GetDeveloperRequestStatus)
|
||||||
|
dev.Get("/developer-request/list", devHandler.ListDeveloperRequests)
|
||||||
|
dev.Post("/developer-request/:id/approve", devHandler.ApproveDeveloperRequest)
|
||||||
|
dev.Post("/developer-request/:id/reject", devHandler.RejectDeveloperRequest)
|
||||||
|
dev.Post("/developer-request/:id/cancel-approval", devHandler.CancelDeveloperRequestApproval)
|
||||||
|
|
||||||
// Webhook for Kratos courier (HTTP delivery)
|
// Webhook for Kratos courier (HTTP delivery)
|
||||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DeveloperRequestStatusPending = "pending"
|
DeveloperRequestStatusPending = "pending"
|
||||||
DeveloperRequestStatusApproved = "approved"
|
DeveloperRequestStatusApproved = "approved"
|
||||||
DeveloperRequestStatusRejected = "rejected"
|
DeveloperRequestStatusRejected = "rejected"
|
||||||
|
DeveloperRequestStatusCancelled = "cancelled"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeveloperRequest represents a user's application to become a developer.
|
// DeveloperRequest represents a user's application to become a developer.
|
||||||
@@ -18,7 +19,7 @@ type DeveloperRequest struct {
|
|||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Organization string `json:"organization"`
|
Organization string `json:"organization"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected
|
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||||
AdminNotes string `json:"adminNotes"`
|
AdminNotes string `json:"adminNotes"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
|||||||
@@ -349,21 +349,47 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
|||||||
if profile == nil {
|
if profile == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
role := normalizeUserRole(profile.Role)
|
||||||
|
if role == domain.RoleSuperAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
clientTenantID := resolveClientTenantID(summary)
|
clientTenantID := resolveClientTenantID(summary)
|
||||||
if clientTenantID != "" {
|
if role != domain.RoleUser && clientTenantID != "" {
|
||||||
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if role == domain.RoleUser {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
||||||
return err == nil && allowed
|
return err == nil && allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) hasDirectRelyingPartyOperatorRelation(c *fiber.Ctx, profile *domain.UserProfileResponse, clientID string) bool {
|
||||||
|
if h.Keto == nil || profile == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
subject := ketoSubjectFromProfile(profile)
|
||||||
|
if subject == "" || strings.TrimSpace(clientID) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for relation := range allowedRelyingPartyOperatorRelations {
|
||||||
|
tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, subject)
|
||||||
|
if err == nil && len(tuples) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
|
func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
|
||||||
if strings.TrimSpace(tenantID) == "" {
|
if strings.TrimSpace(tenantID) == "" {
|
||||||
return false
|
return false
|
||||||
@@ -1428,7 +1454,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if tenantID == "" && profile.TenantID != nil {
|
if tenantID == "" && profile.TenantID != nil {
|
||||||
tenantID = *profile.TenantID
|
tenantID = *profile.TenantID
|
||||||
}
|
}
|
||||||
if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,7 +1499,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||||
}
|
}
|
||||||
if !isAppManager {
|
if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1557,11 +1583,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// [New] Automatically grant admin permission to the creator in Keto
|
// [New] Automatically grant admin permission to the creator in Keto
|
||||||
if h.KetoOutbox != nil && profile != nil {
|
if h.KetoOutbox != nil && profile != nil {
|
||||||
|
subject := "User:" + profile.ID
|
||||||
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
Namespace: "RelyingParty",
|
Namespace: "RelyingParty",
|
||||||
Object: created.ClientID,
|
Object: created.ClientID,
|
||||||
Relation: "admins",
|
Relation: "admins",
|
||||||
Subject: "User:" + profile.ID,
|
Subject: subject,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1569,6 +1596,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
} else {
|
} else {
|
||||||
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
|
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
|
||||||
}
|
}
|
||||||
|
if h.Keto != nil {
|
||||||
|
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
|
||||||
|
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store secret in metadata for later retrieval
|
// Store secret in metadata for later retrieval
|
||||||
@@ -2863,10 +2895,66 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
|||||||
if status == nil {
|
if status == nil {
|
||||||
return c.JSON(fiber.Map{"status": "none"})
|
return c.JSON(fiber.Map{"status": "none"})
|
||||||
}
|
}
|
||||||
|
if status.Status == domain.DeveloperRequestStatusApproved {
|
||||||
|
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(status)
|
return c.JSON(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
|
||||||
|
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := "User:" + strings.TrimSpace(userID)
|
||||||
|
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
|
||||||
|
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: relation,
|
||||||
|
Subject: subject,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if h.hasDirectTenantRelation(c, tenantID, "developer_console_grant_manager", subject) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "developer_console_grant_manager",
|
||||||
|
Subject: subject,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
|
||||||
|
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := "User:" + strings.TrimSpace(userID)
|
||||||
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: relation,
|
||||||
|
Subject: subject,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) hasDirectTenantRelation(c *fiber.Ctx, tenantID, relation, subject string) bool {
|
||||||
|
if h.Keto == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tuples, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, relation, subject)
|
||||||
|
return err == nil && len(tuples) > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -2923,18 +3011,7 @@ func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Grant Keto Permissions
|
// Grant Keto Permissions
|
||||||
if h.KetoOutbox != nil {
|
if h.KetoOutbox != nil {
|
||||||
subject := "User:" + devReq.UserID
|
h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||||
permissions := []string{"view_dev_console", "grant_dev_permissions"}
|
|
||||||
|
|
||||||
for _, relation := range permissions {
|
|
||||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: devReq.TenantID,
|
|
||||||
Relation: relation,
|
|
||||||
Subject: subject,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"status": "ok"})
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
@@ -2968,3 +3045,42 @@ func (h *DevHandler) RejectDeveloperRequest(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(fiber.Map{"status": "ok"})
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *DevHandler) CancelDeveloperRequestApproval(c *fiber.Ctx) error {
|
||||||
|
profile := h.getCurrentProfile(c)
|
||||||
|
if profile == nil {
|
||||||
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||||
|
}
|
||||||
|
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||||
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Params("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody struct {
|
||||||
|
AdminNotes string `json:"adminNotes"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&reqBody); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details")
|
||||||
|
}
|
||||||
|
if devReq.Status != domain.DeveloperRequestStatusApproved {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "only approved requests can be cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1023,6 +1023,68 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
|||||||
mockKeto.AssertExpectations(t)
|
mockKeto.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
|
var body map[string]interface{}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
body["client_secret"] = "generated-secret"
|
||||||
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||||
|
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
|
||||||
|
|
||||||
|
mockOutbox := new(devMockKetoOutboxRepository)
|
||||||
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
|
return entry.Namespace == "RelyingParty" &&
|
||||||
|
entry.Object == "client-1" &&
|
||||||
|
entry.Relation == "admins" &&
|
||||||
|
entry.Subject == "User:user-1" &&
|
||||||
|
entry.Action == domain.KetoOutboxActionCreate
|
||||||
|
})).Return(nil)
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||||
|
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||||
|
Keto: mockKeto,
|
||||||
|
KetoOutbox: mockOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
tenantID := "tenant-a"
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||||
|
ID: "user-1",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
TenantID: &tenantID,
|
||||||
|
})
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"id": "client-1",
|
||||||
|
"name": "App One",
|
||||||
|
"type": "private",
|
||||||
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetStats_Success(t *testing.T) {
|
func TestGetStats_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.Develop
|
|||||||
var existing domain.DeveloperRequest
|
var existing domain.DeveloperRequest
|
||||||
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error
|
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ? AND status = ?", req.UserID, req.TenantID, domain.DeveloperRequestStatusPending).First(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errors.New("already has a pending request")
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.db.WithContext(ctx).Create(&req).Error
|
return s.db.WithContext(ctx).Create(&req).Error
|
||||||
@@ -74,3 +77,10 @@ func (s *DeveloperService) RejectRequest(ctx context.Context, id uint, adminNote
|
|||||||
"admin_notes": adminNotes,
|
"admin_notes": adminNotes,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||||
|
return s.db.WithContext(ctx).Model(&domain.DeveloperRequest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"status": domain.DeveloperRequestStatusCancelled,
|
||||||
|
"admin_notes": adminNotes,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -529,12 +529,16 @@ function ClientGeneralPage() {
|
|||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const axiosError = err as AxiosError<{ error?: string }>;
|
const axiosError = err as AxiosError<{ error?: string }>;
|
||||||
if (axiosError.response?.status === 403) {
|
if (axiosError.response?.status === 403) {
|
||||||
toast(
|
alert(
|
||||||
t(
|
isCreate
|
||||||
"msg.dev.clients.general.save_forbidden",
|
? t(
|
||||||
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
"msg.dev.clients.general.create_forbidden",
|
||||||
),
|
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
|
||||||
"error",
|
)
|
||||||
|
: t(
|
||||||
|
"msg.dev.clients.general.save_forbidden",
|
||||||
|
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ function ClientsPage() {
|
|||||||
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 canCreateClient = role !== "user" && role !== "tenant_member";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading: isLoadingClients,
|
isLoading: isLoadingClients,
|
||||||
@@ -76,6 +74,7 @@ function ClientsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
data: requestStatus,
|
||||||
isLoading: isLoadingRequest,
|
isLoading: isLoadingRequest,
|
||||||
refetch: refetchRequest,
|
refetch: refetchRequest,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
@@ -84,6 +83,16 @@ function ClientsPage() {
|
|||||||
enabled: hasAccessToken && role === "user",
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
@@ -106,6 +115,8 @@ function ClientsPage() {
|
|||||||
const totalClients = statsData?.total_clients ?? clients.length;
|
const totalClients = statsData?.total_clients ?? clients.length;
|
||||||
const activeSessions = statsData?.active_sessions ?? 0;
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
const authFailures = statsData?.auth_failures_24h ?? 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 StatTone = "up" | "down" | "stable";
|
||||||
type StatItem = {
|
type StatItem = {
|
||||||
@@ -378,7 +389,7 @@ function ClientsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredClients.length === 0 && (
|
{!hasFilterResult && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
@@ -386,19 +397,58 @@ function ClientsPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{t(
|
{isFilteredOut
|
||||||
"msg.dev.clients.empty",
|
? t(
|
||||||
"조회 가능한 RP가 없습니다.",
|
"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>
|
</p>
|
||||||
<div className="text-sm space-y-2">
|
<div className="text-sm space-y-2">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{isFilteredOut
|
||||||
"msg.dev.clients.empty_detail",
|
? t(
|
||||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
"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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-primary font-bold hover:underline"
|
className="text-primary font-bold hover:underline"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
approveDeveloperRequest,
|
approveDeveloperRequest,
|
||||||
|
cancelDeveloperRequestApproval,
|
||||||
fetchDeveloperRequests,
|
fetchDeveloperRequests,
|
||||||
rejectDeveloperRequest,
|
rejectDeveloperRequest,
|
||||||
requestDeveloperAccess,
|
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) => {
|
const handleApprove = (id: number) => {
|
||||||
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
|
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
|
||||||
};
|
};
|
||||||
@@ -84,6 +95,14 @@ export default function DeveloperRequestPage() {
|
|||||||
rejectMutation.mutate({ id, adminNotes: adminNotes[id] });
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@@ -95,6 +114,10 @@ export default function DeveloperRequestPage() {
|
|||||||
const hasActiveRequest = requests?.some(
|
const hasActiveRequest = requests?.some(
|
||||||
(r) => r.status === "pending" || r.status === "approved",
|
(r) => r.status === "pending" || r.status === "approved",
|
||||||
);
|
);
|
||||||
|
const isActionPending =
|
||||||
|
approveMutation.isPending ||
|
||||||
|
rejectMutation.isPending ||
|
||||||
|
cancelApprovalMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -211,7 +234,7 @@ export default function DeveloperRequestPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:bg-destructive/10"
|
||||||
onClick={() => handleReject(req.id)}
|
onClick={() => handleReject(req.id)}
|
||||||
disabled={rejectMutation.isPending}
|
disabled={isActionPending}
|
||||||
>
|
>
|
||||||
<XCircle className="mr-1 h-3 w-3" />
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
{t("ui.common.reject", "반려")}
|
{t("ui.common.reject", "반려")}
|
||||||
@@ -220,17 +243,44 @@ export default function DeveloperRequestPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => handleApprove(req.id)}
|
||||||
disabled={approveMutation.isPending}
|
disabled={isActionPending}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
{t("ui.common.approve", "승인")}
|
{t("ui.common.approve", "승인")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<span className="text-muted-foreground text-xs italic">
|
||||||
{req.status === "approved"
|
{req.status === "cancelled"
|
||||||
? t("ui.common.completed", "처리 완료")
|
? t("ui.dev.request.status.cancelled", "승인 취소됨")
|
||||||
: t("ui.common.rejected", "반려됨")}
|
: t("ui.common.rejected", "반려됨")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -282,6 +332,13 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
{t("ui.dev.request.status.rejected", "반려됨")}
|
{t("ui.dev.request.status.rejected", "반려됨")}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
case "cancelled":
|
||||||
|
return (
|
||||||
|
<Badge variant="muted" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <Badge variant="muted">{status}</Badge>;
|
return <Badge variant="muted">{status}</Badge>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export type DeveloperRequestStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "approved"
|
| "approved"
|
||||||
| "rejected"
|
| "rejected"
|
||||||
|
| "cancelled"
|
||||||
| "none";
|
| "none";
|
||||||
|
|
||||||
export type DeveloperRequest = {
|
export type DeveloperRequest = {
|
||||||
@@ -461,3 +462,14 @@ export async function rejectDeveloperRequest(
|
|||||||
);
|
);
|
||||||
return data;
|
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."
|
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||||
empty = "No RPs are available."
|
empty = "No RPs are available."
|
||||||
empty_detail = "RPs will appear here when a relationship is assigned to your account."
|
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]
|
[msg.dev.clients.consents]
|
||||||
empty = "No consents found."
|
empty = "No consents found."
|
||||||
@@ -353,6 +359,7 @@ missing_id = "Client ID is required."
|
|||||||
redirect_saved = "Redirect URIs saved."
|
redirect_saved = "Redirect URIs saved."
|
||||||
rotate_confirm = "Rotate Confirm"
|
rotate_confirm = "Rotate Confirm"
|
||||||
rotate_error = "Rotate Error"
|
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_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."
|
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"
|
secret_rotated = "Secret Rotated"
|
||||||
|
|||||||
@@ -331,6 +331,12 @@ delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은
|
|||||||
delete_error = "삭제 실패: {{error}}"
|
delete_error = "삭제 실패: {{error}}"
|
||||||
empty = "조회 가능한 RP가 없습니다."
|
empty = "조회 가능한 RP가 없습니다."
|
||||||
empty_detail = "RP 관계가 부여되면 이 목록에 해당 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}}"
|
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
|
||||||
loading = "앱 정보를 불러오는 중..."
|
loading = "앱 정보를 불러오는 중..."
|
||||||
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
|
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
|
||||||
@@ -353,6 +359,7 @@ missing_id = "Client ID가 필요합니다."
|
|||||||
redirect_saved = "Redirect URIs가 저장되었습니다."
|
redirect_saved = "Redirect URIs가 저장되었습니다."
|
||||||
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
|
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
|
||||||
rotate_error = "재발급 실패: {{error}}"
|
rotate_error = "재발급 실패: {{error}}"
|
||||||
|
create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요."
|
||||||
save_error = "저장 실패: {{error}}"
|
save_error = "저장 실패: {{error}}"
|
||||||
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
|
||||||
secret_rotated = "Client Secret이 재발급되었습니다."
|
secret_rotated = "Client Secret이 재발급되었습니다."
|
||||||
|
|||||||
@@ -334,6 +334,12 @@ delete_error = ""
|
|||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
empty_detail = ""
|
empty_detail = ""
|
||||||
|
empty_can_create = ""
|
||||||
|
empty_can_create_detail = ""
|
||||||
|
empty_filtered = ""
|
||||||
|
empty_filtered_detail = ""
|
||||||
|
empty_pending = ""
|
||||||
|
empty_pending_detail = ""
|
||||||
|
|
||||||
[msg.dev.clients.consents]
|
[msg.dev.clients.consents]
|
||||||
empty = ""
|
empty = ""
|
||||||
@@ -353,6 +359,7 @@ missing_id = ""
|
|||||||
redirect_saved = ""
|
redirect_saved = ""
|
||||||
rotate_confirm = ""
|
rotate_confirm = ""
|
||||||
rotate_error = ""
|
rotate_error = ""
|
||||||
|
create_forbidden = ""
|
||||||
save_error = ""
|
save_error = ""
|
||||||
save_forbidden = ""
|
save_forbidden = ""
|
||||||
secret_rotated = ""
|
secret_rotated = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user