1
0
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:
2026-04-22 17:32:17 +09:00
29 changed files with 2259 additions and 43 deletions

View File

@@ -196,7 +196,7 @@ code-check-front-lint:
code-check-backend-tests: code-check-backend-tests:
@echo "==> 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: code-check-userfront-tests:
@echo "==> userfront tests (isolated workspace)" @echo "==> userfront tests (isolated workspace)"

View File

@@ -5,6 +5,7 @@ import (
authhandler "baron-sso-backend/internal/handler" authhandler "baron-sso-backend/internal/handler"
"baron-sso-backend/internal/middleware" "baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
@@ -281,8 +282,9 @@ func runHeadlessPasswordLoginE2ERequest(
headers map[string]string, headers map[string]string,
) (*http.Response, string) { ) (*http.Response, string) {
t.Helper() t.Helper()
if !testsupport.PortBindingAvailable() {
t.Helper() t.Skip("skipping headless password login E2E tests because this environment cannot bind local TCP listeners")
}
logBuffer := &bytes.Buffer{} logBuffer := &bytes.Buffer{}
if logger == nil { if logger == nil {

View File

@@ -285,12 +285,13 @@ func main() {
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo) relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
secretRepo := repository.NewClientSecretRepository(db) secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db) consentRepo := repository.NewClientConsentRepository(db)
developerService := service.NewDeveloperService(db)
auditHandler := handler.NewAuditHandler(auditRepo) auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache authHandler.HeadlessJWKS = headlessJWKSCache
adminHandler := handler.NewAdminHandler(ketoService) adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo devHandler.AuditRepo = auditRepo
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
@@ -723,6 +724,15 @@ func main() {
dev.Delete("/consents", devHandler.RevokeConsents) dev.Delete("/consents", devHandler.RevokeConsents)
dev.Get("/audit-logs", devHandler.ListAuditLogs) 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) // Webhook for Kratos courier (HTTP delivery)
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)

View File

@@ -42,6 +42,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientConsent{}, &domain.ClientConsent{},
&domain.KetoOutbox{}, &domain.KetoOutbox{},
&domain.SharedLink{}, &domain.SharedLink{},
&domain.DeveloperRequest{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
) )
} }

View 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"`
}

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"runtime" "runtime"
"time" "time"
@@ -10,10 +11,14 @@ import (
type AdminHandler struct { type AdminHandler struct {
Keto service.KetoService Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
} }
func NewAdminHandler(keto service.KetoService) *AdminHandler { func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
return &AdminHandler{Keto: keto} return &AdminHandler{
Keto: keto,
KetoOutbox: ketoOutbox,
}
} }
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes" "bytes"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -173,6 +174,10 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
} }
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(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)} redis := &mockRedisRepo{data: make(map[string]string)}
privateKey, jwks := mustHeadlessRSAJWK(t) privateKey, jwks := mustHeadlessRSAJWK(t)
jwksBody, _ := json.Marshal(jwks) jwksBody, _ := json.Marshal(jwks)
@@ -240,6 +245,10 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
} }
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(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)} redis := &mockRedisRepo{data: make(map[string]string)}
privateKey, jwks := mustHeadlessRSAJWK(t) privateKey, jwks := mustHeadlessRSAJWK(t)
jwksBody, _ := json.Marshal(jwks) jwksBody, _ := json.Marshal(jwks)

View File

@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/middleware" "baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes" "bytes"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
@@ -369,6 +370,9 @@ func runHeadlessPasswordLoginWithAssertionRequest(
headers map[string]string, headers map[string]string,
) *http.Response { ) *http.Response {
t.Helper() t.Helper()
if !testsupport.PortBindingAvailable() {
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
}
mockIdp := new(MockIdentityProvider) mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
@@ -469,6 +473,9 @@ func runHeadlessPasswordLoginWithAssertionAndLoggerRequest(
logger *slog.Logger, logger *slog.Logger,
) *http.Response { ) *http.Response {
t.Helper() t.Helper()
if !testsupport.PortBindingAvailable() {
t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners")
}
mockIdp := new(MockIdentityProvider) mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ 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) { 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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -1006,6 +1017,10 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) {
} }
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -1089,6 +1104,10 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(
} }
func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) { 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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -1271,6 +1290,10 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
} }
func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},

