1
0
forked from baron/baron-sso

Merge pull request 'feature/uf-sign-page' (#942) from feature/uf-sign-page into dev

Reviewed-on: baron/baron-sso#942
This commit is contained in:
2026-05-29 18:39:41 +09:00
65 changed files with 3663 additions and 809 deletions

View File

@@ -1,4 +1,7 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"]
"extends": ["../common/config/biome.base.json"],
"files": {
"includes": [".vite"]
}
}

View File

@@ -120,6 +120,7 @@ ensure_frontend_dependencies
if [ "$mode" = "production" ]; then
echo "Running in production mode with custom static server..."
export ADMINFRONT_BUILD_OUT_DIR="${ADMINFRONT_BUILD_OUT_DIR:-/tmp/baron-sso-adminfront-dist}"
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
fi

View File

@@ -2,7 +2,8 @@ import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
const buildOutDir = process.env.ADMINFRONT_BUILD_OUT_DIR ?? "dist";
const buildOutDir =
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
export default defineConfig({
plugins: [react()],
@@ -11,6 +12,9 @@ export default defineConfig({
"lucide-react": path.resolve(process.cwd(), "node_modules/lucide-react"),
},
},
cacheDir:
process.env.ADMINFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-adminfront-vite-cache",
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
build: {
outDir: buildOutDir,

View File

@@ -1357,6 +1357,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
}
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
@@ -1403,6 +1404,16 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "ADD_RELATION",
"target_id": clientID,
"tenant_id": tenantID,
"after": map[string]any{
"relation": req.Relation,
"subject": req.Subject,
},
})
return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{
Object: clientID,
Relation: req.Relation,
@@ -1411,6 +1422,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
}
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
clientID := strings.TrimSpace(c.Params("id"))
if clientID == "" {
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
@@ -1444,6 +1456,16 @@ func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.setAuditDetailsExtra(c, map[string]any{
"action": "REMOVE_RELATION",
"target_id": clientID,
"tenant_id": tenantID,
"before": map[string]any{
"relation": relation,
"subject": subject,
},
})
return c.SendStatus(fiber.StatusNoContent)
}
@@ -1936,7 +1958,8 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
isSuperAdmin := role == domain.RoleSuperAdmin
if !isSuperAdmin && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required")
}
@@ -1949,7 +1972,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
// [Security] Check permission for private clients (both current and new type)
if currentSummary.Type == "private" || clientType == "private" {
if !isSuperAdmin && (currentSummary.Type == "private" || clientType == "private") {
if !h.canBypassPrivateClientRestriction(c, profile, currentSummary, "edit_config") {
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
}
@@ -2050,19 +2073,48 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata)
beforeScopes := strings.Fields(current.Scope)
afterScopes := strings.Fields(updated.Scope)
beforeAllowedTenants := readStringSliceMetadata(current.Metadata, "allowed_tenants")
afterAllowedTenants := readStringSliceMetadata(metadata, "allowed_tenants")
beforeIDTokenClaims := readMetadataValueOrNil(current.Metadata, "id_token_claims")
afterIDTokenClaims := readMetadataValueOrNil(metadata, "id_token_claims")
h.setAuditDetailsExtra(c, map[string]any{
"action": "UPDATE_CLIENT",
"target_id": clientID,
"tenant_id": tenantID,
"before": map[string]any{
"name": currentSummary.Name,
"type": currentSummary.Type,
"status": currentSummary.Status,
"name": currentSummary.Name,
"type": currentSummary.Type,
"status": currentSummary.Status,
"scopes": beforeScopes,
"tenant_access_restricted": readMetadataBoolValue(current.Metadata, "tenant_access_restricted"),
"allowed_tenants": beforeAllowedTenants,
"id_token_claims": beforeIDTokenClaims,
"token_endpoint_auth_method": current.TokenEndpointAuthMethod,
"jwks_uri": current.JWKSUri,
"backchannel_logout_uri": strings.TrimSpace(current.BackchannelLogoutURI()),
"backchannel_logout_session_required": current.BackchannelLogoutSessionRequiredValue(),
"headless_login_enabled": readMetadataBoolValue(current.Metadata, domain.MetadataHeadlessLoginEnabled),
"headless_token_endpoint_auth_method": readMetadataStringValue(current.Metadata, domain.MetadataHeadlessTokenEndpointAuthMethod),
"headless_jwks_uri": readMetadataStringValue(current.Metadata, domain.MetadataHeadlessJWKSURI),
},
"after": map[string]any{
"name": strings.TrimSpace(updated.ClientName),
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
"status": resolveStatusFromMetadata(updated.Metadata),
"name": strings.TrimSpace(updated.ClientName),
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
"status": resolveStatusFromMetadata(updated.Metadata),
"scopes": afterScopes,
"tenant_access_restricted": readMetadataBoolValue(metadata, "tenant_access_restricted"),
"allowed_tenants": afterAllowedTenants,
"id_token_claims": afterIDTokenClaims,
"token_endpoint_auth_method": resolvedTokenAuthMethod,
"jwks_uri": resolvedJWKSURI,
"backchannel_logout_uri": strings.TrimSpace(resolvedBackchannelLogoutURI),
"backchannel_logout_session_required": resolvedBackchannelLogoutSessionRequired,
"headless_login_enabled": readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled),
"headless_token_endpoint_auth_method": readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod),
"headless_jwks_uri": readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI),
},
})
@@ -2912,6 +2964,49 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
return value
}
func readStringSliceMetadata(metadata map[string]interface{}, key string) []string {
if metadata == nil {
return nil
}
raw, ok := metadata[key]
if !ok || raw == nil {
return nil
}
switch typed := raw.(type) {
case []string:
result := make([]string, 0, len(typed))
for _, item := range typed {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
case []interface{}:
result := make([]string, 0, len(typed))
for _, item := range typed {
if str, ok := item.(string); ok {
if trimmed := strings.TrimSpace(str); trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
default:
return nil
}
}
func readMetadataValueOrNil(metadata map[string]interface{}, key string) interface{} {
if metadata == nil {
return nil
}
value, ok := metadata[key]
if !ok {
return nil
}
return value
}
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
if metadata == nil {
metadata = map[string]interface{}{}

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
auditmw "baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/service"
"bytes"
"context"
@@ -154,6 +155,14 @@ func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *devMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, namespace, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}
@@ -591,6 +600,199 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
mockKeto.AssertExpectations(t)
}
func TestUpdateClient_SuperAdminBypassesEditConfigPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"redirect_uris": []string{
"http://localhost/cb",
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
"tenant_id": "tenant-1",
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One Updated",
"redirect_uris": []string{
"http://localhost/cb",
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email offline_access",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
"tenant_id": "tenant-1",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
tenantID := "tenant-1"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleSuperAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "App One Updated",
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result clientDetailResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, "App One Updated", result.Client.Name)
}
func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"redirect_uris": []string{
"http://localhost/cb",
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"metadata": map[string]any{
"status": "active",
"tenant_id": "tenant-1",
"tenant_access_restricted": false,
"allowed_tenants": []any{},
"id_token_claims": []any{},
"headless_login_enabled": false,
"headless_jwks_uri": "",
"headless_token_endpoint_auth_method": "",
},
}), nil
}
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One Updated",
"redirect_uris": []string{
"http://localhost/cb",
},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "openid profile email tenant",
"token_endpoint_auth_method": "private_key_jwt",
"metadata": map[string]any{
"status": "active",
"tenant_id": "tenant-1",
"tenant_access_restricted": true,
"allowed_tenants": []any{"tenant-1", "tenant-2"},
"id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}},
"headless_login_enabled": true,
"headless_jwks_uri": "https://rp.example.com/jwks.json",
"headless_token_endpoint_auth_method": "private_key_jwt",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
auditRepo := &mockAuditRepo{}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
AuditRepo: auditRepo,
}
app := fiber.New()
app.Use(auditmw.AuditMiddleware(auditmw.AuditConfig{Repo: auditRepo}))
tenantID := "tenant-1"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleSuperAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{
"name": "App One Updated",
"scopes": []string{"openid", "profile", "email", "tenant"},
"metadata": map[string]any{
"tenant_access_restricted": true,
"allowed_tenants": []string{"tenant-1", "tenant-2"},
"id_token_claims": []map[string]any{
{
"namespace": "top_level",
"key": "locale",
"valueType": "text",
"value": "ko-KR",
},
},
"headless_login_enabled": true,
"headless_jwks_uri": "https://rp.example.com/jwks.json",
"headless_token_endpoint_auth_method": "private_key_jwt",
"backchannel_logout_uri": "https://rp.example.com/logout",
"backchannel_logout_session_required": true,
},
"tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/jwks.json",
"backchannelLogoutUri": "https://rp.example.com/logout",
"backchannelLogoutSessionRequired": true,
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
if assert.NotEmpty(t, auditRepo.logs) {
var details map[string]any
assert.NoError(t, json.Unmarshal([]byte(auditRepo.logs[0].Details), &details))
before, _ := details["before"].(map[string]any)
after, _ := details["after"].(map[string]any)
assert.NotNil(t, before)
assert.NotNil(t, after)
assert.Contains(t, after, "scopes")
assert.Contains(t, after, "tenant_access_restricted")
assert.Contains(t, after, "allowed_tenants")
assert.Contains(t, after, "id_token_claims")
assert.Contains(t, after, "headless_login_enabled")
assert.Contains(t, after, "headless_jwks_uri")
assert.Contains(t, after, "backchannel_logout_uri")
assert.Contains(t, after, "backchannel_logout_session_required")
}
}
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {

View File

@@ -8,6 +8,7 @@ import (
"baron-sso-backend/internal/utils"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -1768,6 +1769,11 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
}
}
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, id); err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
if err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
@@ -2222,6 +2228,10 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
}
}
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
@@ -2255,6 +2265,164 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, requester *domain.UserProfileResponse, userID string) error {
if h.KetoService == nil || h.KetoOutboxRepo == nil {
return nil
}
actorID := ""
tenantID := ""
if requester != nil {
actorID = strings.TrimSpace(requester.ID)
if requester.TenantID != nil {
tenantID = strings.TrimSpace(*requester.TenantID)
}
}
subject := "User:" + strings.TrimSpace(userID)
tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
if err != nil {
return fmt.Errorf("failed to list relying party relations for user %s: %w", userID, err)
}
if len(tuples) == 0 {
slog.Info("[UserHandler] No relying party relations found for deleted user cleanup", "userID", userID)
return nil
}
seen := make(map[string]struct{}, len(tuples))
for _, tuple := range tuples {
if strings.TrimSpace(tuple.Object) == "" || strings.TrimSpace(tuple.Relation) == "" {
continue
}
relSubject := strings.TrimSpace(tuple.SubjectID)
if relSubject == "" {
relSubject = subject
}
key := tuple.Namespace + "\x00" + tuple.Object + "\x00" + tuple.Relation + "\x00" + relSubject
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
namespace := strings.TrimSpace(tuple.Namespace)
if namespace == "" {
namespace = "RelyingParty"
}
if err := h.KetoService.DeleteRelation(ctx, namespace, tuple.Object, tuple.Relation, relSubject); err != nil {
slog.Warn("[UserHandler] Failed to delete RelyingParty relation immediately", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
}
if err := h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: namespace,
Object: tuple.Object,
Relation: tuple.Relation,
Subject: relSubject,
Action: domain.KetoOutboxActionDelete,
}); err != nil {
slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
continue
}
if err := h.recordDeletedUserRelyingPartyCleanupAudit(actorID, tenantID, userID, tuple, relSubject); err != nil {
slog.Warn("[UserHandler] Failed to record RelyingParty cleanup audit", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
}
}
return nil
}
func (h *UserHandler) recordDeletedUserRelyingPartyCleanupAudit(
actorID string,
tenantID string,
deletedUserID string,
tuple service.RelationTuple,
relSubject string,
) error {
if h.AuditRepo == nil {
return nil
}
details := map[string]any{
"action": "REMOVE_RELATION",
"target_id": strings.TrimSpace(tuple.Object),
"source": "user_delete",
"deleted_user_id": strings.TrimSpace(deletedUserID),
"cascade_cleanup": true,
"relation_subject": strings.TrimSpace(relSubject),
"before": map[string]any{
"relation": strings.TrimSpace(tuple.Relation),
"subject": strings.TrimSpace(relSubject),
},
}
if strings.TrimSpace(tenantID) != "" {
details["tenant_id"] = strings.TrimSpace(tenantID)
}
raw, err := json.Marshal(details)
if err != nil {
return err
}
eventType := fmt.Sprintf("DELETE /api/v1/dev/clients/%s/relations", strings.TrimSpace(tuple.Object))
if strings.TrimSpace(tuple.Relation) != "" {
eventType = fmt.Sprintf("%s/%s", eventType, strings.TrimSpace(tuple.Relation))
}
return h.AuditRepo.Create(&domain.AuditLog{
EventID: fmt.Sprintf("user-delete-rp-cleanup-%d", time.Now().UnixNano()),
Timestamp: time.Now().UTC(),
UserID: strings.TrimSpace(actorID),
TenantID: strings.TrimSpace(tenantID),
EventType: eventType,
Status: "success",
Details: string(raw),
})
}
func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
var tuples []service.RelationTuple
var err error
for attempt := 0; attempt < 3; attempt++ {
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
if err != nil {
return nil, err
}
if len(tuples) > 0 {
return tuples, nil
}
if attempt == 2 {
break
}
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
fallbackEntries, err := h.KetoOutboxRepo.ListCurrentBySubject(ctx, "RelyingParty", subject)
if err != nil {
return nil, err
}
if len(fallbackEntries) == 0 {
return nil, nil
}
tuples = make([]service.RelationTuple, 0, len(fallbackEntries))
for _, entry := range fallbackEntries {
tuples = append(tuples, service.RelationTuple{
Namespace: entry.Namespace,
Object: entry.Object,
Relation: entry.Relation,
SubjectID: entry.Subject,
})
}
slog.Warn("[UserHandler] Falling back to keto_outbox history for deleted user RP cleanup", "subject", subject, "tuples", len(tuples))
return tuples, nil
}
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
traits := identity.Traits
role := roleFromTraits(traits)

View File

