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

View File

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

View File

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

View File

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

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

@@ -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는 링크 로그인 초기화 결과입니다.

View File

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

View File

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

View File

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

View File

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

View File

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

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) {
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" {

View File

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

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 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 /> },
],

View File

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

View File

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

View File

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

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");
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."
empty = "No RPs are available."
empty_detail = "RPs will appear here when a relationship is assigned to your account."
empty_can_create = "No linked apps have been registered yet."
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
empty_filtered = "No linked apps match the current filters."
empty_filtered_detail = "Try changing the search text or filters."
empty_pending = "Your developer access request is under review."
empty_pending_detail = "You can add linked apps after a super admin approves it."
[msg.dev.clients.consents]
empty = "No consents found."
@@ -353,6 +359,7 @@ missing_id = "Client ID is required."
redirect_saved = "Redirect URIs saved."
rotate_confirm = "Rotate Confirm"
rotate_error = "Rotate Error"
create_forbidden = "You do not have permission to create this RP. Ask an administrator to grant developer access."
save_error = "Save Error"
save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship."
secret_rotated = "Secret Rotated"

View File

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

View File

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

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;
};
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" },

View File

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

View File

@@ -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 = "접근 권한이 없습니다."

View File

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