View File

@@ -15,6 +15,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -34,6 +35,7 @@ type DevHandler struct {
KetoOutbox repository.KetoOutboxRepository KetoOutbox repository.KetoOutboxRepository
RPSvc service.RelyingPartyService RPSvc service.RelyingPartyService
TenantSvc service.TenantService TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
Auth interface { Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
} }
@@ -47,6 +49,7 @@ func NewDevHandler(
keto service.KetoService, keto service.KetoService,
ketoOutbox repository.KetoOutboxRepository, ketoOutbox repository.KetoOutboxRepository,
tenantSvc service.TenantService, tenantSvc service.TenantService,
developerSvc *service.DeveloperService,
auth ...interface { auth ...interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}, },
@@ -70,6 +73,7 @@ func NewDevHandler(
KetoOutbox: ketoOutbox, KetoOutbox: ketoOutbox,
RPSvc: rpSvc, RPSvc: rpSvc,
TenantSvc: tenantSvc, TenantSvc: tenantSvc,
DeveloperSvc: developerSvc,
Auth: authProvider, Auth: authProvider,
} }
} }
@@ -345,12 +349,17 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
if profile == nil { if profile == nil {
return false return false
} }
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { role := normalizeUserRole(profile.Role)
if role == domain.RoleSuperAdmin {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
return true return true
} }
clientTenantID := resolveClientTenantID(summary) clientTenantID := resolveClientTenantID(summary)
if clientTenantID != "" { if role != domain.RoleUser && clientTenantID != "" {
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed { if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
return true return true
} }
@@ -360,6 +369,23 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
return err == nil && allowed return err == nil && allowed
} }
func (h *DevHandler) hasDirectRelyingPartyOperatorRelation(c *fiber.Ctx, profile *domain.UserProfileResponse, clientID string) bool {
if h.Keto == nil || profile == nil {
return false
}
subject := ketoSubjectFromProfile(profile)
if subject == "" || strings.TrimSpace(clientID) == "" {
return false
}
for relation := range allowedRelyingPartyOperatorRelations {
tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, subject)
if err == nil && len(tuples) > 0 {
return true
}
}
return false
}
func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
if strings.TrimSpace(tenantID) == "" { if strings.TrimSpace(tenantID) == "" {
return false return false
@@ -1424,7 +1450,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if tenantID == "" && profile.TenantID != nil { if tenantID == "" && profile.TenantID != nil {
tenantID = *profile.TenantID tenantID = *profile.TenantID
} }
if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) { if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required") return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
} }
@@ -1469,7 +1495,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error") return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
} }
if !isAppManager { if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
} }
} }
@@ -1551,6 +1577,28 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
} }
h.syncHeadlessJWKSCache(c.Context(), *created, "client_create") 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 // Store secret in metadata for later retrieval
if created.ClientSecret != "" { if created.ClientSecret != "" {
// 1. Store in PostgreSQL (Source of Truth) // 1. Store in PostgreSQL (Source of Truth)
@@ -2748,7 +2796,17 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
role := normalizeUserRole(profile.Role) role := normalizeUserRole(profile.Role)
if role == domain.RoleUser { if role == domain.RoleUser {
return errorJSON(c, fiber.StatusForbidden, "access denied") if profile.TenantID == nil || strings.TrimSpace(*profile.TenantID) == "" {
return c.JSON([]domain.Tenant{})
}
tenant, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
}
if tenant == nil {
return c.JSON([]domain.Tenant{})
}
return c.JSON([]domain.Tenant{*tenant})
} }
if role == domain.RoleSuperAdmin { if role == domain.RoleSuperAdmin {
@@ -2781,3 +2839,274 @@ func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
return c.JSON(tenants) 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"})
}

View File

@@ -92,6 +92,14 @@ func TestDevHandler_Isolation(t *testing.T) {
// Explicit permission for private client check bypass // 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("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) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)

View File

@@ -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) { 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) args := m.Called(ctx, ns, obj, rel, sub)
return args.Get(0).([]service.RelationTuple), args.Error(1) return args.Get(0).([]service.RelationTuple), args.Error(1)
} }
@@ -241,10 +254,16 @@ func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
}) })
mockKeto := new(devMockKetoService) 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", "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("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{ h := &DevHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
@@ -843,7 +862,6 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
}) })
mockKeto := new(devMockKetoService) 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").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, 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 := 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").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").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) mockKeto.AssertExpectations(t)
} }
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
body["client_secret"] = "generated-secret"
return httpJSONAny(r, http.StatusCreated, body), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "admins" &&
entry.Subject == "User:user-1" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
Redis: &devMockRedisRepo{data: make(map[string]string)},
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
tenantID := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"id": "client-1",
"name": "App One",
"type": "private",
"redirectUris": []string{"http://localhost/cb"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestGetStats_Success(t *testing.T) { func TestGetStats_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {

View File

@@ -2,6 +2,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"context" "context"
"log" "log"
"os" "os"
@@ -18,6 +19,11 @@ import (
var testDB *gorm.DB var testDB *gorm.DB
func TestMain(m *testing.M) { 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() ctx := context.Background()
// Start PostgreSQL container // Start PostgreSQL container

View 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
}

View 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
}

View File

@@ -9,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientRelationsPage from "../features/clients/ClientRelationsPage"; import ClientRelationsPage from "../features/clients/ClientRelationsPage";
import ClientsPage from "../features/clients/ClientsPage"; import ClientsPage from "../features/clients/ClientsPage";
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
import ProfilePage from "../features/profile/ProfilePage"; import ProfilePage from "../features/profile/ProfilePage";
export const router = createBrowserRouter( export const router = createBrowserRouter(
@@ -38,6 +39,7 @@ export const router = createBrowserRouter(
path: "clients/:id/relationships", path: "clients/:id/relationships",
element: <ClientRelationsPage />, element: <ClientRelationsPage />,
}, },
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> }, { path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> }, { path: "profile", element: <ProfilePage /> },
], ],

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { import {
BadgeCheck, BadgeCheck,
ChevronDown, ChevronDown,
ClipboardCheck,
LogOut, LogOut,
Moon, Moon,
NotebookTabs, NotebookTabs,
@@ -29,6 +30,12 @@ const navItems = [
to: "/clients", to: "/clients",
icon: ShieldHalf, icon: ShieldHalf,
}, },
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "개발자 권한 신청",
to: "/developer-requests",
icon: ClipboardCheck,
},
{ {
labelKey: "ui.dev.nav.audit_logs", labelKey: "ui.dev.nav.audit_logs",
labelFallback: "Audit Logs", labelFallback: "Audit Logs",

View File

@@ -529,12 +529,16 @@ function ClientGeneralPage() {
onError: (err) => { onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>; const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) { if (axiosError.response?.status === 403) {
toast( alert(
t( isCreate
? t(
"msg.dev.clients.general.create_forbidden",
"이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요.",
)
: t(
"msg.dev.clients.general.save_forbidden", "msg.dev.clients.general.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
), ),
"error",
); );
return; return;
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
BookOpenText, BookOpenText,
@@ -7,8 +7,9 @@ import {
Search, Search,
ServerCog, ServerCog,
ShieldHalf, ShieldHalf,
X,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
@@ -27,6 +28,7 @@ import {
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator"; import { Separator } from "../../components/ui/separator";
import { import {
Table, Table,
@@ -36,19 +38,27 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } 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 { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role"; import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
function ClientsPage() { function ClientsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth(); const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token); const hasAccessToken = Boolean(auth.user?.access_token);
const role = resolveProfileRole( const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
auth.user?.profile as Record<string, unknown> | undefined, const role = resolveProfileRole(userProfile);
); const tenantId = userProfile?.tenant_id as string | undefined;
const canCreateClient = role !== "user" && role !== "tenant_member"; const companyCode = userProfile?.companyCode as string | undefined;
const { const {
data, data,
@@ -66,10 +76,41 @@ function ClientsPage() {
enabled: hasAccessToken, 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 [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const clients = data?.items || []; const clients = data?.items || [];
@@ -87,6 +128,21 @@ function ClientsPage() {
const totalClients = statsData?.total_clients ?? clients.length; const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0; const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0; const authFailures = statsData?.auth_failures_24h ?? 0;
const hasFilterResult = filteredClients.length > 0;
const isFilteredOut = clients.length > 0 && !hasFilterResult;
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role || role;
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
type StatTone = "up" | "down" | "stable"; type StatTone = "up" | "down" | "stable";
type StatItem = { type StatItem = {
@@ -128,7 +184,7 @@ function ClientsPage() {
}, },
]; ];
const isLoading = isLoadingClients || isLoadingStats; const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
if (auth.isLoading || !hasAccessToken || isLoading) { if (auth.isLoading || !hasAccessToken || isLoading) {
return ( return (
@@ -359,7 +415,7 @@ function ClientsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredClients.length === 0 && ( {!hasFilterResult && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={6}
@@ -367,17 +423,70 @@ function ClientsPage() {
> >
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">
{t( {isFilteredOut
? 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", "msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.", "조회 가능한 RP가 없습니다.",
)} )}
</p> </p>
<p className="text-sm"> <div className="text-sm space-y-2">
{t( <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", "msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)} )}
</p> </p>
{!isFilteredOut && canCreateClient && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/clients/new")}
>
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/developer-requests")}
>
{t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
</button>
)}
</div>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -552,6 +661,198 @@ function ClientsPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </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> </div>
); );
} }

View 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>
);
}

View File

@@ -386,3 +386,87 @@ export async function fetchMyTenants() {
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants"); const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
return data; 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;
}

View File

@@ -334,6 +334,12 @@ delete_error = "Failed to delete: {{error}}"
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
empty = "No RPs are available." empty = "No RPs are available."
empty_detail = "RPs will appear here when a relationship is assigned to your account." empty_detail = "RPs will appear here when a relationship is assigned to your account."
empty_can_create = "No linked apps have been registered yet."
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
empty_filtered = "No linked apps match the current filters."
empty_filtered_detail = "Try changing the search text or filters."
empty_pending = "Your developer access request is under review."
empty_pending_detail = "You can add linked apps after a super admin approves it."
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "No consents found." empty = "No consents found."
@@ -353,6 +359,7 @@ missing_id = "Client ID is required."
redirect_saved = "Redirect URIs saved." redirect_saved = "Redirect URIs saved."
rotate_confirm = "Rotate Confirm" rotate_confirm = "Rotate Confirm"
rotate_error = "Rotate Error" rotate_error = "Rotate Error"
create_forbidden = "You do not have permission to create this RP. Ask an administrator to grant developer access."
save_error = "Save Error" save_error = "Save Error"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship." save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
secret_rotated = "Secret Rotated" secret_rotated = "Secret Rotated"

View File

@@ -331,6 +331,12 @@ delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은
delete_error = "삭제 실패: {{error}}" delete_error = "삭제 실패: {{error}}"
empty = "조회 가능한 RP가 없습니다." empty = "조회 가능한 RP가 없습니다."
empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다." empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다."
empty_can_create = "아직 등록된 연동 앱이 없습니다."
empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다."
empty_filtered = "조건에 맞는 연동 앱이 없습니다."
empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요."
empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}" load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..." loading = "앱 정보를 불러오는 중..."
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
@@ -353,6 +359,7 @@ missing_id = "Client ID가 필요합니다."
redirect_saved = "Redirect URIs가 저장되었습니다." redirect_saved = "Redirect URIs가 저장되었습니다."
rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?" rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?"
rotate_error = "재발급 실패: {{error}}" rotate_error = "재발급 실패: {{error}}"
create_forbidden = "이 RP를 생성할 권한이 없습니다.\n관리자에게 개발자 권한 부여를 요청해 주세요."
save_error = "저장 실패: {{error}}" save_error = "저장 실패: {{error}}"
save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요."
secret_rotated = "Client Secret이 재발급되었습니다." secret_rotated = "Client Secret이 재발급되었습니다."

View File

@@ -325,6 +325,51 @@ loaded_count = ""
loading = "" loading = ""
subtitle = "" 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] [msg.dev.clients]
load_error = "" load_error = ""
loading = "" loading = ""
@@ -334,6 +379,12 @@ delete_error = ""
delete_confirm = "" delete_confirm = ""
empty = "" empty = ""
empty_detail = "" empty_detail = ""
empty_can_create = ""
empty_can_create_detail = ""
empty_filtered = ""
empty_filtered_detail = ""
empty_pending = ""
empty_pending_detail = ""
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "" empty = ""
@@ -353,6 +404,7 @@ missing_id = ""
redirect_saved = "" redirect_saved = ""
rotate_confirm = "" rotate_confirm = ""
rotate_error = "" rotate_error = ""
create_forbidden = ""
save_error = "" save_error = ""
save_forbidden = "" save_forbidden = ""
secret_rotated = "" secret_rotated = ""
@@ -1283,6 +1335,42 @@ scope_badge = ""
audit_logs = "" audit_logs = ""
clients = "" clients = ""
logout = "" 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] [ui.dev.audit]
load_more = "" load_more = ""

View 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();
});
});

View File

@@ -53,6 +53,25 @@ export type Consent = {
tenantName: string; 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 = { export type ClientRelation = {
relation: string; relation: string;
subject: string; subject: string;
@@ -84,6 +103,7 @@ export type AuditLog = {
export type DevApiMockState = { export type DevApiMockState = {
clients: Client[]; clients: Client[];
consents: Consent[]; consents: Consent[];
developerRequests?: DeveloperRequest[];
relations?: Record<string, ClientRelation[]>; relations?: Record<string, ClientRelation[]>;
users?: DevAssignableUser[]; users?: DevAssignableUser[];
auditLogsByCursor?: Record< auditLogsByCursor?: Record<
@@ -241,6 +261,97 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
const { pathname, searchParams } = url; const { pathname, searchParams } = url;
const method = request.method(); 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") { if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
return json(route, [ return json(route, [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" }, { id: "tenant-a", name: "Tenant A", slug: "tenant-a" },

View File

@@ -349,6 +349,51 @@ loaded_count = "Loaded {{count}} rows"
loading = "Loading audit logs..." loading = "Loading audit logs..."
subtitle = "Shows DevFront activity history within current tenant/app scope." 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] [msg.dev.auth]
access_denied_description = "DevFront is for administrators only. Request access from your administrator." access_denied_description = "DevFront is for administrators only. Request access from your administrator."
access_denied_title = "Access denied." access_denied_title = "Access denied."
@@ -2029,6 +2074,43 @@ subtitle = "Manage your applications"
[ui.dev.nav] [ui.dev.nav]
clients = "Connected Application" clients = "Connected Application"
logout = "Logout" 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] [ui.dev.profile]
error = "Failed to load profile." error = "Failed to load profile."

View File

@@ -350,6 +350,43 @@ success = "성공"
[ui.dev.nav] [ui.dev.nav]
clients = "연동 앱" clients = "연동 앱"
logout = "로그아웃" 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] [ui.dev.tenant]
single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다." single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다."
@@ -751,6 +788,51 @@ loaded_count = "로드된 로그 {{count}}건"
loading = "감사 로그를 불러오는 중..." loading = "감사 로그를 불러오는 중..."
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다." 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] [msg.dev.auth]
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요." access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
access_denied_title = "접근 권한이 없습니다." access_denied_title = "접근 권한이 없습니다."

View File

@@ -225,6 +225,43 @@ success = ""
[ui.dev.nav] [ui.dev.nav]
clients = "" clients = ""
logout = "" 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] [ui.dev.tenant]
single_notice = "" single_notice = ""
@@ -626,6 +663,51 @@ loaded_count = ""
loading = "" loading = ""
subtitle = "" 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] [msg.dev.auth]
access_denied_description = "" access_denied_description = ""
access_denied_title = "" access_denied_title = ""