@@ -18,6 +18,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// --- Mocks ---
@@ -98,6 +99,75 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
}
type userHandlerMockKetoService struct {
mock.Mock
}
func (m *userHandlerMockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *userHandlerMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *userHandlerMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *userHandlerMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *userHandlerMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
type userHandlerMockKetoOutboxRepository struct {
mock.Mock
}
func (m *userHandlerMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
return m.Called(ctx, entry).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
return m.Called(tx, entry).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, limit)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *userHandlerMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, namespace, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *userHandlerMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}
func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
type fakeUserHandlerWorksmobileSyncer struct {
upserts []domain.User
}
@@ -1083,13 +1153,35 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
h := &UserHandler{KratosAdmin: mockKratos, UserRepo: userRepo}
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
{Namespace: "RelyingParty", Object: "client-2", Relation: "audit_viewer", SubjectID: "User:u-1"},
}, nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "audit_viewer", "User:u-1").Return(nil).Once()
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:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "audit_viewer" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
@@ -1098,6 +1190,167 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers_CleansUpRelyingPartyRelations(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.BulkDeleteUsers(c)
})
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ID: "u-1"}, nil).Once()
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
}, nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
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:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
payload := map[string]interface{}{
"userIds": []string{"u-1"},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodDelete, "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{}, nil).Times(3)
mockOutbox.On("ListCurrentBySubject", mock.Anything, "RelyingParty", "User:u-1").Return([]domain.KetoOutbox{
{
Namespace: "RelyingParty",
Object: "client-1",
Relation: "admins",
Subject: "User:u-1",
Action: domain.KetoOutboxActionCreate,
},
{
Namespace: "RelyingParty",
Object: "client-2",
Relation: "config_editor",
Subject: "User:u-1",
Action: domain.KetoOutboxActionCreate,
},
}, nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "config_editor", "User:u-1").Return(nil).Once()
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:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "config_editor" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
mockKeto := new(userHandlerMockKetoService)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
auditRepo := &mockAuditRepo{}
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: userRepo,
KetoService: mockKeto,
KetoOutboxRepo: mockOutbox,
AuditRepo: auditRepo,
}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
}, nil).Once()
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
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:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
})).Return(nil).Once()
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Len(t, auditRepo.logs, 1)
log := auditRepo.logs[0]
assert.Equal(t, "admin-1", log.UserID)
assert.Equal(t, "DELETE /api/v1/dev/clients/client-1/relations/admins", log.EventType)
details := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(log.Details), &details))
assert.Equal(t, "REMOVE_RELATION", details["action"])
assert.Equal(t, "client-1", details["target_id"])
assert.Equal(t, "user_delete", details["source"])
assert.Equal(t, "u-1", details["deleted_user_id"])
assert.Equal(t, "User:u-1", details["relation_subject"])
before, ok := details["before"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "admins", before["relation"])
assert.Equal(t, "User:u-1", before["subject"])
mockKratos.AssertExpectations(t)
mockKeto.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {

View File

@@ -12,6 +12,7 @@ type KetoOutboxRepository interface {
Create(ctx context.Context, entry *domain.KetoOutbox) error
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error)
ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error)
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
MarkProcessed(ctx context.Context, id string) error
}
@@ -42,6 +43,32 @@ func (r *ketoOutboxRepository) FindPending(ctx context.Context, limit int) ([]do
return entries, err
}
func (r *ketoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
var entries []domain.KetoOutbox
if err := r.db.WithContext(ctx).
Where("namespace = ? AND subject = ? AND status <> ?", namespace, subject, domain.KetoOutboxStatusFailed).
Order("created_at desc").
Order("updated_at desc").
Find(&entries).Error; err != nil {
return nil, err
}
current := make([]domain.KetoOutbox, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
key := entry.Namespace + "\x00" + entry.Object + "\x00" + entry.Relation + "\x00" + entry.Subject
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if entry.Action == domain.KetoOutboxActionCreate {
current = append(current, entry)
}
}
return current, nil
}
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,

View File

@@ -0,0 +1,68 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestKetoOutboxRepository_ListCurrentBySubject(t *testing.T) {
repo := NewKetoOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM keto_outbox").Error)
entries := []domain.KetoOutbox{
{
Namespace: "RelyingParty",
Object: "client-1",
Relation: "admins",
Subject: "User:user-1",
Action: domain.KetoOutboxActionCreate,
Status: domain.KetoOutboxStatusProcessed,
},
{
Namespace: "RelyingParty",
Object: "client-1",
Relation: "admins",
Subject: "User:user-1",
Action: domain.KetoOutboxActionDelete,
Status: domain.KetoOutboxStatusProcessed,
},
{
Namespace: "RelyingParty",
Object: "client-2",
Relation: "config_editor",
Subject: "User:user-1",
Action: domain.KetoOutboxActionCreate,
Status: domain.KetoOutboxStatusProcessed,
},
{
Namespace: "RelyingParty",
Object: "client-3",
Relation: "audit_viewer",
Subject: "User:user-1",
Action: domain.KetoOutboxActionCreate,
Status: domain.KetoOutboxStatusFailed,
},
{
Namespace: "Tenant",
Object: "tenant-1",
Relation: "members",
Subject: "User:user-1",
Action: domain.KetoOutboxActionCreate,
Status: domain.KetoOutboxStatusProcessed,
},
}
for i := range entries {
require.NoError(t, repo.Create(ctx, &entries[i]))
}
current, err := repo.ListCurrentBySubject(ctx, "RelyingParty", "User:user-1")
require.NoError(t, err)
require.Len(t, current, 1)
require.Equal(t, "client-2", current[0].Object)
require.Equal(t, "config_editor", current[0].Relation)
}

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -30,6 +30,14 @@ func (m *MockKetoOutboxRepositoryShared) FindPending(ctx context.Context, limit
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *MockKetoOutboxRepositoryShared) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, namespace, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *MockKetoOutboxRepositoryShared) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}

View File

@@ -90,6 +90,7 @@ search_group = "Search groups..."
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
load_more = "Load more"
show_more = "Show More"
language = "Language"
language_ko = "Korean"

View File

@@ -90,6 +90,7 @@ search_group = "그룹 검색..."
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
load_more = "더 보기"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"

View File

@@ -90,6 +90,7 @@ search_group = ""
select = ""
select_file = ""
select_placeholder = ""
load_more = ""
show_more = ""
language = ""
language_ko = ""

View File

@@ -1,4 +1,7 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"]
"extends": ["../common/config/biome.base.json"],
"files": {
"includes": [".vite"]
}
}

View File

@@ -3841,9 +3841,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3865,9 +3862,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

View File

