forked from baron/baron-sso
Merge pull request 'feature/df-developer' (#616) from feature/df-developer into dev
Reviewed-on: baron/baron-sso#616
This commit is contained in:
2
Makefile
2
Makefile
@@ -196,7 +196,7 @@ code-check-front-lint:
|
||||
|
||||
code-check-backend-tests:
|
||||
@echo "==> backend tests"
|
||||
cd backend && go test -v ./...
|
||||
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
|
||||
|
||||
code-check-userfront-tests:
|
||||
@echo "==> userfront tests (isolated workspace)"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
authhandler "baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
@@ -281,8 +282,9 @@ func runHeadlessPasswordLoginE2ERequest(
|
||||
headers map[string]string,
|
||||
) (*http.Response, string) {
|
||||
t.Helper()
|
||||
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless password login E2E tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
logBuffer := &bytes.Buffer{}
|
||||
if logger == nil {
|
||||
|
||||
@@ -285,12 +285,13 @@ func main() {
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
developerService := service.NewDeveloperService(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler)
|
||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||
@@ -723,6 +724,15 @@ func main() {
|
||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||
|
||||
// [New] Developer Registration Flow
|
||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||
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)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.SharedLink{},
|
||||
&domain.DeveloperRequest{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
29
backend/internal/domain/developer_request.go
Normal file
29
backend/internal/domain/developer_request.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DeveloperRequestStatusPending = "pending"
|
||||
DeveloperRequestStatusApproved = "approved"
|
||||
DeveloperRequestStatusRejected = "rejected"
|
||||
DeveloperRequestStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// DeveloperRequest represents a user's application to become a developer.
|
||||
type DeveloperRequest struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -52,8 +52,8 @@ type AuthInfo struct {
|
||||
SessionToken *Token
|
||||
RefreshToken *Token
|
||||
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||
Subject string
|
||||
SetCookies []*http.Cookie
|
||||
Subject string
|
||||
SetCookies []*http.Cookie
|
||||
}
|
||||
|
||||
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"runtime"
|
||||
"time"
|
||||
@@ -9,11 +10,15 @@ import (
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
Keto service.KetoService
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func NewAdminHandler(keto service.KetoService) *AdminHandler {
|
||||
return &AdminHandler{Keto: keto}
|
||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
Keto: keto,
|
||||
KetoOutbox: ketoOutbox,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
@@ -173,6 +174,10 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
@@ -240,6 +245,10 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/middleware"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
@@ -369,6 +370,9 @@ func runHeadlessPasswordLoginWithAssertionRequest(
|
||||
headers map[string]string,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
@@ -469,6 +473,9 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
|
||||
logger *slog.Logger,
|
||||
) *http.Response {
|
||||
t.Helper()
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
@@ -792,6 +799,10 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1006,6 +1017,10 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1089,6 +1104,10 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
@@ -1271,6 +1290,10 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
if !testsupport.PortBindingAvailable() {
|
||||
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
|
||||
}
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +35,7 @@ type DevHandler struct {
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
DeveloperSvc *service.DeveloperService
|
||||
Auth interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
@@ -47,6 +49,7 @@ func NewDevHandler(
|
||||
keto service.KetoService,
|
||||
ketoOutbox repository.KetoOutboxRepository,
|
||||
tenantSvc service.TenantService,
|
||||
developerSvc *service.DeveloperService,
|
||||
auth ...interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
},
|
||||
@@ -70,6 +73,7 @@ func NewDevHandler(
|
||||
KetoOutbox: ketoOutbox,
|
||||
RPSvc: rpSvc,
|
||||
TenantSvc: tenantSvc,
|
||||
DeveloperSvc: developerSvc,
|
||||
Auth: authProvider,
|
||||
}
|
||||
}
|
||||
@@ -345,12 +349,17 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
||||
if profile == nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
@@ -360,6 +369,23 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
||||
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 {
|
||||
if strings.TrimSpace(tenantID) == "" {
|
||||
return false
|
||||
@@ -1424,7 +1450,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1469,7 +1495,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1551,6 +1577,28 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
h.syncHeadlessJWKSCache(c.Context(), *created, "client_create")
|
||||
|
||||
// [New] Automatically grant admin permission to the creator in Keto
|
||||
if h.KetoOutbox != nil && profile != nil {
|
||||
subject := "User:" + profile.ID
|
||||
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: created.ClientID,
|
||||
Relation: "admins",
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||
} else {
|
||||
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
|
||||
if created.ClientSecret != "" {
|
||||
// 1. Store in PostgreSQL (Source of Truth)
|
||||
@@ -2748,7 +2796,17 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
|
||||
role := normalizeUserRole(profile.Role)
|
||||
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 {
|
||||
@@ -2781,3 +2839,274 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(tenants)
|
||||
}
|
||||
|
||||
func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Reason string `json:"reason"`
|
||||
TenantID string `json:"tenantId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if req.TenantID == "" && profile.TenantID != nil {
|
||||
req.TenantID = *profile.TenantID
|
||||
}
|
||||
if req.TenantID == "" {
|
||||
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{
|
||||
UserID: profile.ID,
|
||||
TenantID: req.TenantID,
|
||||
Name: name,
|
||||
Organization: organization,
|
||||
Email: profile.Email,
|
||||
Phone: profile.Phone,
|
||||
Role: normalizeUserRole(profile.Role),
|
||||
Reason: req.Reason,
|
||||
Status: domain.DeveloperRequestStatusPending,
|
||||
}
|
||||
|
||||
if err := h.DeveloperSvc.RequestAccess(c.Context(), devReq); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
|
||||
tenantID := c.Query("tenantId")
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = *profile.TenantID
|
||||
}
|
||||
if tenantID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
||||
}
|
||||
|
||||
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if status == nil {
|
||||
return c.JSON(fiber.Map{"status": "none"})
|
||||
}
|
||||
if status.Status == domain.DeveloperRequestStatusApproved {
|
||||
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
|
||||
}
|
||||
|
||||
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 {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
|
||||
role := normalizeUserRole(profile.Role)
|
||||
status := c.Query("status")
|
||||
|
||||
userID := profile.ID
|
||||
if role == domain.RoleSuperAdmin {
|
||||
// Super Admin can see everyone's requests
|
||||
userID = ""
|
||||
}
|
||||
|
||||
requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(requests)
|
||||
}
|
||||
|
||||
func (h *DevHandler) ApproveDeveloperRequest(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 err := h.DeveloperSvc.ApproveRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// Grant Keto Permissions
|
||||
if h.KetoOutbox != nil {
|
||||
h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *DevHandler) RejectDeveloperRequest(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")
|
||||
}
|
||||
|
||||
if err := h.DeveloperSvc.RejectRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
|
||||
// Explicit permission for private client check bypass
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Once()
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
@@ -39,6 +39,19 @@ func (m *devMockKetoService) DeleteRelation(ctx context.Context, ns, obj, rel, s
|
||||
}
|
||||
|
||||
func (m *devMockKetoService) ListRelations(ctx context.Context, ns, obj, rel, sub string) ([]service.RelationTuple, error) {
|
||||
if len(m.ExpectedCalls) == 0 {
|
||||
return []service.RelationTuple{}, nil
|
||||
}
|
||||
hasListRelationsExpectation := false
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "ListRelations" {
|
||||
hasListRelationsExpectation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasListRelationsExpectation {
|
||||
return []service.RelationTuple{}, nil
|
||||
}
|
||||
args := m.Called(ctx, ns, obj, rel, sub)
|
||||
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
||||
}
|
||||
@@ -241,10 +254,16 @@ func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -843,7 +862,6 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, nil)
|
||||
|
||||
@@ -893,7 +911,6 @@ func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, nil)
|
||||
|
||||
@@ -1023,6 +1040,68 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.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) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
@@ -18,6 +19,11 @@ import (
|
||||
var testDB *gorm.DB
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
log.Printf("skipping repository tests: Docker provider is unavailable in this environment")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Start PostgreSQL container
|
||||
|
||||
86
backend/internal/service/developer_service.go
Normal file
86
backend/internal/service/developer_service.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeveloperService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDeveloperService(db *gorm.DB) *DeveloperService {
|
||||
return &DeveloperService{db: db}
|
||||
}
|
||||
|
||||
func (s *DeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
|
||||
// Check if there is already a pending request
|
||||
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
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Create(&req).Error
|
||||
}
|
||||
|
||||
func (s *DeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) {
|
||||
var req domain.DeveloperRequest
|
||||
err := s.db.WithContext(ctx).Where("user_id = ? AND tenant_id = ?", userID, tenantID).Order("created_at DESC").First(&req).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (s *DeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {
|
||||
var req domain.DeveloperRequest
|
||||
err := s.db.WithContext(ctx).First(&req, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (s *DeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) {
|
||||
var requests []domain.DeveloperRequest
|
||||
query := s.db.WithContext(ctx)
|
||||
if userID != "" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
err := query.Order("created_at DESC").Find(&requests).Error
|
||||
return requests, err
|
||||
}
|
||||
|
||||
func (s *DeveloperService) ApproveRequest(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.DeveloperRequestStatusApproved,
|
||||
"admin_notes": adminNotes,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *DeveloperService) RejectRequest(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.DeveloperRequestStatusRejected,
|
||||
"admin_notes": adminNotes,
|
||||
}).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
|
||||
}
|
||||
34
backend/internal/testsupport/env.go
Normal file
34
backend/internal/testsupport/env.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package testsupport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
)
|
||||
|
||||
// PortBindingAvailable reports whether this environment can bind a local TCP listener.
|
||||
func PortBindingAvailable() bool {
|
||||
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = ln.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// DockerAvailable reports whether Testcontainers can talk to a Docker provider.
|
||||
func DockerAvailable() bool {
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
|
||||
provider, err := testcontainers.ProviderDocker.GetProvider()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := provider.Health(context.Background()); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
|
||||
export const router = createBrowserRouter(
|
||||
@@ -38,6 +39,7 @@ export const router = createBrowserRouter(
|
||||
path: "clients/:id/relationships",
|
||||
element: <ClientRelationsPage />,
|
||||
},
|
||||
{ path: "developer-requests", element: <DeveloperRequestPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "profile", element: <ProfilePage /> },
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
LogOut,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
@@ -29,6 +30,12 @@ const navItems = [
|
||||
to: "/clients",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.developer_request",
|
||||
labelFallback: "개발자 권한 신청",
|
||||
to: "/developer-requests",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.audit_logs",
|
||||
labelFallback: "Audit Logs",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
BookOpenText,
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
Search,
|
||||
ServerCog,
|
||||
ShieldHalf,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,19 +38,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
fetchClients,
|
||||
fetchDevStats,
|
||||
fetchDeveloperRequestStatus,
|
||||
fetchMyTenants,
|
||||
requestDeveloperAccess,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
|
||||
function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const role = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const canCreateClient = role !== "user" && role !== "tenant_member";
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
const companyCode = userProfile?.companyCode as string | undefined;
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -66,10 +76,41 @@ function ClientsPage() {
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const {
|
||||
data: requestStatus,
|
||||
isLoading: isLoadingRequest,
|
||||
refetch: refetchRequest,
|
||||
} = useQuery({
|
||||
queryKey: ["developer-request", tenantId],
|
||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||
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 =
|
||||
(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");
|
||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
|
||||
const clients = data?.items || [];
|
||||
|
||||
@@ -87,6 +128,21 @@ 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;
|
||||
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 StatItem = {
|
||||
@@ -128,7 +184,7 @@ function ClientsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = isLoadingClients || isLoadingStats;
|
||||
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
||||
|
||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||
return (
|
||||
@@ -359,7 +415,7 @@ function ClientsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredClients.length === 0 && (
|
||||
{!hasFilterResult && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
@@ -367,17 +423,70 @@ 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>
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"msg.dev.clients.empty_detail",
|
||||
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{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>
|
||||
{!isFilteredOut && canCreateClient && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => navigate("/clients/new")}
|
||||
>
|
||||
{t("ui.dev.clients.new", "연동 앱 추가")}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
{!isFilteredOut && canRequestDeveloperAccess && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
onClick={() => navigate("/developer-requests")}
|
||||
>
|
||||
{t(
|
||||
"ui.dev.welcome.btn_request",
|
||||
"개발자 등록 신청하기",
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -552,6 +661,198 @@ function ClientsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<RequestAccessModal
|
||||
isOpen={isRequestModalOpen}
|
||||
onClose={() => setIsRequestModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetchRequest();
|
||||
setIsRequestModalOpen(false);
|
||||
}}
|
||||
tenantId={tenantId || ""}
|
||||
initialName={profileName}
|
||||
initialOrg={organizationName}
|
||||
initialEmail={profileEmail}
|
||||
initialPhone={profilePhone}
|
||||
initialRole={profileRoleLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestAccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
tenantId: string;
|
||||
initialName: string;
|
||||
initialOrg: string;
|
||||
initialEmail: string;
|
||||
initialPhone: string;
|
||||
initialRole: string;
|
||||
}
|
||||
|
||||
function RequestAccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
tenantId,
|
||||
initialName,
|
||||
initialOrg,
|
||||
initialEmail,
|
||||
initialPhone,
|
||||
initialRole,
|
||||
}: RequestAccessModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [organization, setOrganization] = useState(initialOrg);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName(initialName);
|
||||
setOrganization(initialOrg);
|
||||
}, [initialName, initialOrg, isOpen]);
|
||||
|
||||
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}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="org">
|
||||
{t("ui.dev.request.modal.org", "소속")}
|
||||
</Label>
|
||||
<Input
|
||||
id="org"
|
||||
value={organization}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="reason">
|
||||
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</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 border-primary/50 bg-background focus-visible:ring-primary/40"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
571
devfront/src/features/developer-request/DeveloperRequestPage.tsx
Normal file
571
devfront/src/features/developer-request/DeveloperRequestPage.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
approveDeveloperRequest,
|
||||
cancelDeveloperRequestApproval,
|
||||
fetchDeveloperRequests,
|
||||
fetchMyTenants,
|
||||
rejectDeveloperRequest,
|
||||
requestDeveloperAccess,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
|
||||
export default function DeveloperRequestPage() {
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const role = resolveProfileRole(userProfile);
|
||||
const isSuperAdmin = role === "super_admin";
|
||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||
const companyCode = userProfile?.companyCode as string | undefined;
|
||||
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
|
||||
|
||||
const { data: requests, isLoading } = useQuery({
|
||||
queryKey: ["developer-requests"],
|
||||
queryFn: () => fetchDeveloperRequests(),
|
||||
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({
|
||||
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
||||
approveDeveloperRequest(id, adminNotes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
alert(t("msg.dev.request.approved", "승인되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
|
||||
rejectDeveloperRequest(id, adminNotes),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
alert(t("msg.dev.request.rejected", "반려되었습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
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] || "" });
|
||||
};
|
||||
|
||||
const handleReject = (id: number) => {
|
||||
if (!adminNotes[id]) {
|
||||
alert(t("msg.dev.request.need_notes", "반려 사유를 입력해주세요."));
|
||||
return;
|
||||
}
|
||||
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">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{isSuperAdmin
|
||||
? t(
|
||||
"msg.dev.request.admin_desc",
|
||||
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.request.user_desc",
|
||||
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!isSuperAdmin && !hasActiveRequest && (
|
||||
<Button onClick={() => setIsRequestModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
{t("ui.dev.request.list.title", "신청 내역")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{isSuperAdmin && (
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.user", "사용자")}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>{t("ui.dev.request.table.org", "소속")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.reason", "신청 사유")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.status", "상태")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.date", "신청일")}
|
||||
</TableHead>
|
||||
{isSuperAdmin && (
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.request.table.actions", "관리")}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!requests || requests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isSuperAdmin ? 6 : 4}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<TableRow key={req.id}>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="font-medium">
|
||||
<div>{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.email || req.userId}
|
||||
</div>
|
||||
{(req.phone || req.role) && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{[req.phone, req.role].filter(Boolean).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{req.organization}</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="truncate" title={req.reason}>
|
||||
{req.reason}
|
||||
</div>
|
||||
{req.adminNotes && (
|
||||
<div className="mt-1 text-xs text-amber-600 bg-amber-50 dark:bg-amber-900/20 p-1.5 rounded">
|
||||
<strong>Admin:</strong> {req.adminNotes}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={req.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{isSuperAdmin && (
|
||||
<TableCell className="text-right">
|
||||
{req.status === "pending" ? (
|
||||
<div className="flex flex-col gap-2 min-w-[200px] items-end ml-auto">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.dev.request.admin_notes_placeholder",
|
||||
"메모 입력 (선택)...",
|
||||
)}
|
||||
className="h-8 text-xs"
|
||||
value={adminNotes[req.id] || ""}
|
||||
onChange={(e) =>
|
||||
setAdminNotes({
|
||||
...adminNotes,
|
||||
[req.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleReject(req.id)}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{t("ui.common.reject", "반려")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={() => handleApprove(req.id)}
|
||||
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 === "cancelled"
|
||||
? t(
|
||||
"ui.dev.request.status.cancelled",
|
||||
"승인 취소됨",
|
||||
)
|
||||
: t("ui.common.rejected", "반려됨")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RequestAccessModal
|
||||
isOpen={isRequestModalOpen}
|
||||
onClose={() => setIsRequestModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
|
||||
setIsRequestModalOpen(false);
|
||||
}}
|
||||
tenantId={tenantId || ""}
|
||||
initialName={profileName}
|
||||
initialOrg={organizationName}
|
||||
initialEmail={profileEmail}
|
||||
initialPhone={profilePhone}
|
||||
initialRole={profileRoleLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.pending", "대기 중")}
|
||||
</Badge>
|
||||
);
|
||||
case "approved":
|
||||
return (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t("ui.dev.request.status.approved", "승인됨")}
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="muted" className="gap-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{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>;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestAccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
tenantId: string;
|
||||
initialName: string;
|
||||
initialOrg: string;
|
||||
initialEmail: string;
|
||||
initialPhone: string;
|
||||
initialRole: string;
|
||||
}
|
||||
|
||||
function RequestAccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
tenantId,
|
||||
initialName,
|
||||
initialOrg,
|
||||
initialEmail,
|
||||
initialPhone,
|
||||
initialRole,
|
||||
}: RequestAccessModalProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [organization, setOrganization] = useState(initialOrg);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName(initialName);
|
||||
setOrganization(initialOrg);
|
||||
}, [initialName, initialOrg, isOpen]);
|
||||
|
||||
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}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="org">
|
||||
{t("ui.dev.request.modal.org", "소속")}
|
||||
</Label>
|
||||
<Input
|
||||
id="org"
|
||||
value={organization}
|
||||
readOnly
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="reason">
|
||||
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</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 border-primary/50 bg-background focus-visible:ring-primary/40"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -386,3 +386,87 @@ export async function fetchMyTenants() {
|
||||
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Developer Request API ---
|
||||
export type DeveloperRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "none";
|
||||
|
||||
export type DeveloperRequest = {
|
||||
id: number;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
organization: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: 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/status",
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
export async function fetchDeveloperRequests(status?: string) {
|
||||
const { data } = await apiClient.get<DeveloperRequest[]>(
|
||||
"/dev/developer-request/list",
|
||||
{
|
||||
params: { status },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function approveDeveloperRequest(id: number, adminNotes: string) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/approve`,
|
||||
{ adminNotes },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function rejectDeveloperRequest(id: number, adminNotes: string) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
`/dev/developer-request/${id}/reject`,
|
||||
{ adminNotes },
|
||||
);
|
||||
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이 재발급되었습니다."
|
||||
|
||||
@@ -325,6 +325,51 @@ loaded_count = ""
|
||||
loading = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = ""
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
empty = ""
|
||||
need_cancel_notes = ""
|
||||
need_notes = ""
|
||||
rejected = ""
|
||||
user_desc = ""
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = ""
|
||||
email = ""
|
||||
name = ""
|
||||
org = ""
|
||||
phone = ""
|
||||
reason = ""
|
||||
reason_placeholder = ""
|
||||
role = ""
|
||||
title = ""
|
||||
|
||||
[msg.dev.request.status]
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
pending = ""
|
||||
rejected = ""
|
||||
|
||||
[msg.dev.request.table]
|
||||
actions = ""
|
||||
date = ""
|
||||
org = ""
|
||||
reason = ""
|
||||
status = ""
|
||||
user = ""
|
||||
|
||||
[msg.dev.request.list]
|
||||
title = ""
|
||||
|
||||
[msg.dev.request.admin]
|
||||
notes_placeholder = ""
|
||||
|
||||
[msg.dev.request.cancel]
|
||||
approval = ""
|
||||
notes_placeholder = ""
|
||||
|
||||
[msg.dev.clients]
|
||||
load_error = ""
|
||||
loading = ""
|
||||
@@ -334,6 +379,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 +404,7 @@ missing_id = ""
|
||||
redirect_saved = ""
|
||||
rotate_confirm = ""
|
||||
rotate_error = ""
|
||||
create_forbidden = ""
|
||||
save_error = ""
|
||||
save_forbidden = ""
|
||||
secret_rotated = ""
|
||||
@@ -1283,6 +1335,42 @@ scope_badge = ""
|
||||
audit_logs = ""
|
||||
clients = ""
|
||||
logout = ""
|
||||
developer_request = ""
|
||||
|
||||
[ui.dev.welcome]
|
||||
btn_request = ""
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = ""
|
||||
cancel_approval = ""
|
||||
cancel_notes_placeholder = ""
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = ""
|
||||
|
||||
[ui.dev.request.modal]
|
||||
email = ""
|
||||
name = ""
|
||||
org = ""
|
||||
phone = ""
|
||||
reason = ""
|
||||
reason_placeholder = ""
|
||||
role = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
pending = ""
|
||||
rejected = ""
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = ""
|
||||
date = ""
|
||||
org = ""
|
||||
reason = ""
|
||||
status = ""
|
||||
user = ""
|
||||
|
||||
[ui.dev.audit]
|
||||
load_more = ""
|
||||
|
||||
167
devfront/tests/devfront-developer-request.spec.ts
Normal file
167
devfront/tests/devfront-developer-request.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
type DeveloperRequest,
|
||||
installDevApiMock,
|
||||
seedAuth,
|
||||
} from "./helpers/devfront-fixtures";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
test.describe("DevFront developer request and management", () => {
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await captureEvidence(page, testInfo, testInfo.title);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
});
|
||||
|
||||
test("user can request developer access when no RP exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
clients: [],
|
||||
consents: [],
|
||||
developerRequests: [],
|
||||
};
|
||||
await seedAuth(page, "user");
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients");
|
||||
|
||||
// Click request link and open the request modal on the dedicated page
|
||||
const requestBtn = page.getByRole("button", {
|
||||
name: /개발자 등록 신청하기|개발자 등록 신청/,
|
||||
});
|
||||
await requestBtn.waitFor({ state: "visible" });
|
||||
await requestBtn.click();
|
||||
await expect(page).toHaveURL(/\/developer-requests$/);
|
||||
|
||||
const openRequestBtn = page.getByRole("button", {
|
||||
name: /신규 신청하기|Request|Apply/,
|
||||
});
|
||||
await openRequestBtn.click();
|
||||
|
||||
// Fill Form (organization is read-only and comes from the active tenant)
|
||||
await page.locator("#reason").fill("Need to test OIDC integration");
|
||||
|
||||
// Submit
|
||||
await page.getByRole("button", { name: "신청하기", exact: true }).click();
|
||||
|
||||
// Verify Status - Look for "Pending" or "대기" anywhere
|
||||
await expect(page.locator("body")).toContainText(/대기|Pending/);
|
||||
});
|
||||
|
||||
test("super admin can approve, reject and cancel developer requests", async ({
|
||||
page,
|
||||
}) => {
|
||||
const request: DeveloperRequest = {
|
||||
id: "req-admin-test",
|
||||
userId: "user-1",
|
||||
userName: "Requester User",
|
||||
name: "Requester User",
|
||||
userEmail: "user1@example.com",
|
||||
organization: "Dev Team",
|
||||
reason: "API Test",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const state = {
|
||||
clients: [],
|
||||
consents: [],
|
||||
developerRequests: [request],
|
||||
};
|
||||
|
||||
await seedAuth(page, "super_admin");
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/developer-requests");
|
||||
|
||||
// Wait for data to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("table")).toContainText("Requester User", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Approve
|
||||
const approveBtn = page.getByRole("button", { name: "승인" }).first();
|
||||
await approveBtn.click();
|
||||
await expect(page.locator("table")).toContainText(/승인됨|Approved/);
|
||||
|
||||
// Cancel approval (Requires notes)
|
||||
await page.locator("input.h-8").first().fill("Cancellation reason");
|
||||
await page.getByRole("button", { name: "승인 취소" }).click();
|
||||
await expect(page.locator("table")).toContainText(/대기|Pending/);
|
||||
|
||||
// Reject (Requires notes)
|
||||
await page.locator("input.h-8").first().fill("Rejection reason");
|
||||
await page.getByRole("button", { name: "반려" }).click();
|
||||
await expect(page.locator("table")).toContainText(/반려됨|Rejected/);
|
||||
});
|
||||
|
||||
test("approved user can see 'Add App' guidance and create RP", async ({
|
||||
page,
|
||||
}) => {
|
||||
const request: DeveloperRequest = {
|
||||
id: "req-approved",
|
||||
userId: "playwright-user",
|
||||
userName: "Playwright User",
|
||||
name: "Playwright User",
|
||||
userEmail: "playwright@example.com",
|
||||
organization: "QA",
|
||||
reason: "Test",
|
||||
status: "approved",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
approvedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const state = {
|
||||
clients: [],
|
||||
consents: [],
|
||||
developerRequests: [request],
|
||||
};
|
||||
|
||||
await seedAuth(page, "rp_admin");
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients");
|
||||
|
||||
// Click Add App
|
||||
const createBtn = page
|
||||
.getByRole("button", { name: /연동 앱 추가/ })
|
||||
.first();
|
||||
await createBtn.click();
|
||||
|
||||
// Fill Form (Must fill all mandatory fields to enable Submit)
|
||||
await expect(page).toHaveURL(/\/clients\/new$/);
|
||||
|
||||
const nameInput = page.getByPlaceholder(
|
||||
/My Awesome Application|예: 멋진 애플리케이션/,
|
||||
);
|
||||
await nameInput.fill("E2E Test RP");
|
||||
await nameInput.press("Tab");
|
||||
|
||||
const uriInput = page.locator("textarea.font-mono");
|
||||
await uriInput.fill("https://example.com/callback");
|
||||
await uriInput.press("Tab");
|
||||
|
||||
// Submit
|
||||
const submitBtn = page
|
||||
.getByRole("button", { name: /생성/ })
|
||||
.filter({ hasNotText: "취소" });
|
||||
await expect(submitBtn).toBeEnabled({ timeout: 10000 });
|
||||
await submitBtn.click();
|
||||
|
||||
// Verification
|
||||
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /연동 앱 설정|Settings/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,25 @@ export type Consent = {
|
||||
tenantName: string;
|
||||
};
|
||||
|
||||
export type DeveloperRequestStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export type DeveloperRequest = {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
name?: string; // 추가
|
||||
userEmail: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
status: DeveloperRequestStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt?: string;
|
||||
rejectedAt?: string;
|
||||
comment?: string;
|
||||
adminNotes?: string; // 추가
|
||||
};
|
||||
|
||||
export type ClientRelation = {
|
||||
relation: string;
|
||||
subject: string;
|
||||
@@ -84,6 +103,7 @@ export type AuditLog = {
|
||||
export type DevApiMockState = {
|
||||
clients: Client[];
|
||||
consents: Consent[];
|
||||
developerRequests?: DeveloperRequest[];
|
||||
relations?: Record<string, ClientRelation[]>;
|
||||
users?: DevAssignableUser[];
|
||||
auditLogsByCursor?: Record<
|
||||
@@ -241,6 +261,97 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
const { pathname, searchParams } = url;
|
||||
const method = request.method();
|
||||
|
||||
if (
|
||||
(pathname === "/api/v1/dev/requests" ||
|
||||
pathname === "/api/v1/dev/developer-request/list") &&
|
||||
method === "GET"
|
||||
) {
|
||||
return json(route, state.developerRequests ?? []);
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname === "/api/v1/dev/requests" ||
|
||||
pathname === "/api/v1/dev/developer-request") &&
|
||||
method === "POST"
|
||||
) {
|
||||
const payload =
|
||||
(request.postDataJSON() as {
|
||||
name?: string;
|
||||
organization?: string;
|
||||
reason?: string;
|
||||
}) || {};
|
||||
const created: DeveloperRequest = {
|
||||
id: `req-${Date.now()}`,
|
||||
userId: "playwright-user",
|
||||
userName: payload.name ?? "Playwright User",
|
||||
name: payload.name ?? "Playwright User",
|
||||
userEmail: "playwright@example.com",
|
||||
organization: payload.organization ?? "Unknown",
|
||||
reason: payload.reason ?? "No reason",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!state.developerRequests) {
|
||||
state.developerRequests = [];
|
||||
}
|
||||
state.developerRequests.push(created);
|
||||
return json(route, created, 201);
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname === "/api/v1/dev/requests/status" ||
|
||||
pathname === "/api/v1/dev/developer-request/status") &&
|
||||
method === "GET"
|
||||
) {
|
||||
const myRequest = (state.developerRequests ?? []).find(
|
||||
(r) => r.userId === "playwright-user",
|
||||
);
|
||||
return json(route, myRequest || null);
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith("/api/v1/dev/requests/") ||
|
||||
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
||||
pathname.endsWith("/approve") &&
|
||||
method === "POST"
|
||||
) {
|
||||
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
||||
const found = state.developerRequests?.find((r) => r.id === reqId);
|
||||
if (!found) return json(route, { error: "not found" }, 404);
|
||||
found.status = "approved";
|
||||
found.approvedAt = new Date().toISOString();
|
||||
return json(route, found);
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith("/api/v1/dev/requests/") ||
|
||||
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
||||
pathname.endsWith("/reject") &&
|
||||
method === "POST"
|
||||
) {
|
||||
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
||||
const found = state.developerRequests?.find((r) => r.id === reqId);
|
||||
if (!found) return json(route, { error: "not found" }, 404);
|
||||
found.status = "rejected";
|
||||
found.rejectedAt = new Date().toISOString();
|
||||
return json(route, found);
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith("/api/v1/dev/requests/") ||
|
||||
pathname.startsWith("/api/v1/dev/developer-request/")) &&
|
||||
(pathname.endsWith("/cancel") || pathname.endsWith("/cancel-approval")) &&
|
||||
method === "POST"
|
||||
) {
|
||||
const reqId = pathname.split("/")[5] ?? pathname.split("/")[4] ?? "";
|
||||
const found = state.developerRequests?.find((r) => r.id === reqId);
|
||||
if (!found) return json(route, { error: "not found" }, 404);
|
||||
found.status = "pending";
|
||||
found.approvedAt = undefined;
|
||||
return json(route, found);
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
||||
return json(route, [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
|
||||
@@ -349,6 +349,51 @@ loaded_count = "Loaded {{count}} rows"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "A super admin can review developer access requests and approve or reject them."
|
||||
approved = "Approved."
|
||||
cancelled = "Approval cancelled."
|
||||
empty = "No requests found."
|
||||
need_cancel_notes = "Please enter a reason for cancelling the approval."
|
||||
need_notes = "Please enter a rejection reason."
|
||||
rejected = "Rejected."
|
||||
user_desc = "Request developer access and check the review result."
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = "Review the information below and enter a request reason to apply for developer access."
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
org = "Organization"
|
||||
phone = "Phone"
|
||||
reason = "Request Reason"
|
||||
reason_placeholder = "Explain why you need developer access."
|
||||
role = "Role"
|
||||
title = "Developer Registration Request"
|
||||
|
||||
[msg.dev.request.status]
|
||||
approved = "Approved"
|
||||
cancelled = "Approval Cancelled"
|
||||
pending = "Pending"
|
||||
rejected = "Rejected"
|
||||
|
||||
[msg.dev.request.table]
|
||||
actions = "Actions"
|
||||
date = "Requested At"
|
||||
org = "Organization"
|
||||
reason = "Request Reason"
|
||||
status = "Status"
|
||||
user = "User"
|
||||
|
||||
[msg.dev.request.list]
|
||||
title = "Request History"
|
||||
|
||||
[msg.dev.request.admin]
|
||||
notes_placeholder = "Enter a reason for approval or rejection."
|
||||
|
||||
[msg.dev.request.cancel]
|
||||
approval = "Cancel Approval"
|
||||
notes_placeholder = "Enter a reason for cancelling the approval."
|
||||
|
||||
[msg.dev.auth]
|
||||
access_denied_description = "DevFront is for administrators only. Request access from your administrator."
|
||||
access_denied_title = "Access denied."
|
||||
@@ -2029,6 +2074,43 @@ subtitle = "Manage your applications"
|
||||
[ui.dev.nav]
|
||||
clients = "Connected Application"
|
||||
logout = "Logout"
|
||||
developer_request = "Developer Access Request"
|
||||
|
||||
[ui.dev.welcome]
|
||||
btn_request = "New Request"
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = "Enter a reason for approval or rejection."
|
||||
cancel_approval = "Cancel Approval"
|
||||
cancel_notes_placeholder = "Enter a reason for cancelling the approval."
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = "Request History"
|
||||
|
||||
[ui.dev.request.modal]
|
||||
desc = "Review the information below and enter a request reason to apply for developer access."
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
org = "Organization"
|
||||
phone = "Phone"
|
||||
reason = "Request Reason"
|
||||
reason_placeholder = "Explain why you need developer access."
|
||||
role = "Role"
|
||||
title = "Developer Registration Request"
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = "Approved"
|
||||
cancelled = "Approval Cancelled"
|
||||
pending = "Pending"
|
||||
rejected = "Rejected"
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = "Actions"
|
||||
date = "Requested At"
|
||||
org = "Organization"
|
||||
reason = "Request Reason"
|
||||
status = "Status"
|
||||
user = "User"
|
||||
|
||||
[ui.dev.profile]
|
||||
error = "Failed to load profile."
|
||||
|
||||
@@ -350,6 +350,43 @@ success = "성공"
|
||||
[ui.dev.nav]
|
||||
clients = "연동 앱"
|
||||
logout = "로그아웃"
|
||||
developer_request = "개발자 권한 신청"
|
||||
|
||||
[ui.dev.welcome]
|
||||
btn_request = "신규 신청하기"
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = "승인 또는 반려 사유를 입력하세요."
|
||||
cancel_approval = "승인 취소"
|
||||
cancel_notes_placeholder = "승인 취소 사유를 입력하세요."
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = "신청 내역"
|
||||
|
||||
[ui.dev.request.modal]
|
||||
desc = "개발자 권한을 신청하려면 아래 정보를 확인한 뒤 신청 사유를 입력하세요."
|
||||
email = "이메일"
|
||||
name = "성함"
|
||||
org = "소속"
|
||||
phone = "전화번호"
|
||||
reason = "신청 사유"
|
||||
reason_placeholder = "개발자 권한이 필요한 이유를 작성해주세요."
|
||||
role = "역할"
|
||||
title = "개발자 등록 신청"
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = "승인됨"
|
||||
cancelled = "승인 취소됨"
|
||||
pending = "대기 중"
|
||||
rejected = "반려됨"
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = "관리"
|
||||
date = "신청일"
|
||||
org = "소속"
|
||||
reason = "신청 사유"
|
||||
status = "상태"
|
||||
user = "사용자"
|
||||
|
||||
[ui.dev.tenant]
|
||||
single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다."
|
||||
@@ -751,6 +788,51 @@ loaded_count = "로드된 로그 {{count}}건"
|
||||
loading = "감사 로그를 불러오는 중..."
|
||||
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = "super admin이 개발자 권한 신청을 검토하고 승인 또는 반려할 수 있습니다."
|
||||
approved = "승인되었습니다."
|
||||
cancelled = "승인이 취소되었습니다."
|
||||
empty = "신청 내역이 없습니다."
|
||||
need_cancel_notes = "승인 취소 사유를 입력해주세요."
|
||||
need_notes = "반려 사유를 입력해주세요."
|
||||
rejected = "반려되었습니다."
|
||||
user_desc = "개발자 권한을 신청하고 승인 결과를 확인할 수 있습니다."
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = "개발자 권한을 신청하려면 아래 정보를 확인한 뒤 신청 사유를 입력하세요."
|
||||
email = "이메일"
|
||||
name = "성함"
|
||||
org = "소속"
|
||||
phone = "전화번호"
|
||||
reason = "신청 사유"
|
||||
reason_placeholder = "개발자 권한이 필요한 이유를 작성해주세요."
|
||||
role = "역할"
|
||||
title = "개발자 등록 신청"
|
||||
|
||||
[msg.dev.request.status]
|
||||
approved = "승인됨"
|
||||
cancelled = "승인 취소됨"
|
||||
pending = "대기 중"
|
||||
rejected = "반려됨"
|
||||
|
||||
[msg.dev.request.table]
|
||||
actions = "관리"
|
||||
date = "신청일"
|
||||
org = "소속"
|
||||
reason = "신청 사유"
|
||||
status = "상태"
|
||||
user = "사용자"
|
||||
|
||||
[msg.dev.request.list]
|
||||
title = "신청 내역"
|
||||
|
||||
[msg.dev.request.admin]
|
||||
notes_placeholder = "승인 또는 반려 사유를 입력하세요."
|
||||
|
||||
[msg.dev.request.cancel]
|
||||
approval = "승인 취소"
|
||||
notes_placeholder = "승인 취소 사유를 입력하세요."
|
||||
|
||||
[msg.dev.auth]
|
||||
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
|
||||
access_denied_title = "접근 권한이 없습니다."
|
||||
|
||||
@@ -225,6 +225,43 @@ success = ""
|
||||
[ui.dev.nav]
|
||||
clients = ""
|
||||
logout = ""
|
||||
developer_request = ""
|
||||
|
||||
[ui.dev.welcome]
|
||||
btn_request = ""
|
||||
|
||||
[ui.dev.request]
|
||||
admin_notes_placeholder = ""
|
||||
cancel_approval = ""
|
||||
cancel_notes_placeholder = ""
|
||||
|
||||
[ui.dev.request.list]
|
||||
title = ""
|
||||
|
||||
[ui.dev.request.modal]
|
||||
desc = ""
|
||||
email = ""
|
||||
name = ""
|
||||
org = ""
|
||||
phone = ""
|
||||
reason = ""
|
||||
reason_placeholder = ""
|
||||
role = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.request.status]
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
pending = ""
|
||||
rejected = ""
|
||||
|
||||
[ui.dev.request.table]
|
||||
actions = ""
|
||||
date = ""
|
||||
org = ""
|
||||
reason = ""
|
||||
status = ""
|
||||
user = ""
|
||||
|
||||
[ui.dev.tenant]
|
||||
single_notice = ""
|
||||
@@ -626,6 +663,51 @@ loaded_count = ""
|
||||
loading = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.request]
|
||||
admin_desc = ""
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
empty = ""
|
||||
need_cancel_notes = ""
|
||||
need_notes = ""
|
||||
rejected = ""
|
||||
user_desc = ""
|
||||
|
||||
[msg.dev.request.modal]
|
||||
desc = ""
|
||||
email = ""
|
||||
name = ""
|
||||
org = ""
|
||||
phone = ""
|
||||
reason = ""
|
||||
reason_placeholder = ""
|
||||
role = ""
|
||||
title = ""
|
||||
|
||||
[msg.dev.request.status]
|
||||
approved = ""
|
||||
cancelled = ""
|
||||
pending = ""
|
||||
rejected = ""
|
||||
|
||||
[msg.dev.request.table]
|
||||
actions = ""
|
||||
date = ""
|
||||
org = ""
|
||||
reason = ""
|
||||
status = ""
|
||||
user = ""
|
||||
|
||||
[msg.dev.request.list]
|
||||
title = ""
|
||||
|
||||
[msg.dev.request.admin]
|
||||
notes_placeholder = ""
|
||||
|
||||
[msg.dev.request.cancel]
|
||||
approval = ""
|
||||
notes_placeholder = ""
|
||||
|
||||
[msg.dev.auth]
|
||||
access_denied_description = ""
|
||||
access_denied_title = ""
|
||||
|
||||
Reference in New Issue
Block a user