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:
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"]
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
backend/internal/repository/keto_outbox_repository_test.go
Normal file
68
backend/internal/repository/keto_outbox_repository_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -90,6 +90,7 @@ search_group = "그룹 검색..."
|
||||
select = "선택"
|
||||
select_file = "파일 선택"
|
||||
select_placeholder = "선택하세요"
|
||||
load_more = "더 보기"
|
||||
show_more = "+ 더보기"
|
||||
language = "언어"
|
||||
language_ko = "한국어"
|
||||
|
||||
@@ -90,6 +90,7 @@ search_group = ""
|
||||
select = ""
|
||||
select_file = ""
|
||||
select_placeholder = ""
|
||||
load_more = ""
|
||||
show_more = ""
|
||||
language = ""
|
||||
language_ko = ""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"]
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
6
devfront/package-lock.json
generated
6
devfront/package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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
178
devfront/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("react-oidc-context", () => ({
|
||||
|
||||
vi.mock("../../lib/auth", () => ({
|
||||
userManager: {
|
||||
getUser: vi.fn(async () => undefined),
|
||||
signinPopupCallback: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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}` &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -19,6 +19,10 @@ export function resolveClientCreateAccess({
|
||||
role,
|
||||
requestStatus,
|
||||
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
|
||||
if (!role.trim()) {
|
||||
return "request_required";
|
||||
}
|
||||
|
||||
if (!canSelfRequestDeveloperAccess(role)) {
|
||||
return "can_create";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
devfront/src/lib/oidcStorage.ts
Normal file
42
devfront/src/lib/oidcStorage.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "연동 앱"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "로그인 승인 확인 중"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["../common/config/biome.base.json"]
|
||||
"extends": ["../common/config/biome.base.json"],
|
||||
"files": {
|
||||
"includes": [".vite"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
77
userfront-e2e/pnpm-lock.yaml
generated
Normal 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: {}
|
||||
@@ -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(?:\?.*)?$/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 유지와 네비게이션 수로 정상 동작을 확인했습니다.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
470
userfront-e2e/tests/signup-theme-visibility.spec.ts
Normal file
470
userfront-e2e/tests/signup-theme-visibility.spec.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 = "로그인 승인 확인 중"
|
||||
|
||||
@@ -429,6 +429,7 @@ approved = ""
|
||||
approved_local = ""
|
||||
approved_remote = ""
|
||||
pending_remote = ""
|
||||
close_hint = ""
|
||||
success = ""
|
||||
|
||||
[msg.userfront.login_success]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user