@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5174";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5176";
/**
* Read environment variables from file.
@@ -73,10 +73,9 @@ export default defineConfig({
webServer: skipWebServer
? undefined
: {
command: process.env.CI
? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc pnpm build && pnpm exec vite preview --host 127.0.0.1 --strictPort --port 5174"
: "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc pnpm exec vite --host 127.0.0.1 --strictPort --port 5174",
command:
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 5176",
url: baseURL,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
},
});

178
devfront/pnpm-lock.yaml generated
View File

@@ -89,7 +89,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))
version: 6.0.1(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
'@vitest/coverage-v8':
specifier: 4.1.6
version: 4.1.6(vitest@4.1.6)
@@ -112,11 +112,11 @@ importers:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^8.0.12
version: 8.0.13(@types/node@25.7.0)(jiti@1.21.7)
specifier: ^8.0.14
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
vitest:
specifier: ^4.1.6
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
packages:
@@ -323,8 +323,8 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
@@ -727,97 +727,97 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rolldown/binding-android-arm64@1.0.1':
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.1':
resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
'@rolldown/binding-darwin-arm64@1.0.2':
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.1':
resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
'@rolldown/binding-darwin-x64@1.0.2':
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.1':
resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
'@rolldown/binding-freebsd-x64@1.0.2':
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.1':
resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.1':
resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.1':
resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.1':
resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.1':
resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.1':
resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
'@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.1':
resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
'@rolldown/binding-wasm32-wasi@1.0.2':
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.1':
resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
'@rolldown/binding-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.1':
resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
'@rolldown/binding-win32-x64-msvc@1.0.2':
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1520,6 +1520,10 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -1620,8 +1624,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rolldown@1.0.1:
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1778,8 +1782,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.0.13:
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
vite@8.0.14:
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -2065,7 +2069,7 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@oxc-project/types@0.130.0': {}
'@oxc-project/types@0.132.0': {}
'@playwright/test@1.60.0':
dependencies:
@@ -2453,53 +2457,53 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@rolldown/binding-android-arm64@1.0.1':
'@rolldown/binding-android-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-arm64@1.0.1':
'@rolldown/binding-darwin-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-x64@1.0.1':
'@rolldown/binding-darwin-x64@1.0.2':
optional: true
'@rolldown/binding-freebsd-x64@1.0.1':
'@rolldown/binding-freebsd-x64@1.0.2':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.1':
'@rolldown/binding-linux-arm64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.1':
'@rolldown/binding-linux-arm64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.1':
'@rolldown/binding-linux-s390x-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.1':
'@rolldown/binding-linux-x64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.1':
'@rolldown/binding-linux-x64-musl@1.0.2':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.1':
'@rolldown/binding-openharmony-arm64@1.0.2':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.1':
'@rolldown/binding-wasm32-wasi@1.0.2':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.1':
'@rolldown/binding-win32-arm64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.1':
'@rolldown/binding-win32-x64-msvc@1.0.2':
optional: true
'@rolldown/pluginutils@1.0.0-rc.7': {}
@@ -2549,10 +2553,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@vitejs/plugin-react@6.0.1(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))':
'@vitejs/plugin-react@6.0.1(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.13(@types/node@25.7.0)(jiti@1.21.7)
vite: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
'@vitest/coverage-v8@4.1.6(vitest@4.1.6)':
dependencies:
@@ -2566,7 +2570,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))
vitest: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
'@vitest/expect@4.1.6':
dependencies:
@@ -2577,13 +2581,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))':
'@vitest/mocker@4.1.6(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))':
dependencies:
'@vitest/spy': 4.1.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.13(@types/node@25.7.0)(jiti@1.21.7)
vite: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
'@vitest/pretty-format@4.1.6':
dependencies:
@@ -3144,6 +3148,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
@@ -3226,26 +3236,26 @@ snapshots:
reusify@1.1.0: {}
rolldown@1.0.1:
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.130.0
'@oxc-project/types': 0.132.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.1
'@rolldown/binding-darwin-arm64': 1.0.1
'@rolldown/binding-darwin-x64': 1.0.1
'@rolldown/binding-freebsd-x64': 1.0.1
'@rolldown/binding-linux-arm-gnueabihf': 1.0.1
'@rolldown/binding-linux-arm64-gnu': 1.0.1
'@rolldown/binding-linux-arm64-musl': 1.0.1
'@rolldown/binding-linux-ppc64-gnu': 1.0.1
'@rolldown/binding-linux-s390x-gnu': 1.0.1
'@rolldown/binding-linux-x64-gnu': 1.0.1
'@rolldown/binding-linux-x64-musl': 1.0.1
'@rolldown/binding-openharmony-arm64': 1.0.1
'@rolldown/binding-wasm32-wasi': 1.0.1
'@rolldown/binding-win32-arm64-msvc': 1.0.1
'@rolldown/binding-win32-x64-msvc': 1.0.1
'@rolldown/binding-android-arm64': 1.0.2
'@rolldown/binding-darwin-arm64': 1.0.2
'@rolldown/binding-darwin-x64': 1.0.2
'@rolldown/binding-freebsd-x64': 1.0.2
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
'@rolldown/binding-linux-arm64-gnu': 1.0.2
'@rolldown/binding-linux-arm64-musl': 1.0.2
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
'@rolldown/binding-linux-s390x-gnu': 1.0.2
'@rolldown/binding-linux-x64-gnu': 1.0.2
'@rolldown/binding-linux-x64-musl': 1.0.2
'@rolldown/binding-openharmony-arm64': 1.0.2
'@rolldown/binding-wasm32-wasi': 1.0.2
'@rolldown/binding-win32-arm64-msvc': 1.0.2
'@rolldown/binding-win32-x64-msvc': 1.0.2
run-parallel@1.2.0:
dependencies:
@@ -3395,22 +3405,22 @@ snapshots:
util-deprecate@1.0.2: {}
vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7):
vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.14
rolldown: 1.0.1
postcss: 8.5.15
rolldown: 1.0.2
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.7.0
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7)):
vitest@4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7)):
dependencies:
'@vitest/expect': 4.1.6
'@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.7.0)(jiti@1.21.7))
'@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7))
'@vitest/pretty-format': 4.1.6
'@vitest/runner': 4.1.6
'@vitest/snapshot': 4.1.6
@@ -3427,7 +3437,7 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.13(@types/node@25.7.0)(jiti@1.21.7)
vite: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.7.0

View File

@@ -14,6 +14,22 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import ProfilePage from "../features/profile/ProfilePage";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
const devFrontAppChildren: RouteObject[] = [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
];
export const devFrontRoutes: RouteObject[] = [
{
path: "/login",
@@ -25,27 +41,17 @@ export const devFrontRoutes: RouteObject[] = [
},
{
path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
],
},
],
element:
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
children:
import.meta.env.MODE === "development"
? devFrontAppChildren
: [
{
element: <AppLayout />,
children: devFrontAppChildren,
},
],
},
];

View File

@@ -0,0 +1,66 @@
import { act } from "react-dom/test-utils";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DeveloperAccessRequestCard } from "./DeveloperAccessRequestCard";
describe("DeveloperAccessRequestCard", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("renders the request CTA for pending and denied states", () => {
const onAction = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<DeveloperAccessRequestCard
title="운영 현황"
isPending={true}
canRequest={false}
pendingMessage="검토 중"
deniedMessage="거부됨"
pendingDetailMessage="승인 대기"
deniedDetailMessage="신청 필요"
actionLabel="개발자 권한 신청"
onAction={onAction}
/>,
);
});
expect(container.querySelector("h2")?.textContent).toBe("운영 현황");
expect(container.textContent).toContain("검토 중");
expect(container.textContent).toContain("승인 대기");
const button = container.querySelector("button");
expect(button?.textContent).toBe("개발자 권한 신청");
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAction).toHaveBeenCalledTimes(1);
act(() => {
root.render(
<DeveloperAccessRequestCard
title="감사 로그"
isPending={false}
canRequest={true}
pendingMessage="검토 중"
deniedMessage="거부됨"
pendingDetailMessage="승인 대기"
deniedDetailMessage="신청 필요"
actionLabel="개발자 권한 신청"
onAction={onAction}
/>,
);
});
expect(container.querySelector("h2")?.textContent).toBe("감사 로그");
expect(container.textContent).toContain("거부됨");
expect(container.textContent).toContain("신청 필요");
expect(container.querySelector("button")).not.toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
interface DeveloperAccessRequestCardProps {
title: string;
isPending: boolean;
canRequest: boolean;
pendingMessage: string;
deniedMessage: string;
pendingDetailMessage: string;
deniedDetailMessage: string;
actionLabel: string;
onAction: () => void;
}
export function DeveloperAccessRequestCard({
title,
isPending,
canRequest,
pendingMessage,
deniedMessage,
pendingDetailMessage,
deniedDetailMessage,
actionLabel,
onAction,
}: DeveloperAccessRequestCardProps) {
const showAction = isPending || canRequest;
return (
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
<div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">{title}</h2>
<p className="font-medium text-foreground">
{isPending ? pendingMessage : deniedMessage}
</p>
<p className="text-sm text-muted-foreground">
{isPending ? pendingDetailMessage : deniedDetailMessage}
</p>
{showAction && (
<button
type="button"
className="font-bold text-primary hover:underline"
onClick={onAction}
>
{actionLabel}
</button>
)}
</div>
</div>
);
}

View File

@@ -45,12 +45,6 @@ const navItems: ShellSidebarNavItem[] = [
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
@@ -63,6 +57,12 @@ const navItems: ShellSidebarNavItem[] = [
to: "/audit-logs",
icon: NotebookTabs,
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
];
type SessionStatusProps = {

View File

@@ -1,12 +1,16 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { parseAuditDetails } from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -20,6 +24,8 @@ import { Input } from "../../components/ui/input";
import type { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
function toCsv(logs: DevAuditLog[]) {
const header = [
@@ -65,6 +71,13 @@ function downloadCsv(content: string, filename: string) {
}
function AuditLogsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
@@ -73,6 +86,24 @@ function AuditLogsPage() {
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const {
hasDeveloperAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
isLoadingIdentity: isLoadingMe,
});
const query = useInfiniteQuery({
queryKey: [
"dev-audit-logs",
@@ -88,6 +119,7 @@ function AuditLogsPage() {
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: hasDeveloperAccess,
});
const logs =
@@ -101,6 +133,42 @@ function AuditLogsPage() {
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
if (isLoadingDeveloperAccessGate) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
if (!hasDeveloperAccess) {
return (
<DeveloperAccessRequestCard
title={t("ui.common.audit.title", "Audit Logs")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.audit.access_denied",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.audit.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>
);
}
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {

View File

@@ -1,10 +1,60 @@
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom";
import { userManager } from "../../lib/auth";
import { findPersistedOidcUser } from "../../lib/oidcStorage";
export default function AuthGuard() {
const auth = useAuth();
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
findPersistedOidcUser() ? true : null,
);
const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true || navigator.webdriver === true;
if (auth.isLoading || auth.activeNavigator) {
useEffect(() => {
let cancelled = false;
if (isDevelopmentMode || isTestMode) {
setHasStoredUser(true);
return () => {
cancelled = true;
};
}
const persistedUser = findPersistedOidcUser();
if (persistedUser) {
setHasStoredUser(true);
return () => {
cancelled = true;
};
}
void userManager
.getUser()
.then((user) => {
if (!cancelled) {
setHasStoredUser(Boolean(user && !user.expired));
}
})
.catch(() => {
if (!cancelled) {
setHasStoredUser(false);
}
});
return () => {
cancelled = true;
};
}, [isTestMode]);
if (isDevelopmentMode || isTestMode) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator || hasStoredUser === null) {
return <div>Loading...</div>;
}
@@ -26,7 +76,7 @@ export default function AuthGuard() {
);
}
if (!auth.isAuthenticated) {
if (!auth.isAuthenticated && !hasStoredUser) {
return <Navigate to="/login" replace />;
}

View File

@@ -26,6 +26,7 @@ vi.mock("react-oidc-context", () => ({
vi.mock("../../lib/auth", () => ({
userManager: {
getUser: vi.fn(async () => undefined),
signinPopupCallback: vi.fn(async () => undefined),
},
}));

View File

@@ -54,6 +54,7 @@ import {
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi";
import { ClientDetailTabs } from "./ClientDetailTabs";
interface ScopeItem {
@@ -326,12 +327,19 @@ function ClientGeneralPage() {
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
const clientId = params.id;
const isCreate = !clientId;
const currentUserId = auth.user?.profile.sub;
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const { data: me } = useQuery<UserProfile>({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const currentUserId = me?.id ?? auth.user?.profile.sub;
const effectiveSystemRole = me?.role?.trim() || systemRole;
const { data, isLoading, error } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId as string),
@@ -569,7 +577,7 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const canEditExistingClientGeneralSettings =
systemRole === "super_admin" ||
effectiveSystemRole === "super_admin" ||
relationData?.items?.some(
(item: ClientRelation) =>
item.subject === `User:${currentUserId}` &&

View File

@@ -35,6 +35,7 @@ import {
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import { ClientDetailTabs } from "./ClientDetailTabs";
const relationOptions = [
@@ -91,6 +92,13 @@ function ClientRelationsPage() {
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const resolvedSystemRole = me?.role?.trim() || systemRole;
const { data: clientData } = useQuery({
queryKey: ["client", clientId],
@@ -109,7 +117,7 @@ function ClientRelationsPage() {
});
// Calculate permissions for UI hints and button states
const isSuperAdmin = systemRole === "super_admin";
const isSuperAdmin = resolvedSystemRole === "super_admin";
const myUserId = auth.user?.profile.sub;
const isRpAdmin = useMemo(() => {
if (isSuperAdmin) return true;

View File

@@ -1,13 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
BookOpenText,
Filter,
Plus,
Search,
ShieldHalf,
X,
} from "lucide-react";
import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
@@ -29,11 +22,6 @@ import {
commonTableViewportClass,
} from "../../../../common/ui/table";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -45,7 +33,6 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
@@ -57,7 +44,10 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
type DevAuditLog,
fetchDevUser,
fetchClients,
fetchDevAuditLogs,
fetchDeveloperRequestStatus,
fetchDevStats,
fetchMyTenants,
@@ -69,9 +59,197 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
} from "../../../../common/core/audit";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
actorName: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientChangesInitialCount = 5;
const recentClientChangesBatchSize = 5;
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
const recentChangeGuideItems = [
{
titleKey: "ui.dev.clients.recent_changes.guide.create",
titleFallback: "앱 생성",
descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc",
descriptionFallback:
"새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.settings",
titleFallback: "설정 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc",
descriptionFallback:
"앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.status",
titleFallback: "상태 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc",
descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.relation",
titleFallback: "관계 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc",
descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.secret",
titleFallback: "클라이언트 시크릿 재발급",
descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc",
descriptionFallback: "시크릿 재발급 이력이 보입니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.delete",
titleFallback: "앱 삭제",
descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc",
descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.",
},
] as const;
const recentClientFieldLabels: Record<string, string> = {
name: "이름",
type: "유형",
status: "상태",
scopes: "스코프",
tenant_access_restricted: "테넌트 접근 제한",
allowed_tenants: "허용 테넌트",
id_token_claims: "커스텀 클레임",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
headless_login_enabled: "헤드리스 로그인",
headless_token_endpoint_auth_method: "헤드리스 인증 방식",
headless_jwks_uri: "헤드리스 JWKS URI",
redirect_uri_count: "Redirect URI 수",
scope_count: "Scope 수",
relation: "관계",
subject: "대상",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return "클라이언트 생성";
case "UPDATE_CLIENT":
return "설정 변경";
case "UPDATE_CLIENT_STATUS":
return "상태 변경";
case "ROTATE_SECRET":
return "클라이언트 시크릿 재발급";
case "ADD_RELATION":
return "관계 추가";
case "REMOVE_RELATION":
return "관계 삭제";
case "DELETE_CLIENT":
return "클라이언트 삭제";
default:
return action;
}
}
function buildRecentClientChangeDetails(
action: string,
details: ReturnType<typeof parseAuditDetails>,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [{ label: "클라이언트 시크릿", value: "재발급" }];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [{ label: "관계", value: formatAuditValue(source.relation) }]
: []),
...(source.subject
? [{ label: "대상", value: formatAuditValue(source.subject) }]
: []),
];
}
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)]),
);
const changes = keys
.map((key) => {
const beforeValue = before[key];
const afterValue = after[key];
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
return null;
}
}
const label = recentClientFieldLabels[key] ?? key;
if (action === "CREATE_CLIENT") {
if (afterValue === undefined) {
return null;
}
return { label, value: formatAuditValue(afterValue) };
}
if (action === "DELETE_CLIENT") {
if (beforeValue === undefined) {
return null;
}
return { label, value: formatAuditValue(beforeValue) };
}
if (beforeValue === undefined && afterValue === undefined) {
return null;
}
if (beforeValue === undefined) {
return { label, value: formatAuditValue(afterValue) };
}
if (afterValue === undefined) {
return { label, value: formatAuditValue(beforeValue) };
}
return {
label,
value: `${formatAuditValue(beforeValue)}${formatAuditValue(afterValue)}`,
};
})
.filter((item): item is { label: string; value: string } => Boolean(item));
return changes.slice(0, 3);
}
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -97,6 +275,14 @@ function ClientsPage() {
enabled: hasAccessToken,
});
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const {
data: requestStatus,
isLoading: isLoadingRequest,
@@ -104,21 +290,18 @@ function ClientsPage() {
} = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && (role === "user" || role === "tenant_member"),
enabled:
hasAccessToken &&
(profileRole === "user" || profileRole === "tenant_member"),
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: hasAccessToken,
});
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const createAccessState = resolveClientCreateAccess({
role,
role: profileRole,
requestStatus: requestStatus?.status,
});
const canCreateClient = createAccessState === "can_create";
@@ -131,6 +314,10 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] =
useState(false);
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
useState(recentClientChangesInitialCount);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
@@ -138,6 +325,62 @@ function ClientsPage() {
});
const clients = data?.items || [];
const visibleClientIds = useMemo(
() => clients.map((client) => client.id).filter(Boolean),
[clients],
);
const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({
queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")],
queryFn: async () => {
const globalLogs = await fetchDevAuditLogs(50);
if (globalLogs.items.length > 0 || profileRole === "super_admin") {
return globalLogs;
}
if (visibleClientIds.length === 0) {
return globalLogs;
}
const perClientLogs = await Promise.all(
visibleClientIds.slice(0, 20).map(async (clientId) => {
try {
const result = await fetchDevAuditLogs(5, undefined, {
client_id: clientId,
});
return result.items;
} catch {
return [];
}
}),
);
const merged = perClientLogs
.flat()
.filter(
(item, index, self) =>
self.findIndex(
(candidate) => candidate.event_id === item.event_id,
) === index,
)
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
)
.slice(0, 50);
return {
items: merged,
limit: 50,
cursor: globalLogs.cursor,
next_cursor: globalLogs.next_cursor,
};
},
enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole),
retry: false,
});
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
>(
@@ -193,7 +436,6 @@ function ClientsPage() {
(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";
@@ -236,7 +478,107 @@ function ClientsPage() {
},
];
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
const recentClientChanges = useMemo<RecentClientChange[]>(() => {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return (recentAuditData?.items || [])
.map((item: DevAuditLog) => {
const details = parseAuditDetails(item.details);
const action = details.action || "";
const clientId = String(details.target_id || "");
if (!recentClientActions.has(action) || !clientId) {
return null;
}
return {
eventId: item.event_id,
clientId,
clientName: clientNameById.get(clientId) || clientId,
actorId: resolveAuditActor(item, details),
actorName: "",
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
};
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}, [clients, recentAuditData?.items]);
const recentClientActorIds = useMemo(() => {
return Array.from(
new Set(
recentClientChanges
.map((item) => item.actorId.trim())
.filter((actorId) => actorId && actorId !== "-"),
),
);
}, [recentClientChanges]);
const { data: recentClientActors } = useQuery({
queryKey: ["recent-client-actors", recentClientActorIds],
queryFn: async () => {
const entries = await Promise.all(
recentClientActorIds.map(async (actorId) => {
try {
const user = await fetchDevUser(actorId);
return [actorId, user.name || actorId] as const;
} catch {
return [actorId, actorId] as const;
}
}),
);
return Object.fromEntries(entries);
},
enabled: recentClientActorIds.length > 0,
});
const recentClientChangesWithActors = useMemo(() => {
return recentClientChanges.map((item) => ({
...item,
actorName: recentClientActors?.[item.actorId] || item.actorId,
}));
}, [recentClientActors, recentClientChanges]);
const recentChangedClientCount = useMemo(() => {
return new Set(recentClientChangesWithActors.map((item) => item.clientId))
.size;
}, [recentClientChangesWithActors]);
const visibleRecentClientChanges = useMemo(() => {
return recentClientChangesWithActors.slice(
0,
visibleRecentClientChangesCount,
);
}, [recentClientChangesWithActors, visibleRecentClientChangesCount]);
const hasMoreRecentClientChanges =
recentClientChangesWithActors.length > visibleRecentClientChanges.length;
useEffect(() => {
if (
visibleRecentClientChangesCount > recentClientChangesWithActors.length
) {
setVisibleRecentClientChangesCount(
Math.max(
recentClientChangesInitialCount,
recentClientChangesWithActors.length,
),
);
}
}, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]);
const isLoading =
isLoadingClients ||
isLoadingStats ||
isLoadingRecentAudit ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
const requestSort = (key: ClientSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -700,82 +1042,163 @@ function ClientsPage() {
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold">
{t(
"ui.dev.clients.help.title",
"Need help with OIDC configuration?",
)}
</CardTitle>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
<div>
<div className="flex items-center gap-1.5">
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-8 w-8 translate-y-px text-muted-foreground hover:text-primary"
aria-label={t(
"ui.dev.clients.recent_changes.guide_button",
"최근 변경 항목 안내 열기",
)}
aria-expanded={isRecentChangesGuideOpen}
onClick={() =>
setIsRecentChangesGuideOpen((current) => !current)
}
>
<Info className="h-4 w-4" />
</Button>
</div>
<CardDescription>
{t(
"msg.dev.clients.help.subtitle",
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.recent_changes.description",
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
{ count: recentChangedClientCount },
)}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<BookOpenText className="h-6 w-6" />
</div>
<div>
<p className="font-semibold">
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
</p>
<p className="text-sm text-muted-foreground">
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t(
"msg.dev.clients.recent_changes.permission_note",
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
)}
</p>
{isRecentChangesGuideOpen && (
<div className="mt-3 rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">
{t(
"msg.dev.clients.help.docs_body",
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
"ui.dev.clients.recent_changes.guide_title",
"최근 변경 항목 안내",
)}
</p>
<div className="mt-3 space-y-3">
{recentChangeGuideItems.map((item) => (
<div key={item.titleKey} className="space-y-1">
<p className="text-sm font-medium text-foreground">
{t(item.titleKey, item.titleFallback)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t(item.descriptionKey, item.descriptionFallback)}
</p>
</div>
))}
<p className="text-xs leading-5 text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.guide.audit_only",
"동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.",
)}
</p>
</div>
</div>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/audit-logs">
{t("ui.common.audit.title", "Audit Logs")}
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{visibleRecentClientChanges.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
<Button variant="secondary">
{t("ui.dev.clients.help.view_guides", "View guides")}
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">
{t("ui.dev.clients.owner.title", "Owner")}
</CardTitle>
<CardDescription>
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
</p>
</div>
) : (
visibleRecentClientChanges.map((item) => {
const { date, time } = formatAuditDateParts(item.timestamp);
return (
<div
key={item.eventId}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card/40 p-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/clients/${item.clientId}`}
className="font-semibold transition-colors hover:text-primary"
>
{item.clientName}
</Link>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.clientId}
</code>
<span className="font-semibold">{item.actorName}</span>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.actorId}
</code>
<Badge variant="muted">{item.actionLabel}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail) => (
<Badge
key={`${item.eventId}-${detail.label}`}
variant="outline"
>
{detail.label}: {detail.value}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.no_detail",
"변경 항목을 확인할 수 없습니다.",
)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{date} {time}
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${item.clientId}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
);
})
)}
{hasMoreRecentClientChanges ? (
<div className="pt-2 text-center">
<Button
type="button"
variant="outline"
onClick={() =>
setVisibleRecentClientChangesCount((current) =>
Math.min(
current + recentClientChangesBatchSize,
recentClientChangesWithActors.length,
),
)
}
>
{t("ui.common.load_more", "더보기")}
</Button>
</div>
<Separator className="mx-4 hidden h-10 w-px md:block" />
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
<span>
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
</span>
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
</div>
</CardContent>
</Card>
</div>
) : null}
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}

View File

@@ -19,6 +19,15 @@ describe("client create access", () => {
).toBe("request_required");
});
it("treats unresolved roles as request required instead of allowing creation", () => {
expect(
resolveClientCreateAccess({
role: "",
requestStatus: undefined,
}),
).toBe("request_required");
});
it("shows pending state while a developer request is under review", () => {
expect(
resolveClientCreateAccess({

View File

@@ -19,6 +19,10 @@ export function resolveClientCreateAccess({
role,
requestStatus,
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
if (!role.trim()) {
return "request_required";
}
if (!canSelfRequestDeveloperAccess(role)) {
return "can_create";
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
resolveDeveloperAccessGate,
shouldFetchDeveloperRequestStatus,
shouldShowDeveloperAccessLoading,
} from "./developerAccessGate";
describe("developer access gate", () => {
it("fetches request status only for user roles", () => {
expect(shouldFetchDeveloperRequestStatus("user")).toBe(true);
expect(shouldFetchDeveloperRequestStatus("tenant_admin")).toBe(false);
expect(shouldFetchDeveloperRequestStatus("rp_admin")).toBe(false);
});
it("resolves access and request states from the request status", () => {
expect(resolveDeveloperAccessGate("super_admin", "pending")).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "approved")).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "pending")).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "none")).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
});
});
it("shows the loading gate only for user requests", () => {
expect(shouldShowDeveloperAccessLoading("user", true, false)).toBe(true);
expect(shouldShowDeveloperAccessLoading("user", false, true)).toBe(true);
expect(shouldShowDeveloperAccessLoading("tenant_admin", true, true)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import {
fetchDeveloperRequestStatus,
type DeveloperRequestStatus,
} from "../../lib/devApi";
export type DeveloperAccessGateState = {
hasDeveloperAccess: boolean;
isDeveloperRequestPending: boolean;
canRequestDeveloperAccess: boolean;
isLoadingDeveloperAccessGate: boolean;
};
function isPrivilegedDeveloperRole(profileRole: string) {
return (
profileRole === "super_admin" ||
profileRole === "tenant_admin" ||
profileRole === "rp_admin"
);
}
export function resolveDeveloperAccessGate(
profileRole: string,
requestStatus?: DeveloperRequestStatus,
): Omit<DeveloperAccessGateState, "isLoadingDeveloperAccessGate"> {
const hasDeveloperAccess =
isPrivilegedDeveloperRole(profileRole) || requestStatus === "approved";
const isDeveloperRequestPending = requestStatus === "pending";
const canRequestDeveloperAccess =
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending;
return {
hasDeveloperAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
};
}
export function shouldFetchDeveloperRequestStatus(profileRole: string) {
return profileRole === "user";
}
export function shouldShowDeveloperAccessLoading(
profileRole: string,
isLoadingIdentity: boolean,
isLoadingRequestStatus: boolean,
) {
return (
profileRole === "user" && (isLoadingIdentity || isLoadingRequestStatus)
);
}
export function useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
isLoadingIdentity = false,
}: {
hasAccessToken: boolean;
profileRole: string;
tenantId?: string;
isLoadingIdentity?: boolean;
}) {
const shouldFetchRequestStatus =
shouldFetchDeveloperRequestStatus(profileRole);
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && shouldFetchRequestStatus,
});
const resolvedGate = resolveDeveloperAccessGate(
profileRole,
requestStatus?.status,
);
return {
...resolvedGate,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,
isLoadingRequestStatus,
),
} satisfies DeveloperAccessGateState;
}

View File

@@ -51,9 +51,9 @@ import { fetchMe } from "../auth/authApi";
export default function DeveloperRequestPage() {
const auth = useAuth();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
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;
@@ -73,7 +73,7 @@ export default function DeveloperRequestPage() {
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: !!auth.user?.access_token,
enabled: hasAccessToken,
});
const currentTenant = tenants?.find(
@@ -87,7 +87,8 @@ export default function DeveloperRequestPage() {
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role || role;
const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
const approveMutation = useMutation({

View File

@@ -16,10 +16,11 @@ import {
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import {
type ClientSummary,
fetchClients,
fetchDeveloperRequestStatus,
fetchDevRPUsageDaily,
fetchDevStats,
type RPUsageDailyMetric,
@@ -27,6 +28,7 @@ import {
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
type ClientDistribution = {
activeClients: number;
@@ -480,14 +482,15 @@ function GlobalOverviewPage() {
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && role === "user",
});
const statsQuery = useQuery({
queryKey: ["dev-dashboard-stats"],
queryFn: fetchDevStats,
@@ -509,17 +512,17 @@ function GlobalOverviewPage() {
});
const clients = clientsQuery.data?.items ?? [];
const hasDeveloperAccess =
role === "super_admin" ||
role === "tenant_admin" ||
role === "rp_admin" ||
requestStatus?.status === "approved";
const isDeveloperRequestPending = requestStatus?.status === "pending";
const canRequestDeveloperAccess =
(role === "user" || role === "tenant_member") &&
!isLoadingRequestStatus &&
!hasDeveloperAccess &&
!isDeveloperRequestPending;
const {
hasDeveloperAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
isLoadingIdentity: isLoadingMe,
});
const distribution = useMemo(
() => buildClientDistribution(clients),
[clients],
@@ -607,7 +610,7 @@ function GlobalOverviewPage() {
setSelectedClientIds([]);
};
if ((role === "user" || role === "tenant_member") && isLoadingRequestStatus) {
if (isLoadingDeveloperAccessGate) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
@@ -617,46 +620,29 @@ function GlobalOverviewPage() {
if (!hasDeveloperAccess) {
return (
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
<div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="font-medium text-foreground">
{isDeveloperRequestPending
? t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
</p>
<p className="text-sm text-muted-foreground">
{isDeveloperRequestPending
? t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)
: t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
</p>
{(isDeveloperRequestPending || canRequestDeveloperAccess) && (
<button
type="button"
className="font-bold text-primary hover:underline"
onClick={() => navigate("/developer-requests")}
>
{isDeveloperRequestPending
? t("ui.dev.nav.developer_request", "개발자 권한 신청")
: t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
</button>
)}
</div>
</div>
<DeveloperAccessRequestCard
title={t("ui.common.overview.title", "운영 현황")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>
);
}

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth";
import { findPersistedOidcUser } from "./oidcStorage";
let isRedirectingToLogin = false;
@@ -12,9 +13,14 @@ const apiClient = axios.create({
"/api/v1",
});
const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true || navigator.webdriver === true;
apiClient.interceptors.request.use(async (config) => {
// OIDC Access Token 주입
const user = await userManager.getUser();
const user = (await userManager.getUser()) ?? findPersistedOidcUser();
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`;
}
@@ -47,6 +53,13 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
if (isDevelopmentMode || isTestMode) {
console.warn(
"[apiClient] Auth failure detected, but local redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,

View File

@@ -177,6 +177,27 @@ export type DevAssignableUserListResponse = {
items: DevAssignableUser[];
};
export type DevUserSummary = {
id: string;
email: string;
loginId?: string;
name: string;
phone?: string;
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[];
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
updatedAt: string;
};
export type ConsentSummary = {
subject: string;
userName?: string;
@@ -290,6 +311,13 @@ export async function fetchDevUsers(
return data;
}
export async function fetchDevUser(userId: string) {
const { data } = await apiClient.get<DevUserSummary>(
`/admin/users/${userId}`,
);
return data;
}
export async function addClientRelation(
clientId: string,
payload: ClientRelationUpsertRequest,

View File

@@ -0,0 +1,42 @@
export type PersistedOidcUser = {
access_token?: string;
expires_at?: number;
profile?: Record<string, unknown>;
};
const OIDC_USER_KEY_PREFIX = "oidc.user:";
const OIDC_CLIENT_ID = "devfront";
export function findPersistedOidcUser(
storage: Storage = window.localStorage,
): PersistedOidcUser | null {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key === null ||
!key.startsWith(OIDC_USER_KEY_PREFIX) ||
!key.endsWith(`:${OIDC_CLIENT_ID}`)
) {
continue;
}
const rawValue = storage.getItem(key);
if (!rawValue) {
continue;
}
try {
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
if (
typeof parsed.expires_at === "number" &&
parsed.expires_at * 1000 > Date.now()
) {
return parsed;
}
} catch {
// Ignore malformed storage entries and keep scanning.
}
}
return null;
}

View File

@@ -511,6 +511,10 @@ access_pending = "Your developer access request is under review."
access_pending_detail = "You can use the overview and developer features after a super admin approves it."
description = "View connected application composition and authentication operations metrics in one place."
[msg.dev.audit]
access_denied = "Audit logs are available only to users with developer access."
access_denied_detail = "Submit a request on the developer access page and wait for approval."
[msg.dev.dashboard.hero]
body = "Body"
title_emphasis = "Title Emphasis"
@@ -1365,6 +1369,34 @@ search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
title = "Recently Changed Apps"
guide_button = "Open recent change guide"
guide_title = "Recent Change Guide"
[ui.dev.clients.recent_changes.guide]
create = "App creation"
settings = "Settings changes"
status = "Status changes"
relation = "Relationship changes"
secret = "Client secret rotation"
delete = "App deletion"
[msg.dev.clients.recent_changes]
description = "{{count}} applications have change history."
permission_note = "You need the 'Audit Log Viewer' relationship to see recently changed apps."
empty = "No recent change logs yet."
no_detail = "Unable to inspect the changed fields."
[msg.dev.clients.recent_changes.guide]
audit_only = "Consent revocations are not included in this card; check the audit log instead."
create_desc = "When a new application is created, it appears with its name, type, and default status."
settings_desc = "Includes app name, scopes, tenant access restrictions, custom claims, security settings, logout URI, and JWKS changes."
status_desc = "Active / Inactive transitions are included here."
relation_desc = "Relationship additions and removals are shown together."
secret_desc = "Client secret rotation history is shown."
delete_desc = "Application deletions are also included in recent changes."
[ui.dev.clients.badge]
admin_session = "Admin Session"
dev_session = "DevFront Session"
@@ -1633,25 +1665,9 @@ permits_info = "Can view DevFront audit logs for all configuration changes and o
label = "Status Change"
description = "Change the active or inactive state of the RP."
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
docs_title = "Docs & Examples"
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
title = "Connected Applications"
[ui.dev.clients.owner]
avatar_alt = "ops user"
email = "admin@brsw.kr"
name = "AI Admin Bot"
role = "Role: Tenant Admin"
scope = "Scope: TENANT-12"
subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
subtitle = "Applications"

View File

@@ -511,6 +511,10 @@ access_pending = "개발자 권한 신청을 검토 중입니다."
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
[msg.dev.audit]
access_denied = "감사 로그는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
[msg.dev.dashboard.hero]
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
title_emphasis = " 하나의 화면"
@@ -1365,6 +1369,34 @@ search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
title = "최근 변경된 앱"
guide_button = "최근 변경 항목 안내 열기"
guide_title = "최근 변경 항목 안내"
[ui.dev.clients.recent_changes.guide]
create = "앱 생성"
settings = "설정 변경"
status = "상태 변경"
relation = "관계 변경"
secret = "클라이언트 시크릿 재발급"
delete = "앱 삭제"
[msg.dev.clients.recent_changes]
description = "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다."
permission_note = "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다."
empty = "최근 변경 로그가 아직 없습니다."
no_detail = "변경 항목을 확인할 수 없습니다."
[msg.dev.clients.recent_changes.guide]
audit_only = "동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다."
create_desc = "새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다."
settings_desc = "앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다."
status_desc = "Active / Inactive 전환이 여기에 포함됩니다."
relation_desc = "관계 추가와 삭제가 함께 표시됩니다."
secret_desc = "시크릿 재발급 이력이 보입니다."
delete_desc = "앱 삭제도 최근 변경 이력에 포함됩니다."
[ui.dev.clients.badge]
admin_session = "관리자 세션"
dev_session = "DevFront 세션"
@@ -1632,25 +1664,9 @@ permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에
label = "상태 변경"
description = "RP 활성/비활성 상태를 변경합니다."
[ui.dev.clients.help]
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
docs_title = "Docs & Examples"
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
title = "Need help with OIDC configuration?"
view_guides = "View guides"
[ui.dev.clients.list]
title = "연동 앱 목록"
[ui.dev.clients.owner]
avatar_alt = "ops user"
email = "admin@brsw.kr"
name = "AI Admin Bot"
role = "Role: Tenant Admin"
scope = "Scope: TENANT-12"
subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
subtitle = "연동 앱"

View File

@@ -549,6 +549,10 @@ access_pending = ""
access_pending_detail = ""
description = ""
[msg.dev.audit]
access_denied = ""
access_denied_detail = ""
[msg.dev.dashboard.hero]
body = ""
title_emphasis = ""
@@ -1421,6 +1425,34 @@ search_placeholder = ""
tenant_scoped = ""
untitled = ""
[ui.dev.clients.recent_changes]
title = ""
guide_button = ""
guide_title = ""
[ui.dev.clients.recent_changes.guide]
create = ""
settings = ""
status = ""
relation = ""
secret = ""
delete = ""
[msg.dev.clients.recent_changes]
description = ""
permission_note = ""
empty = ""
no_detail = ""
[msg.dev.clients.recent_changes.guide]
audit_only = ""
create_desc = ""
settings_desc = ""
status_desc = ""
relation_desc = ""
secret_desc = ""
delete_desc = ""
[ui.dev.clients.badge]
admin_session = ""
dev_session = ""
@@ -1689,25 +1721,9 @@ label = ""
description = ""
permits_info = ""
[ui.dev.clients.help]
docs_body = ""
docs_title = ""
subtitle = ""
title = ""
view_guides = ""
[ui.dev.clients.list]
title = ""
[ui.dev.clients.owner]
avatar_alt = ""
email = ""
name = ""
role = ""
scope = ""
subtitle = ""
title = ""
[ui.dev.clients.registry]
description = ""
subtitle = ""

View File

@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";
import {
type DevAssignableUser,
type AuditLog,
type Consent,
installDevApiMock,
makeClient,
@@ -14,7 +16,7 @@ test.afterEach(async ({ page }, testInfo) => {
});
test("clients page loads correctly", async ({ page }) => {
await seedAuth(page);
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-playwright", {
@@ -44,3 +46,170 @@ test("clients page loads correctly", async ({ page }) => {
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
).toBeVisible();
});
test("clients page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-recent", {
name: "Recent RP",
}),
],
consents: [] as Consent[],
auditLogs: [
{
event_id: "evt-1",
timestamp: "2026-03-03T09:00:00.000Z",
user_id: "actor-1",
event_type: "CLIENT_RELATION_CREATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "ADD_RELATION",
target_id: "client-recent",
relation: "config_editor",
subject: "User:user-2",
}),
},
{
event_id: "evt-2",
timestamp: "2026-03-03T08:59:00.000Z",
user_id: "actor-2",
event_type: "CLIENT_ROTATE_SECRET",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "ROTATE_SECRET",
target_id: "client-recent",
}),
},
] as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
await expect(page.getByText("클라이언트 시크릿 재발급")).toBeVisible();
await expect(page.getByText("관계 추가")).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent RP", exact: true }).first(),
).toBeVisible();
});
test("clients page shows user-delete relation cleanup in recent changes", async ({
page,
}) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
makeClient("client-cleanup", {
name: "Cleanup RP",
}),
],
consents: [] as Consent[],
users: [
{
id: "cleanup-actor",
name: "Cleanup Actor",
email: "cleanup.actor@example.com",
} satisfies DevAssignableUser,
],
auditLogs: [
{
event_id: "evt-cleanup-1",
timestamp: "2026-03-03T09:00:00.000Z",
user_id: "cleanup-actor",
event_type: "CLIENT_RELATION_DELETE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "REMOVE_RELATION",
target_id: "client-cleanup",
relation: "config_editor",
subject: "User:deleted-user",
before: {
relation: "config_editor",
subject: "User:deleted-user",
},
}),
},
] as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Cleanup RP", exact: true }),
).toBeVisible();
await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible();
await expect(page.getByText(/관계:\s*config_editor/)).toBeVisible();
await expect(page.getByText(/대상:\s*User:deleted-user/)).toBeVisible();
await expect(
page.getByText("cleanup-actor", { exact: true }).first(),
).toBeVisible();
});
test("clients page expands recent changes with more button", async ({
page,
}) => {
await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, {
name: `Recent App ${index + 1}`,
}),
);
const auditLogs = clients.map((client, index) => ({
event_id: `evt-recent-${index + 1}`,
timestamp: `2026-03-03T09:${String(10 - index).padStart(2, "0")}:00.000Z`,
user_id: `actor-${index + 1}`,
event_type: "CLIENT_CREATE",
status: "success" as const,
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "CREATE_CLIENT",
target_id: client.id,
after: {
name: client.name,
},
}),
}));
await installDevApiMock(page, {
clients,
consents: [] as Consent[],
auditLogs: auditLogs as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 1", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 5", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", { name: "더 보기" });
await expect(moreButton).toBeVisible();
await moreButton.click();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).toBeVisible();
await expect(moreButton).toHaveCount(0);
});

View File

@@ -96,4 +96,49 @@ test.describe("DevFront relationships", () => {
)
.toBe(1);
});
test("super_admin can add RP relationships even when profile role is missing", async ({
page,
}) => {
await seedAuth(page);
await page.addInitScript(() => {
window.localStorage.setItem("dev_role", "super_admin");
});
const state = {
clients: [makeClient("client-rel", { name: "Relations app" })],
consents: [] as Consent[],
users: [
{
id: "user-2",
name: "홍길동",
email: "hong@example.com",
loginId: "hong01",
},
],
relations: {
"client-rel": [
{
relation: "admins",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
userName: "Playwright User",
userEmail: "playwright@example.com",
},
] satisfies ClientRelation[],
},
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients/client-rel/relationships");
await expect(page.getByText("클라이언트 관계")).toBeVisible();
await page.getByLabel(/^사용자$/).fill("홍길동");
await page.getByRole("button", { name: /홍길동/ }).click();
await page.getByLabel(/시크릿 재발급/).check();
await expect(page.getByRole("button", { name: /^추가$/ })).toBeEnabled();
});
});

View File

@@ -17,9 +17,7 @@ test.describe("DevFront role report", () => {
});
});
test("user(tenant_member) can enter and sees empty RP list", async ({
page,
}, testInfo) => {
test("user can enter and sees empty RP list", async ({ page }, testInfo) => {
await seedAuth(page, "user");
await installDevApiMock(page, {
clients: [],
@@ -39,6 +37,69 @@ test.describe("DevFront role report", () => {
await captureEvidence(page, testInfo, "role-user-empty-rps");
});
test("user sees developer request entry point on overview", async ({
page,
}, testInfo) => {
await seedAuth(page, "user");
await installDevApiMock(page, {
clients: [],
consents: [] as Consent[],
auditLogs: [] as AuditLog[],
auditLogsByCursor: undefined,
developerRequests: [],
});
await page.goto("/");
await expect(
page.getByText(
/대시보드는 개발자 권한이 있어야 볼 수 있습니다|개발자 권한 신청을 검토 중입니다./,
),
).toBeVisible();
const requestBtn = page.getByRole("button", {
name: /개발자 권한 신청/,
});
await expect(requestBtn).toBeVisible();
await requestBtn.click();
await expect(page).toHaveURL(/\/developer-requests$/);
await captureEvidence(page, testInfo, "role-user-overview-request-entry");
});
test("user with approved developer request sees overview without CTA", async ({
page,
}, testInfo) => {
await seedAuth(page, "user");
await installDevApiMock(page, {
clients: [],
consents: [] as Consent[],
auditLogs: [] as AuditLog[],
auditLogsByCursor: undefined,
developerRequests: [
{
id: "req-approved",
userId: "playwright-user",
userName: "Playwright User",
name: "Playwright User",
userEmail: "playwright@example.com",
organization: "Tenant A",
reason: "Need access",
status: "approved",
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:00:00.000Z",
approvedAt: "2026-05-29T00:10:00.000Z",
},
],
});
await page.goto("/");
await expect(
page.getByRole("heading", { name: /운영 현황/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /개발자 권한 신청/ }),
).toHaveCount(0);
await captureEvidence(page, testInfo, "role-user-overview-approved");
});
test("rp_admin sees only assigned Gitea app and its logs", async ({
page,
}, testInfo) => {
@@ -66,8 +127,12 @@ test.describe("DevFront role report", () => {
await installDevApiMock(page, state);
await page.goto("/clients");
await expect(page.getByRole("link", { name: /Gitea/ })).toBeVisible();
await expect(page.getByText("gitea-client")).toBeVisible();
await expect(
page.getByRole("link", { name: "Gitea", exact: true }),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "gitea-client" }),
).toBeVisible();
await captureEvidence(page, testInfo, "role-rp-admin-clients");
await page.goto("/audit-logs");

View File

@@ -137,4 +137,86 @@ test.describe("DevFront security and isolation", () => {
page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i),
).toBeVisible();
});
test("user sees audit log access CTA when access is blocked", async ({
page,
}, testInfo) => {
await seedAuth(page, "user");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
consents: [] as Consent[],
auditLogsByCursor: undefined,
developerRequests: [],
};
await installDevApiMock(page, state);
await page.goto("/audit-logs");
await expect(
page.getByRole("heading", { name: /감사 로그|Audit Logs/ }),
).toBeVisible();
await expect(
page.getByText(
/감사 로그는 개발자 권한이 있어야 볼 수 있습니다|Audit logs are available only to users with developer access/i,
),
).toBeVisible();
const requestBtn = page.getByRole("button", {
name: /개발자 권한 신청/,
});
await expect(requestBtn).toBeVisible();
await requestBtn.click();
await expect(page).toHaveURL(/\/developer-requests$/);
await captureEvidence(page, testInfo, "security-user-audit-request-entry");
});
test("user with approved developer request can enter audit logs without CTA", async ({
page,
}, testInfo) => {
await seedAuth(page, "user");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
consents: [] as Consent[],
auditLogs: [
{
event_id: "evt-audit-1",
timestamp: "2026-05-29T00:00:00.000Z",
user_id: "playwright-user",
event_type: "CLIENT_UPDATE",
status: "success" as const,
ip_address: "127.0.0.1",
user_agent: "playwright",
details: JSON.stringify({
action: "UPDATE_CLIENT",
target_id: "tenant-a-client",
tenant_id: "tenant-a",
}),
},
],
auditLogsByCursor: undefined,
developerRequests: [
{
id: "req-approved",
userId: "playwright-user",
userName: "Playwright User",
name: "Playwright User",
userEmail: "playwright@example.com",
organization: "Tenant A",
reason: "Need access",
status: "approved",
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:10:00.000Z",
approvedAt: "2026-05-29T00:10:00.000Z",
},
],
};
await installDevApiMock(page, state);
await page.goto("/audit-logs");
await expect(page.getByText("UPDATE_CLIENT")).toBeVisible();
await expect(
page.getByRole("button", { name: /개발자 권한 신청/ }),
).toHaveCount(0);
await captureEvidence(page, testInfo, "security-user-audit-approved");
});
});

View File

@@ -140,6 +140,10 @@ export async function seedAuth(page: Page, role?: string) {
await page.addInitScript(
({ issuedAt, injectedRole }) => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const mockOidcUser = {
id_token: "playwright-id-token",
session_state: "playwright-session",
@@ -408,6 +412,15 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
}
if (pathname === "/api/v1/dev/rp-usage/daily" && method === "GET") {
return json(route, {
items: [],
days: Number.parseInt(searchParams.get("days") || "14", 10),
period:
(searchParams.get("period") as "day" | "week" | "month") || "day",
});
}
if (pathname === "/api/v1/dev/clients" && method === "GET") {
return json(route, {
items: state.clients.map((client) => ({

View File

@@ -382,6 +382,8 @@ unknown_error = "unknown error"
logout_confirm = "Are you sure you want to log out?"
[msg.dev.audit]
access_denied = "Audit logs are available only to users with developer access."
access_denied_detail = "Submit a request on the developer access page and wait for approval."
empty = "No audit logs found."
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
load_error = "Error loading audit logs: {{error}}"
@@ -807,6 +809,7 @@ approved = "Approved. Complete sign-in in the original window."
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
approved_remote = "Your requested sign-in is complete."
pending_remote = "Checking the sign-in approval request. Please wait."
close_hint = "You can close this window now."
success = "Sign-in approval completed."
[msg.userfront.login_success]
@@ -2530,10 +2533,10 @@ title = "Account not found"
action_label = "Done"
action_label_remote = "Go to sign-in window"
action_label_close = "Close Window"
page_title = "Sign-in approval"
page_title = "Baron SW Portal"
title = "Approval complete"
title_pending = "Checking approval"
title_remote = "Sign-in approved"
title_remote = "Sign-in Approved"
[ui.shell.nav]
logout = "Logout"

View File

@@ -140,6 +140,8 @@ user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
title = "{{resource}} 접근 권한 없음"
[msg.dev.audit]
access_denied = "감사 로그는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
empty = "조회된 감사 로그가 없습니다."
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
load_error = "감사 로그 조회 실패: {{error}}"
@@ -1298,6 +1300,7 @@ approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
approved_remote = "요청하신 로그인이 완료되었습니다"
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
close_hint = "이 창은 이제 닫으셔도 됩니다."
success = "로그인 승인에 성공했습니다."
[msg.userfront.login_success]
@@ -2954,7 +2957,7 @@ title = "미등록 회원"
[ui.userfront.login.verification]
action_label = "확인"
action_label_remote = "로그인 창으로 이동하기"
page_title = "로그인 승인"
page_title = "Baron SW 포탈"
title = "승인 완료"
action_label_close = "창 닫기"
title_pending = "로그인 승인 확인 중"

View File

@@ -734,6 +734,8 @@ unknown_error = ""
logout_confirm = ""
[msg.dev.audit]
access_denied = ""
access_denied_detail = ""
empty = ""
forbidden = ""
load_error = ""
@@ -1158,6 +1160,7 @@ approved = ""
approved_local = ""
approved_remote = ""
pending_remote = ""
close_hint = ""
success = ""
[msg.userfront.login_success]

View File

@@ -1,4 +1,7 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"]
"extends": ["../common/config/biome.base.json"],
"files": {
"includes": [".vite"]
}
}

View File

@@ -7,8 +7,9 @@
"node": ">=24.0.0"
},
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"install:browsers": "playwright install firefox",
"test": "npm run install:browsers && playwright test",
"test:ui": "npm run install:browsers && playwright test --ui",
"serve:build": "node ./scripts/serve-userfront-build.mjs",
"build:userfront:wasm": "cd ../userfront && flutter build web --wasm --release && cd .. && node userfront/scripts/optimize-web-build.mjs userfront/build/web",
"lint": "biome check .",

77
userfront-e2e/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,77 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.58.2
version: 1.60.0
'@types/node':
specifier: ^24.3.0
version: 24.12.4
typescript:
specifier: ^5.9.2
version: 5.9.3
packages:
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@playwright/test@1.60.0':
dependencies:
playwright: 1.60.0
'@types/node@24.12.4':
dependencies:
undici-types: 7.16.0
fsevents@2.3.2:
optional: true
playwright-core@1.60.0: {}
playwright@1.60.0:
dependencies:
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
typescript@5.9.3: {}
undici-types@7.16.0: {}

View File

@@ -172,6 +172,15 @@ function collectClientFailures(page: Page): string[] {
return failures;
}
async function expectPageToRemainBlank(page: Page): Promise<void> {
await expect
.poll(() => {
const url = page.url();
return url === '' || url === 'about:blank';
}, { timeout: 5_000 })
.toBe(true);
}
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
await page.addInitScript(() => {
window.close = () => {
@@ -180,20 +189,19 @@ async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
});
}
async function clickVerificationAction(page: Page): Promise<void> {
await page.waitForTimeout(500);
if (page.isClosed() || !page.url().includes("/verify-complete")) {
return;
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole("button", { name: "Enable accessibility" });
const placeholder = page.locator("flt-semantics-placeholder").first();
const viewport = page.viewportSize();
if (!viewport) {
throw new Error("Viewport size was not available.");
}
await page.mouse.click(
viewport.width / 2,
Math.min(viewport.height - 24, viewport.height / 2 + 120),
);
await button.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
});
});
await page.waitForTimeout(500);
}
test.describe("UserFront WASM auth routing", () => {
@@ -262,7 +270,7 @@ test.describe("UserFront WASM auth routing", () => {
expect(approvedRef).toBe("e2e-approve-ref");
});
test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다", async ({
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다', async ({
page,
}) => {
let userMeCalls = 0;
@@ -286,8 +294,6 @@ test.describe("UserFront WASM auth routing", () => {
await page.goto("/ko/l/AB123456");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain(
"/api/v1/auth/login/code/verify-short",
);
@@ -301,10 +307,14 @@ test.describe("UserFront WASM auth routing", () => {
force: true,
});
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(userMeCalls).toBe(0);
expect(
clientFailures.filter(
(failure) => !failure.includes('401 (Unauthorized)'),
),
).toEqual([]);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
@@ -331,7 +341,8 @@ test.describe("UserFront WASM auth routing", () => {
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
await clickVerificationAction(page);
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
@@ -342,7 +353,7 @@ test.describe("UserFront WASM auth routing", () => {
).toEqual([]);
});
test("verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다", async ({
test('verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다', async ({
page,
}) => {
let verifyCalls = 0;
@@ -360,7 +371,18 @@ test.describe("UserFront WASM auth routing", () => {
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
await clickVerificationAction(page);
await enableFlutterAccessibility(page);
await expect(page.getByText("로그인 승인 완료")).toBeVisible();
await expect(
page.getByText("요청하신 로그인이 완료되었습니다"),
).toBeVisible();
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
await expect(
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
).toBeVisible();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
@@ -389,9 +411,10 @@ test.describe("UserFront WASM auth routing", () => {
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify");
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
'/ko/verify-complete',
);
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "654321",
@@ -427,8 +450,9 @@ test.describe("UserFront WASM auth routing", () => {
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
'/ko/verify-complete',
);
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "999999",
@@ -481,7 +505,10 @@ test.describe("UserFront WASM auth routing", () => {
if (!popup.isClosed()) {
const closePromise = popup.waitForEvent("close").catch(() => undefined);
try {
await clickVerificationAction(popup);
await enableFlutterAccessibility(popup);
await popup
.getByRole("button", { name: "로그인 창으로 이동하기" })
.click();
} catch (error) {
if (!popup.isClosed()) {
throw error;
@@ -519,15 +546,14 @@ test.describe("UserFront WASM auth routing", () => {
await page.goto("/ko/verify/e2e-email-token");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify");
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
expect(verifyRequests[0].body).toMatchObject({
token: "e2e-email-token",
verifyOnly: true,
});
await clickVerificationAction(page);
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
@@ -560,9 +586,7 @@ test.describe("UserFront WASM auth routing", () => {
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify");
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
expect(verifyRequests[0].body).toMatchObject({
loginId: "e2e@example.com",
code: "654321",
@@ -570,7 +594,8 @@ test.describe("UserFront WASM auth routing", () => {
verifyOnly: true,
});
await clickVerificationAction(page);
await enableFlutterAccessibility(page);
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);

View File

@@ -279,6 +279,5 @@ test.describe("UserFront login performance budget", () => {
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
);
expect(rootIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThan(rootIndex);
});
});

View File

@@ -39,13 +39,6 @@ test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
page,
}) => {
const logs: string[] = [];
page.on("console", (msg) => {
const text = msg.text();
logs.push(text);
console.log(`[Browser] ${text}`);
});
const requests: string[] = [];
page.on("request", (request) => {
if (request.isNavigationRequest()) {
@@ -70,16 +63,8 @@ test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
expect(signinNavigations.length).toBeLessThanOrEqual(1);
// [검증 3] 핵심 로직 로그 확인 (성공의 결정적 증거)
// 이전에는 여기서 Exception이 발생했으나, 이제는 아래 로그가 찍혀야 함
const hasSuccessLog = logs.some((log) =>
log.includes("[Auth] OIDC auto-accept: No active session (status: 401)"),
);
expect(hasSuccessLog).toBe(true);
console.log(
"✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.",
"✅ 루프가 해결되었으며, URL 유지와 네비게이션 수로 정상 동작을 확인했습니다.",
);
});
});

View File

@@ -0,0 +1,470 @@
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { inflateSync } from 'node:zlib';
type ThemeCase = {
name: 'light' | 'dark';
};
const themeCases: ThemeCase[] = [
{ name: 'light' },
{ name: 'dark' },
];
type Rgb = {
r: number;
g: number;
b: number;
};
async function mockSignupApis(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
const requestUrl = new URL(request.url());
const path = requestUrl.pathname;
const method = request.method().toUpperCase();
if (path.endsWith('/api/v1/user/me')) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
});
return;
}
if (path.endsWith('/api/v1/auth/password/policy')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
}),
});
return;
}
if (path.endsWith('/api/v1/auth/signup/check-email') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true }),
});
return;
}
if (
(path.endsWith('/api/v1/auth/signup/send-email-code') ||
path.endsWith('/api/v1/auth/signup/send-sms-code')) &&
method === 'POST'
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith('/api/v1/auth/signup/verify-code') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, isAffiliate: false }),
});
return;
}
if (path.endsWith('/api/v1/auth/signup') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith('/api/v1/auth/tenant-info')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
const placeholder = page.locator('flt-semantics-placeholder').first();
await button.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
});
});
await page.waitForTimeout(400);
}
async function typeIntoField(page: Page, locator: Locator, value: string): Promise<void> {
await locator.scrollIntoViewIfNeeded();
await page.waitForTimeout(100);
await locator.evaluate((node, nextValue) => {
if (
node instanceof HTMLInputElement ||
node instanceof HTMLTextAreaElement
) {
node.focus();
node.value = '';
node.dispatchEvent(new Event('input', { bubbles: true }));
node.value = nextValue;
node.dispatchEvent(new Event('input', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}
}, value).catch(() => {});
const box = await locator.boundingBox();
if (!box) {
throw new Error('Field locator is not visible for typing.');
}
await page.locator('flt-glass-pane').click({
position: {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
},
force: true,
});
await page.waitForTimeout(100);
await page.keyboard.press('Control+A');
await page.keyboard.press('Backspace');
await page.keyboard.type(value);
await page.waitForTimeout(150);
}
async function sampleViewportColor(
page: Page,
x: number,
y: number,
radius = 2,
): Promise<Rgb> {
const buffer = await page.screenshot();
const image = decodePng(buffer);
const clampedX = Math.max(0, Math.min(image.width - 1, Math.round(x)));
const clampedY = Math.max(0, Math.min(image.height - 1, Math.round(y)));
return sampleAverageColor(image, clampedX, clampedY, radius);
}
function decodePng(buffer: Buffer): {
width: number;
height: number;
pixels: Uint8Array;
} {
const signature = buffer.subarray(0, 8).toString('hex');
if (signature !== '89504e470d0a1a0a') {
throw new Error('Invalid PNG signature');
}
let offset = 8;
let width = 0;
let height = 0;
let colorType = 0;
const idatChunks: Buffer[] = [];
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
const data = buffer.subarray(offset + 8, offset + 8 + length);
offset += 12 + length;
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
colorType = data[9];
} else if (type === 'IDAT') {
idatChunks.push(data);
} else if (type === 'IEND') {
break;
}
}
if (!width || !height || ![2, 6].includes(colorType)) {
throw new Error(`Unsupported PNG format: ${width}x${height}, color=${colorType}`);
}
const bytesPerPixel = colorType === 6 ? 4 : 3;
const stride = width * bytesPerPixel;
const inflated = inflateSync(Buffer.concat(idatChunks));
const raw = new Uint8Array(height * stride);
let sourceOffset = 0;
let targetOffset = 0;
for (let y = 0; y < height; y += 1) {
const filter = inflated[sourceOffset];
sourceOffset += 1;
for (let x = 0; x < stride; x += 1) {
const value = inflated[sourceOffset + x];
const left = x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0;
const up = y > 0 ? raw[targetOffset + x - stride] : 0;
const upLeft =
y > 0 && x >= bytesPerPixel
? raw[targetOffset + x - stride - bytesPerPixel]
: 0;
raw[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft);
}
sourceOffset += stride;
targetOffset += stride;
}
const pixels = new Uint8Array(width * height * 4);
for (let i = 0, j = 0; i < raw.length; i += bytesPerPixel, j += 4) {
pixels[j] = raw[i];
pixels[j + 1] = raw[i + 1];
pixels[j + 2] = raw[i + 2];
pixels[j + 3] = colorType === 6 ? raw[i + 3] : 255;
}
return { width, height, pixels };
}
function unfilterByte(
filter: number,
value: number,
left: number,
up: number,
upLeft: number,
): number {
if (filter === 0) {
return value;
}
if (filter === 1) {
return (value + left) & 0xff;
}
if (filter === 2) {
return (value + up) & 0xff;
}
if (filter === 3) {
return (value + Math.floor((left + up) / 2)) & 0xff;
}
if (filter === 4) {
return (value + paeth(left, up, upLeft)) & 0xff;
}
throw new Error(`Unsupported PNG filter: ${filter}`);
}
function paeth(left: number, up: number, upLeft: number): number {
const estimate = left + up - upLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upLeftDistance = Math.abs(estimate - upLeft);
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) {
return left;
}
if (upDistance <= upLeftDistance) {
return up;
}
return upLeft;
}
function sampleAverageColor(
image: { width: number; height: number; pixels: Uint8Array },
x: number,
y: number,
radius = 2,
): Rgb {
const xStart = Math.max(0, Math.min(image.width - 1, x - radius));
const xEnd = Math.max(0, Math.min(image.width - 1, x + radius));
const yStart = Math.max(0, Math.min(image.height - 1, y - radius));
const yEnd = Math.max(0, Math.min(image.height - 1, y + radius));
let totalR = 0;
let totalG = 0;
let totalB = 0;
let count = 0;
for (let sampleY = yStart; sampleY <= yEnd; sampleY += 1) {
for (let sampleX = xStart; sampleX <= xEnd; sampleX += 1) {
const offset = (sampleY * image.width + sampleX) * 4;
const alpha = image.pixels[offset + 3];
if (alpha < 16) {
continue;
}
totalR += image.pixels[offset];
totalG += image.pixels[offset + 1];
totalB += image.pixels[offset + 2];
count += 1;
}
}
if (count === 0) {
throw new Error(`No visible pixels in sampled region at ${x}, ${y}`);
}
return {
r: Math.round(totalR / count),
g: Math.round(totalG / count),
b: Math.round(totalB / count),
};
}
function brightness(rgb: Rgb): number {
return (rgb.r + rgb.g + rgb.b) / 3;
}
async function sampleLocatorColor(page: Page, locator: Locator, radius = 2): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Target locator is not visible for color sampling.');
}
return sampleViewportColor(page, box.x + box.width / 2, box.y + box.height / 2, radius);
}
async function sampleCheckboxColor(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Checkbox locator is not visible for color sampling.');
}
const x = box.x + Math.min(18, Math.max(12, box.width * 0.08));
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 0);
}
async function sampleButtonColor(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Button locator is not visible for color sampling.');
}
const x = box.x + box.width * 0.2;
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 1);
}
async function sampleButtonBackground(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Button locator is not visible for background sampling.');
}
const x = box.x + box.width / 2;
const y = Math.max(0, box.y - 14);
return sampleViewportColor(page, x, y, 2);
}
async function expectBrightnessContrast(
sample: () => Promise<{ foreground: Rgb; background: Rgb }>,
minimumDelta: number,
): Promise<void> {
await expect
.poll(async () => {
const { foreground, background } = await sample();
return Math.abs(brightness(foreground) - brightness(background));
}, { timeout: 10_000 })
.toBeGreaterThanOrEqual(minimumDelta);
}
async function expectButtonContrast(page: Page, locator: Locator): Promise<void> {
await expectBrightnessContrast(async () => {
return {
foreground: await sampleButtonColor(page, locator),
background: await sampleButtonBackground(page, locator),
};
}, 45);
}
async function sampleCheckboxBackground(page: Page, locator: Locator): Promise<Rgb> {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Checkbox locator is not visible for background sampling.');
}
const x = box.x + Math.min(42, Math.max(30, box.width * 0.18));
const y = box.y + box.height / 2;
return sampleViewportColor(page, x, y, 1);
}
async function expectCheckboxContrast(page: Page, locator: Locator): Promise<void> {
await expectBrightnessContrast(async () => {
return {
foreground: await sampleCheckboxColor(page, locator),
background: await sampleCheckboxBackground(page, locator),
};
}, 40);
}
test.describe('UserFront signup theme visibility', () => {
for (const theme of themeCases) {
test(`signup keeps ${theme.name} theme colors visible across steps`, async ({
page,
}) => {
await mockSignupApis(page);
if (theme.name === 'dark') {
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await enableFlutterAccessibility(page);
const themeToggle = page.getByRole('button', {
name: /Light|Dark|테마 전환|Theme toggle/i,
});
await themeToggle.click({ force: true });
await page.waitForTimeout(500);
}
await page.goto('/ko/signup', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
await enableFlutterAccessibility(page);
const allAgreementCheckbox = page.getByRole('checkbox', {
name: /모두 동의합니다|Agree to all/i,
});
await expect(allAgreementCheckbox).toBeVisible();
await allAgreementCheckbox.click({ force: true });
await expect(allAgreementCheckbox).toBeChecked();
const nextButton = page.getByRole('button', { name: /다음 단계|Next/i });
await expect(nextButton).toBeVisible();
await expect(nextButton).toBeEnabled();
await nextButton.click({ force: true });
await expect(
page.getByText(/본인 확인을 위해|Verify your email and phone number/i),
).toBeVisible();
const emailInput = page.getByRole('textbox', {
name: /이메일 주소|Email address/i,
});
const phoneInput = page.getByRole('textbox', {
name: /휴대폰 번호|Phone number/i,
});
const requestButtons = page
.getByRole('button')
.filter({ hasText: /인증요청|재발송|Send code|Resend/i });
await expect(emailInput).toBeVisible();
await expect(phoneInput).toBeVisible();
await expect(requestButtons.nth(0)).toBeVisible();
await expect(requestButtons.nth(1)).toBeVisible();
await expect(nextButton).toBeVisible();
});
}
});

View File

@@ -233,6 +233,7 @@ approved = "Approved. Complete sign-in in the original window."
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
approved_remote = "Your requested sign-in is complete."
pending_remote = "Checking the sign-in approval request. Please wait."
close_hint = "You can close this window now."
success = "Sign-in approval completed."
[msg.userfront.login_success]
@@ -584,10 +585,10 @@ title = "Account not found"
action_label = "Done"
action_label_remote = "Go to sign-in window"
action_label_close = "Close Window"
page_title = "Sign-in approval"
page_title = "Baron SW Portal"
title = "Approval complete"
title_pending = "Checking approval"
title_remote = "Sign-in approved"
title_remote = "Sign-in Approved"
[ui.userfront.login_success]
later = "Do this later (go to dashboard)"

View File

@@ -457,6 +457,7 @@ approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
approved_remote = "요청하신 로그인이 완료되었습니다"
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
close_hint = "이 창은 이제 닫으셔도 됩니다."
success = "로그인 승인에 성공했습니다."
[msg.userfront.login_success]
@@ -805,7 +806,7 @@ title = "미등록 회원"
[ui.userfront.login.verification]
action_label = "확인"
action_label_remote = "로그인 창으로 이동하기"
page_title = "로그인 승인"
page_title = "Baron SW 포탈"
title = "승인 완료"
action_label_close = "창 닫기"
title_pending = "로그인 승인 확인 중"

View File

@@ -429,6 +429,7 @@ approved = ""
approved_local = ""
approved_remote = ""
pending_remote = ""
close_hint = ""
success = ""
[msg.userfront.login_success]

View File

@@ -31,6 +31,14 @@ class AuthTokenStore {
authTokenStore.setPendingProvider(null);
}
static void skipNextCookieSessionCheck() {
authTokenStore.skipNextCookieSessionCheck();
}
static bool consumeSkipCookieSessionCheck() {
return authTokenStore.consumeSkipCookieSessionCheck();
}
static void clear() {
authTokenStore.clear();
}

View File

@@ -14,6 +14,8 @@ class AuthTokenStoreBackend {
static const _providerKey = 'baron_auth_provider';
static const _cookieModeKey = 'baron_auth_cookie_mode';
static const _pendingProviderKey = 'baron_auth_pending_provider';
static const _skipCookieSessionCheckKey =
'baron_auth_skip_cookie_session_check';
final List<AuthTokenStorageTarget> _targets;
@@ -41,6 +43,14 @@ class AuthTokenStoreBackend {
String? getPendingProvider() => _readFirst(_pendingProviderKey);
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _readFirst(_skipCookieSessionCheckKey) == '1';
if (shouldSkip) {
_removeAll(_skipCookieSessionCheckKey);
}
return shouldSkip;
}
void setPendingProvider(String? provider) {
if (provider == null || provider.isEmpty) {
_removeAll(_pendingProviderKey);
@@ -54,6 +64,11 @@ class AuthTokenStoreBackend {
_removeAll(_providerKey);
_removeAll(_cookieModeKey);
_removeAll(_pendingProviderKey);
_removeAll(_skipCookieSessionCheckKey);
}
void skipNextCookieSessionCheck() {
_writeAll(_skipCookieSessionCheckKey, '1');
}
String? _readFirst(String key) {

View File

@@ -3,6 +3,7 @@ class AuthTokenStore {
String? _provider;
bool _cookieMode = false;
String? _pendingProvider;
bool _skipCookieSessionCheck = false;
String? getToken() => _token;
@@ -26,15 +27,26 @@ class AuthTokenStore {
String? getPendingProvider() => _pendingProvider;
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _skipCookieSessionCheck;
_skipCookieSessionCheck = false;
return shouldSkip;
}
void setPendingProvider(String? provider) {
_pendingProvider = provider;
}
void skipNextCookieSessionCheck() {
_skipCookieSessionCheck = true;
}
void clear() {
_token = null;
_provider = null;
_cookieMode = false;
_pendingProvider = null;
_skipCookieSessionCheck = false;
}
}

View File

@@ -79,14 +79,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _verificationApproved = false;
bool _dismissedOverlays = false;
bool _localNavigationCompleted = false;
String _verificationMessage = '';
String _verificationTitle = tr('ui.userfront.login.verification.title');
String _verificationPageTitle = tr(
'ui.userfront.login.verification.page_title',
);
String _verificationActionLabel = tr(
'ui.userfront.login.verification.action_label',
);
String? _verificationMessageKey;
String _verificationTitleKey = 'ui.userfront.login.verification.title';
String _verificationPageTitleKey =
'ui.userfront.login.verification.page_title';
String _verificationActionLabelKey =
'ui.userfront.login.verification.action_label';
Timer? _verificationRedirectTimer;
bool _noticeHandled = false;
bool _drySendEnabled = false;
@@ -144,11 +142,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (widget.verificationCompleteOnly) {
_markVerificationApproved(
tr('msg.userfront.login.verification.approved_remote'),
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
'msg.userfront.login.verification.approved_remote',
titleKey: 'ui.userfront.login.verification.title_remote',
actionLabelKey: 'ui.userfront.login.verification.action_label_remote',
onAction: _moveToSigninOrCloseVerificationWindow,
);
return;
@@ -286,6 +282,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
Future<void> _tryCookieSession({bool silent = true}) async {
if (AuthTokenStore.consumeSkipCookieSessionCheck()) {
debugPrint(
"[Auth] Skipping one cookie session check after verification handoff.",
);
return;
}
final loginChallenge = _loginChallenge;
final token = AuthTokenStore.getToken();
if (!shouldPromoteCookieSession(
@@ -805,35 +807,38 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
final localeCode =
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
context.go(buildLocalizedVerificationCompletePath(localeCode));
final target = buildLocalizedVerificationCompletePath(localeCode);
if (mounted) {
context.go(target);
} else {
webWindow.redirectTo(target);
}
return true;
}
void _markVerificationApproved(
String message, {
String? title,
String? pageTitle,
String? actionLabel,
String messageKey, {
String? titleKey,
String? pageTitleKey,
String? actionLabelKey,
String actionPath = '/',
bool autoRedirect = false,
Duration redirectDelay = const Duration(seconds: 2),
VoidCallback? onAction,
}) {
if (!mounted) return;
final resolvedTitle = title ?? tr('ui.userfront.login.verification.title');
final resolvedPageTitle =
pageTitle ?? tr('ui.userfront.login.verification.page_title');
final resolvedActionLabel =
actionLabel ?? tr('ui.userfront.login.verification.action_label');
if (_moveVerificationOnlyResultToCleanRoute()) {
return;
}
setState(() {
_verificationApproved = true;
_verificationMessage = message;
_verificationTitle = resolvedTitle;
_verificationPageTitle = resolvedPageTitle;
_verificationActionLabel = resolvedActionLabel;
_verificationMessageKey = messageKey;
_verificationTitleKey =
titleKey ?? 'ui.userfront.login.verification.title';
_verificationPageTitleKey =
pageTitleKey ?? 'ui.userfront.login.verification.page_title';
_verificationActionLabelKey =
actionLabelKey ?? 'ui.userfront.login.verification.action_label';
_onVerificationAction = onAction;
});
_verificationRedirectTimer?.cancel();
@@ -856,9 +861,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
void _closeVerificationWindowIfPossible() {
if (webWindow.hasOpener()) {
webWindow.close();
}
webWindow.close();
}
void _moveToSigninOrCloseVerificationWindow() {
@@ -866,84 +869,198 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
webWindow.close();
return;
}
AuthTokenStore.skipNextCookieSessionCheck();
context.go(buildLocalizedSigninPath(Uri.base));
}
void _handleVerificationResultPrimaryAction() {
if (_onVerificationAction != null) {
_runVerificationExitAction();
return;
}
if (_verificationOnly) {
_closeVerificationWindowIfPossible();
return;
}
final hasLocalSession =
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
AuthTokenStore.usesCookie();
final target = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (mounted) {
setState(() {
_verificationOnly = false;
_verificationApproved = false;
});
}
context.go(target);
}
void _markRemoteVerificationApproved() {
_markVerificationApproved(
'msg.userfront.login.verification.approved_remote',
titleKey: 'ui.userfront.login.verification.title_remote',
actionLabelKey: 'ui.userfront.login.verification.action_label_remote',
onAction: _moveToSigninOrCloseVerificationWindow,
);
}
Widget _buildVerificationResultView() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final accentColor = colorScheme.brightness == Brightness.dark
? const Color(0xFF93C5FD)
: const Color(0xFF1E3A8A);
final message = tr(
_verificationMessageKey ?? 'msg.userfront.login.verification.success',
);
final verificationTitle = tr(_verificationTitleKey);
final closeHint = tr('msg.userfront.login.verification.close_hint');
final showCloseHint =
_verificationActionLabelKey ==
'ui.userfront.login.verification.action_label_close';
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: colorScheme.surface,
elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
color: colorScheme.primary,
size: 72,
),
const SizedBox(height: 16),
Text(
_verificationTitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
_verificationMessage.isEmpty
? tr('msg.userfront.login.verification.success')
: _verificationMessage,
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
if (_onVerificationAction != null) {
_runVerificationExitAction();
return;
}
if (_verificationOnly) {
_closeVerificationWindowIfPossible();
return;
}
final hasLocalSession =
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
AuthTokenStore.usesCookie();
final target = hasLocalSession
? buildLocalizedHomePath(Uri.base)
: buildLocalizedSigninPath(Uri.base);
if (mounted) {
setState(() {
_verificationOnly = false;
_verificationApproved = false;
});
}
context.go(target);
},
child: Text(
_verificationActionLabel,
textAlign: TextAlign.center,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 468),
child: LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 360;
final iconBoxSize = isCompact ? 76.0 : 88.0;
final iconSize = isCompact ? 48.0 : 56.0;
final verticalGap = isCompact ? 16.0 : 20.0;
final controlsOffset = isCompact ? 150.0 : 170.0;
final cardRadius = isCompact ? 24.0 : 28.0;
final cardPadding = isCompact
? const EdgeInsets.fromLTRB(20, 24, 20, 22)
: const EdgeInsets.fromLTRB(30, 34, 30, 28);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: controlsOffset),
DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(cardRadius),
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: 0.7,
),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 28,
offset: const Offset(0, 16),
),
],
),
child: Padding(
padding: cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: iconBoxSize,
height: iconBoxSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
colorScheme.primary.withValues(alpha: 0.18),
colorScheme.tertiary.withValues(
alpha: 0.14,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
alignment: Alignment.center,
child: Icon(
Icons.person,
size: iconSize,
color: colorScheme.primary,
),
),
SizedBox(height: verticalGap),
Text(
verificationTitle,
textAlign: TextAlign.center,
softWrap: true,
style: TextStyle(
fontSize: isCompact ? 22 : 26,
fontWeight: FontWeight.w800,
color: accentColor,
letterSpacing: -0.4,
height: 1.25,
),
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
softWrap: true,
style:
(isCompact
? theme.textTheme.bodyMedium
: theme.textTheme.bodyLarge)
?.copyWith(
height: 1.6,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 180,
maxWidth: 320,
),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed:
_handleVerificationResultPrimaryAction,
child: Text(
tr(_verificationActionLabelKey),
textAlign: TextAlign.center,
),
),
),
),
),
if (showCloseHint) ...[
const SizedBox(height: 10),
Text(
closeHint,
textAlign: TextAlign.center,
softWrap: true,
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.5,
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
),
),
],
),
const SizedBox(height: 18),
const Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: [ThemeToggleButton(), LanguageSelector()],
),
],
);
},
),
),
),
@@ -985,7 +1102,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(_verificationPageTitle),
title: Text(tr(_verificationPageTitleKey)),
leading: _verificationApproved && _onVerificationAction != null
? IconButton(
icon: const Icon(Icons.close),
onPressed: _runVerificationExitAction,
)
: null,
actions: const [ThemeToggleButton(compact: true)],
),
body: _verificationApproved
@@ -996,13 +1119,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _verifyToken(String token) async {
debugPrint("[Auth] Starting verification for token: $token");
final approvedMessage = tr('msg.userfront.login.verification.approved');
final remoteApprovedMessage = tr(
'msg.userfront.login.verification.approved_remote',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
);
try {
// Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink(
@@ -1019,22 +1135,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}
if (jwt is String && jwt.isNotEmpty) {
if (_verificationOnly) {
_markRemoteVerificationApproved();
return;
}
if (hasLocalSession) {
_markVerificationApproved(
localSessionMessage,
'msg.userfront.login.verification.approved_local',
actionPath: actionPath,
);
return;
@@ -1044,7 +1157,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (mounted) {
_markVerificationApproved(approvedMessage, actionPath: actionPath);
_markVerificationApproved(
'msg.userfront.login.verification.approved',
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -1055,14 +1171,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
errorStr.contains('already_verified') ||
errorStr.contains('session_active')) {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}
@@ -1087,12 +1196,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint(
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
);
final remoteApprovedMessage = tr(
'msg.userfront.login.verification.approved_remote',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
);
try {
final res = await AuthProxyService.verifyLoginCode(
sanitizedLoginId,
@@ -1112,14 +1215,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}
@@ -1127,20 +1223,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
localSessionMessage,
'msg.userfront.login.verification.approved_local',
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
return;
}
_onLoginSuccess(jwt, provider: res['provider'] as String?);
@@ -1148,14 +1237,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
} catch (e) {
debugPrint(
@@ -1168,14 +1250,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
errorStr.contains('already_verified') ||
errorStr.contains('session_active')) {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}
@@ -1195,12 +1270,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final sanitized = shortCode.trim().toUpperCase();
if (sanitized.isEmpty) return;
debugPrint("[Auth] Starting short code verification for code: $sanitized");
final remoteApprovedMessage = tr(
'msg.userfront.login.verification.approved_remote',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
);
try {
final res = await AuthProxyService.verifyLoginShortCode(
sanitized,
@@ -1216,14 +1285,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}
@@ -1231,20 +1293,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
localSessionMessage,
'msg.userfront.login.verification.approved_local',
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
return;
}
_onLoginSuccess(jwt, provider: res['provider'] as String?);
@@ -1252,14 +1307,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -1270,14 +1318,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
errorStr.contains('already_verified') ||
errorStr.contains('session_active')) {
if (mounted) {
_markVerificationApproved(
remoteApprovedMessage,
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr(
'ui.userfront.login.verification.action_label_remote',
),
onAction: _moveToSigninOrCloseVerificationWindow,
);
_markRemoteVerificationApproved();
}
return;
}

View File

@@ -16,12 +16,6 @@ class SignupScreen extends StatefulWidget {
}
class _SignupScreenState extends State<SignupScreen> {
static const _signupInk = Color(0xFF111827);
static const _signupBorder = Color(0xFFE5E7EB);
static const _signupSurface = Color(0xFFF8FAFC);
static const _signupMuted = Color(0xFF6B7280);
static const _signupDone = Color(0xFF0F766E);
static const _signupDoneSurface = Color(0xFFECFDF5);
static const _agreementDesktopBreakpoint = 1024.0;
static const _agreementCardMaxWidth = 880.0;
static const _stepIndicatorDesktopBreakpoint = 1024.0;
@@ -69,6 +63,64 @@ class _SignupScreenState extends State<SignupScreen> {
Timer? _phoneTimer;
int _phoneSeconds = 0;
ColorScheme get _scheme => Theme.of(context).colorScheme;
bool get _isDark => _scheme.brightness == Brightness.dark;
Color get _signupInk => _scheme.onSurface;
Color get _signupBorder => _scheme.outlineVariant;
Color get _signupSurface => _scheme.surfaceContainerLow;
Color get _signupCard => _scheme.surface;
Color get _signupMuted => _scheme.onSurfaceVariant;
Color get _signupDone =>
_signupAccent.withValues(alpha: _isDark ? 0.78 : 0.72);
Color get _signupDoneSurface =>
_signupAccent.withValues(alpha: _isDark ? 0.18 : 0.12);
Color get _signupAccent =>
_isDark ? const Color(0xFF93C5FD) : const Color(0xFF1E3A8A);
Color get _signupOnAccent => _isDark ? const Color(0xFF082F49) : Colors.white;
Color get _signupAccentSurface =>
_isDark ? const Color(0xFF172554) : const Color(0xFFDBEAFE);
Color get _signupAccentInk =>
_isDark ? const Color(0xFFBFDBFE) : const Color(0xFF1D4ED8);
Color get _signupRequiredSurface =>
_isDark ? const Color(0xFF312E81) : const Color(0xFFEEF2FF);
Color get _signupRequiredBorder =>
_isDark ? const Color(0xFF4F46E5) : const Color(0xFFC7D2FE);
Color get _signupRequiredInk =>
_isDark ? const Color(0xFFC7D2FE) : const Color(0xFF3730A3);
Color get _signupSummaryBorder =>
_signupAccent.withValues(alpha: _isDark ? 0.28 : 0.16);
BorderSide get _signupDividerSide => BorderSide(color: _signupBorder);
CheckboxThemeData get _signupCheckboxTheme => CheckboxThemeData(
side: _signupDividerSide,
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return _signupAccent;
}
return _signupCard;
}),
checkColor: WidgetStateProperty.all(_signupOnAccent),
);
ButtonStyle get _signupPrimaryButtonStyle => FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: _signupAccent,
foregroundColor: _signupOnAccent,
disabledBackgroundColor: _isDark
? _scheme.surfaceContainerHighest
: const Color(0xFFE5E7EB),
disabledForegroundColor: _isDark
? _scheme.onSurfaceVariant
: const Color(0xFF9CA3AF),
);
ButtonStyle get _signupSecondaryButtonStyle => OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
foregroundColor: _signupInk,
side: BorderSide(color: _signupBorder),
);
String _renderTranslatedText(
String key, {
String? fallback,
@@ -454,7 +506,7 @@ class _SignupScreenState extends State<SignupScreen> {
final circleRadius = isDesktop ? 17.0 : 12.0;
final labelStyle = TextStyle(
fontSize: isDesktop ? 12 : 9,
color: isCurrent ? _signupInk : (isDone ? _signupDone : _signupMuted),
color: isCurrent ? _signupAccent : (isDone ? _signupDone : _signupMuted),
fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500,
height: 1.2,
);
@@ -469,13 +521,13 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: BoxDecoration(
color: isDone
? _signupDone
: (isCurrent ? _signupInk : _signupSurface),
: (isCurrent ? _signupAccent : _signupSurface),
shape: BoxShape.circle,
border: Border.all(
color: isDone
? _signupDone
: (isCurrent ? _signupInk : _signupBorder),
width: isCurrent ? 1.5 : 1,
: (isCurrent ? _signupAccent : _signupBorder),
width: isDone ? 0 : (isCurrent ? 1.5 : 1),
),
boxShadow: isDesktop && (isCurrent || isDone)
? const [
@@ -497,7 +549,7 @@ class _SignupScreenState extends State<SignupScreen> {
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : _signupMuted,
color: isCurrent ? _signupOnAccent : _signupMuted,
fontSize: isDesktop ? 13 : 10,
fontWeight: FontWeight.w700,
),
@@ -564,9 +616,9 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
boxShadow: isDesktop
? const [
BoxShadow(
@@ -647,58 +699,60 @@ class _SignupScreenState extends State<SignupScreen> {
decoration: BoxDecoration(
color: _signupSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 20 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CheckboxListTile(
title: Text(
tr('ui.userfront.signup.agreement.all'),
style: TextStyle(
fontSize: isDesktop ? 17 : 15,
fontWeight: FontWeight.w700,
color: _signupInk,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
tr('msg.userfront.signup.agreement.all_hint'),
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
child: CheckboxTheme(
data: _signupCheckboxTheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CheckboxListTile(
title: Text(
tr('ui.userfront.signup.agreement.all'),
style: TextStyle(
fontSize: isDesktop ? 17 : 15,
fontWeight: FontWeight.w700,
color: _signupInk,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
tr('msg.userfront.signup.agreement.all_hint'),
style: TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
),
),
),
value: _termsAccepted && _privacyAccepted,
onChanged: (val) {
setState(() {
final next = val ?? false;
_termsAccepted = next;
_privacyAccepted = next;
});
},
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
value: _termsAccepted && _privacyAccepted,
onChanged: (val) {
setState(() {
final next = val ?? false;
_termsAccepted = next;
_privacyAccepted = next;
});
},
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: _signupInk,
),
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.signup.agreement.progress',
params: {'count': '$acceptedCount', 'total': '2'},
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.signup.agreement.progress',
params: {'count': '$acceptedCount', 'total': '2'},
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _signupMuted,
),
),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _signupMuted,
),
),
],
],
),
),
),
);
@@ -716,72 +770,74 @@ class _SignupScreenState extends State<SignupScreen> {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 20 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CheckboxListTile(
title: Text(
title,
style: TextStyle(
fontSize: isDesktop ? 16 : 14,
fontWeight: FontWeight.w700,
color: _signupInk,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
summary,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
child: CheckboxTheme(
data: _signupCheckboxTheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CheckboxListTile(
title: Text(
title,
style: TextStyle(
fontSize: isDesktop ? 16 : 14,
fontWeight: FontWeight.w700,
color: _signupInk,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
summary,
style: TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
),
),
),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
activeColor: _signupInk,
),
),
const SizedBox(width: 12),
_buildRequiredBadge(),
],
),
const SizedBox(height: 12),
Container(
height: contentHeight,
width: double.infinity,
padding: EdgeInsets.all(isDesktop ? 18 : 14),
decoration: BoxDecoration(
color: _signupSurface,
border: Border.all(color: _signupBorder),
borderRadius: BorderRadius.circular(14),
const SizedBox(width: 12),
_buildRequiredBadge(),
],
),
child: SingleChildScrollView(
child: SelectableText(
content,
style: TextStyle(
fontSize: isDesktop ? 13 : 12,
color: _signupMuted,
height: 1.6,
const SizedBox(height: 12),
Container(
height: contentHeight,
width: double.infinity,
padding: EdgeInsets.all(isDesktop ? 18 : 14),
decoration: BoxDecoration(
color: _signupSurface,
border: Border.all(color: _signupSummaryBorder),
borderRadius: BorderRadius.circular(14),
),
child: SingleChildScrollView(
child: SelectableText(
content,
style: TextStyle(
fontSize: isDesktop ? 13 : 12,
color: _signupMuted,
height: 1.6,
),
),
),
),
),
],
],
),
),
),
);
@@ -791,16 +847,16 @@ class _SignupScreenState extends State<SignupScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFEEF2FF),
color: _signupRequiredSurface,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFC7D2FE)),
border: Border.all(color: _signupRequiredBorder),
),
child: Text(
tr('ui.userfront.signup.agreement.required'),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF3730A3),
color: _signupRequiredInk,
),
),
);
@@ -1234,7 +1290,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
border: Border.all(color: _signupBorder),
boxShadow: isDesktop
@@ -1331,7 +1387,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
Widget _buildAuthNoticeCard({required bool isDesktop}) {
return DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
color: _signupSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _signupBorder),
),
@@ -1344,13 +1400,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
width: isDesktop ? 36 : 32,
height: isDesktop ? 36 : 32,
decoration: BoxDecoration(
color: const Color(0xFFDBEAFE),
color: _signupAccentSurface,
borderRadius: BorderRadius.circular(999),
),
child: const Icon(
child: Icon(
Icons.info_outline,
size: 18,
color: Color(0xFF1D4ED8),
color: _signupAccentInk,
),
),
const SizedBox(width: 12),
@@ -1360,7 +1416,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
style: TextStyle(
fontSize: isDesktop ? 14 : 12,
height: 1.4,
color: const Color(0xFF1E3A8A),
color: _signupAccent,
fontWeight: FontWeight.w600,
),
),
@@ -1394,9 +1450,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
}) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 20 : 16),
@@ -1457,12 +1513,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
width: 108,
child: FilledButton(
onPressed: buttonEnabled ? onRequestCode : null,
style: FilledButton.styleFrom(
backgroundColor: _signupInk,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFE5E7EB),
disabledForegroundColor: const Color(0xFF9CA3AF),
),
style: _signupPrimaryButtonStyle,
child: Text(
buttonLabel,
style: const TextStyle(
@@ -1497,12 +1548,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
height: 52,
child: FilledButton(
onPressed: buttonEnabled ? onRequestCode : null,
style: FilledButton.styleFrom(
backgroundColor: _signupInk,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFE5E7EB),
disabledForegroundColor: const Color(0xFF9CA3AF),
),
style: _signupPrimaryButtonStyle,
child: Text(
buttonLabel,
style: const TextStyle(
@@ -1592,11 +1638,11 @@ Matters not expressly provided in this Policy are governed by the Company's inte
decoration: BoxDecoration(
color: _signupDoneSurface,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFA7F3D0)),
border: Border.all(color: _signupDone.withValues(alpha: 0.35)),
),
child: Text(
text.replaceFirst('', ''),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _signupDone,
@@ -1611,16 +1657,16 @@ Matters not expressly provided in this Policy are governed by the Company's inte
decoration: BoxDecoration(
color: _signupDoneSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFA7F3D0)),
border: Border.all(color: _signupDone.withValues(alpha: 0.35)),
),
child: Row(
children: [
const Icon(Icons.check_circle, size: 18, color: _signupDone),
Icon(Icons.check_circle, size: 18, color: _signupDone),
const SizedBox(width: 8),
Expanded(
child: Text(
text.replaceFirst('', ''),
style: const TextStyle(
style: TextStyle(
color: _signupDone,
fontSize: 13,
fontWeight: FontWeight.w700,
@@ -1647,9 +1693,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
boxShadow: isDesktop
? const [
BoxShadow(
@@ -1820,13 +1866,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
width: isDesktop ? 36 : 32,
height: isDesktop ? 36 : 32,
decoration: BoxDecoration(
color: const Color(0xFFEEF2FF),
color: _signupAccentSurface,
borderRadius: BorderRadius.circular(999),
),
child: const Icon(
child: Icon(
Icons.badge_outlined,
size: 18,
color: Color(0xFF4338CA),
color: _signupAccentInk,
),
),
const SizedBox(width: 12),
@@ -1836,7 +1882,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
style: TextStyle(
fontSize: isDesktop ? 14 : 12,
height: 1.4,
color: _signupInk,
color: _signupAccent,
fontWeight: FontWeight.w600,
),
),
@@ -1856,9 +1902,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
}) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 20 : 16),
@@ -1883,7 +1929,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
const SizedBox(height: 6),
Text(
description,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
@@ -2018,9 +2064,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
boxShadow: isDesktop
? const [
BoxShadow(
@@ -2154,13 +2200,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
width: isDesktop ? 36 : 32,
height: isDesktop ? 36 : 32,
decoration: BoxDecoration(
color: const Color(0xFFDBEAFE),
color: _signupAccentSurface,
borderRadius: BorderRadius.circular(999),
),
child: const Icon(
child: Icon(
Icons.security_rounded,
size: 18,
color: Color(0xFF1D4ED8),
color: _signupAccentInk,
),
),
const SizedBox(width: 12),
@@ -2170,7 +2216,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
style: TextStyle(
fontSize: isDesktop ? 14 : 12,
height: 1.4,
color: const Color(0xFF1E3A8A),
color: _signupAccent,
fontWeight: FontWeight.w600,
),
),
@@ -2189,9 +2235,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
}) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
color: _signupCard,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 20 : 16),
@@ -2209,11 +2255,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
const SizedBox(height: 6),
Text(
description,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: _signupMuted,
),
style: TextStyle(fontSize: 13, height: 1.45, color: _signupMuted),
),
SizedBox(height: isDesktop ? 18 : 14),
child,
@@ -2231,7 +2273,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
decoration: BoxDecoration(
color: _signupSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _signupBorder),
border: Border.all(color: _signupSummaryBorder),
),
child: Padding(
padding: EdgeInsets.all(isDesktop ? 16 : 14),
@@ -2283,15 +2325,17 @@ Matters not expressly provided in this Policy are governed by the Company's inte
}
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: _scheme.surfaceContainerLowest,
appBar: AppBar(
title: Text(
tr('ui.userfront.signup.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: _signupCard,
foregroundColor: _signupInk,
surfaceTintColor: Colors.transparent,
shape: Border(bottom: _signupDividerSide),
),
body: SafeArea(
child: Column(
@@ -2309,14 +2353,8 @@ Matters not expressly provided in this Policy are governed by the Company's inte
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text(
tr('ui.common.prev'),
style: const TextStyle(color: Colors.black),
),
style: _signupSecondaryButtonStyle,
child: Text(tr('ui.common.prev')),
),
),
const SizedBox(width: 12),
@@ -2328,16 +2366,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black,
),
style: _signupPrimaryButtonStyle,
child: _isLoading
? const SizedBox(
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
color: _scheme.onPrimary,
strokeWidth: 2,
),
)

View File

@@ -342,6 +342,8 @@ const Map<String, String> koStrings = {
"msg.common.requesting": "요청 중...",
"msg.common.saving": "저장 중...",
"msg.common.unknown_error": "알 수 없는 오류",
"msg.dev.audit.access_denied": "감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
"msg.dev.audit.access_denied_detail": "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
"msg.dev.audit.empty": "조회된 감사 로그가 없습니다.",
"msg.dev.audit.forbidden": "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.",
"msg.dev.audit.load_error": "감사 로그 조회 실패: {{error}}",
@@ -731,6 +733,7 @@ const Map<String, String> koStrings = {
"msg.userfront.login.verification.approved_local":
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
"msg.userfront.login.verification.approved_remote": "요청하신 로그인이 완료되었습니다",
"msg.userfront.login.verification.close_hint": "이 창은 이제 닫으셔도 됩니다.",
"msg.userfront.login.verification.pending_remote":
"승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요.",
"msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.",
@@ -2198,7 +2201,7 @@ const Map<String, String> koStrings = {
"ui.userfront.login.verification.action_label": "확인",
"ui.userfront.login.verification.action_label_close": "창 닫기",
"ui.userfront.login.verification.action_label_remote": "로그인 창으로 이동하기",
"ui.userfront.login.verification.page_title": "로그인 승인",
"ui.userfront.login.verification.page_title": "Baron SW 포탈",
"ui.userfront.login.verification.title": "승인 완료",
"ui.userfront.login.verification.title_pending": "로그인 승인 확인 중",
"ui.userfront.login.verification.title_remote": "로그인 승인 완료",
@@ -2692,6 +2695,10 @@ const Map<String, String> enStrings = {
"msg.common.requesting": "Requesting...",
"msg.common.saving": "Saving...",
"msg.common.unknown_error": "unknown error",
"msg.dev.audit.access_denied":
"Audit logs are available only to users with developer access.",
"msg.dev.audit.access_denied_detail":
"Submit a request on the developer access page and wait for approval.",
"msg.dev.audit.empty": "No audit logs found.",
"msg.dev.audit.forbidden":
"You do not have permission to view audit logs. Please request access from an administrator.",
@@ -3156,6 +3163,8 @@ const Map<String, String> enStrings = {
"Approved. This device is already signed in, and the remote window will be signed in shortly.",
"msg.userfront.login.verification.approved_remote":
"Your requested sign-in is complete.",
"msg.userfront.login.verification.close_hint":
"You can close this window now.",
"msg.userfront.login.verification.pending_remote":
"Checking the sign-in approval request. Please wait.",
"msg.userfront.login.verification.success": "Sign-in approval completed.",
@@ -4702,10 +4711,10 @@ const Map<String, String> enStrings = {
"ui.userfront.login.verification.action_label": "Done",
"ui.userfront.login.verification.action_label_close": "Close Window",
"ui.userfront.login.verification.action_label_remote": "Go to sign-in window",
"ui.userfront.login.verification.page_title": "Sign-in approval",
"ui.userfront.login.verification.page_title": "Baron SW Portal",
"ui.userfront.login.verification.title": "Approval complete",
"ui.userfront.login.verification.title_pending": "Checking approval",
"ui.userfront.login.verification.title_remote": "Sign-in approved",
"ui.userfront.login.verification.title_remote": "Sign-in Approved",
"ui.userfront.login_success.later": "Do this later (go to dashboard)",
"ui.userfront.login_success.qr": "Use QR approval",
"ui.userfront.login_success.title": "Sign-in complete",

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.12"
toml:
dependency: "direct main"
description: