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,
|
"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
|
if [ "$mode" = "production" ]; then
|
||||||
echo "Running in production mode with custom static server..."
|
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"
|
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import react from "@vitejs/plugin-react";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -11,6 +12,9 @@ export default defineConfig({
|
|||||||
"lucide-react": path.resolve(process.cwd(), "node_modules/lucide-react"),
|
"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_"],
|
envPrefix: ["VITE_", "USERFRONT_", "ORGFRONT_"],
|
||||||
build: {
|
build: {
|
||||||
outDir: buildOutDir,
|
outDir: buildOutDir,
|
||||||
|
|||||||
@@ -1357,6 +1357,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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())
|
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{
|
return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{
|
||||||
Object: clientID,
|
Object: clientID,
|
||||||
Relation: req.Relation,
|
Relation: req.Relation,
|
||||||
@@ -1411,6 +1422,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
|
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
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())
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1936,7 +1958,8 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
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")
|
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)
|
// [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") {
|
if !h.canBypassPrivateClientRestriction(c, profile, currentSummary, "edit_config") {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client")
|
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)
|
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{
|
h.setAuditDetailsExtra(c, map[string]any{
|
||||||
"action": "UPDATE_CLIENT",
|
"action": "UPDATE_CLIENT",
|
||||||
"target_id": clientID,
|
"target_id": clientID,
|
||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"before": map[string]any{
|
"before": map[string]any{
|
||||||
"name": currentSummary.Name,
|
"name": currentSummary.Name,
|
||||||
"type": currentSummary.Type,
|
"type": currentSummary.Type,
|
||||||
"status": currentSummary.Status,
|
"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{
|
"after": map[string]any{
|
||||||
"name": strings.TrimSpace(updated.ClientName),
|
"name": strings.TrimSpace(updated.ClientName),
|
||||||
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
|
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
|
||||||
"status": resolveStatusFromMetadata(updated.Metadata),
|
"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
|
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) {
|
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = map[string]interface{}{}
|
metadata = map[string]interface{}{}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
auditmw "baron-sso-backend/internal/middleware"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -154,6 +155,14 @@ func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int
|
|||||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
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 {
|
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)
|
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||||
}
|
}
|
||||||
@@ -591,6 +600,199 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
|
|||||||
mockKeto.AssertExpectations(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) {
|
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"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)
|
err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
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 {
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -2255,6 +2265,164 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
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 {
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := roleFromTraits(traits)
|
role := roleFromTraits(traits)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
@@ -98,6 +99,75 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
|||||||
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
|
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 {
|
type fakeUserHandlerWorksmobileSyncer struct {
|
||||||
upserts []domain.User
|
upserts []domain.User
|
||||||
}
|
}
|
||||||
@@ -1083,13 +1153,35 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
userRepo := new(MockUserRepoForHandler)
|
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 {
|
app.Delete("/users/:id", func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
return h.DeleteUser(c)
|
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()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
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, http.StatusNoContent, resp.StatusCode)
|
||||||
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
|
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
|
||||||
mockKratos.AssertExpectations(t)
|
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) {
|
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type KetoOutboxRepository interface {
|
|||||||
Create(ctx context.Context, entry *domain.KetoOutbox) error
|
Create(ctx context.Context, entry *domain.KetoOutbox) error
|
||||||
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
|
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
|
||||||
FindPending(ctx context.Context, limit int) ([]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
|
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
|
||||||
MarkProcessed(ctx context.Context, id 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
|
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 {
|
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{}{
|
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"status": status,
|
"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
|
// 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 {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
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)
|
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 {
|
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)
|
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ search_group = "Search groups..."
|
|||||||
select = "Select"
|
select = "Select"
|
||||||
select_file = "Select File"
|
select_file = "Select File"
|
||||||
select_placeholder = "Select Placeholder"
|
select_placeholder = "Select Placeholder"
|
||||||
|
load_more = "Load more"
|
||||||
show_more = "Show More"
|
show_more = "Show More"
|
||||||
language = "Language"
|
language = "Language"
|
||||||
language_ko = "Korean"
|
language_ko = "Korean"
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ search_group = "그룹 검색..."
|
|||||||
select = "선택"
|
select = "선택"
|
||||||
select_file = "파일 선택"
|
select_file = "파일 선택"
|
||||||
select_placeholder = "선택하세요"
|
select_placeholder = "선택하세요"
|
||||||
|
load_more = "더 보기"
|
||||||
show_more = "+ 더보기"
|
show_more = "+ 더보기"
|
||||||
language = "언어"
|
language = "언어"
|
||||||
language_ko = "한국어"
|
language_ko = "한국어"
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ search_group = ""
|
|||||||
select = ""
|
select = ""
|
||||||
select_file = ""
|
select_file = ""
|
||||||
select_placeholder = ""
|
select_placeholder = ""
|
||||||
|
load_more = ""
|
||||||
show_more = ""
|
show_more = ""
|
||||||
language = ""
|
language = ""
|
||||||
language_ko = ""
|
language_ko = ""
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"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"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3865,9 +3862,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
|||||||
const skipWebServer =
|
const skipWebServer =
|
||||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
|
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
|
||||||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
|
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.
|
* Read environment variables from file.
|
||||||
@@ -73,10 +73,9 @@ export default defineConfig({
|
|||||||
webServer: skipWebServer
|
webServer: skipWebServer
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
command: process.env.CI
|
command:
|
||||||
? "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 ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 5176",
|
||||||
: "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc pnpm exec vite --host 127.0.0.1 --strictPort --port 5174",
|
|
||||||
url: baseURL,
|
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)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
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':
|
'@vitest/coverage-v8':
|
||||||
specifier: 4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(vitest@4.1.6)
|
version: 4.1.6(vitest@4.1.6)
|
||||||
@@ -112,11 +112,11 @@ importers:
|
|||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.12
|
specifier: ^8.0.14
|
||||||
version: 8.0.13(@types/node@25.7.0)(jiti@1.21.7)
|
version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.6
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -323,8 +323,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@oxc-project/types@0.130.0':
|
'@oxc-project/types@0.132.0':
|
||||||
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
|
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
'@playwright/test@1.60.0':
|
||||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||||
@@ -727,97 +727,97 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.1':
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
|
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.0.1':
|
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||||
resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==}
|
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.1':
|
'@rolldown/binding-darwin-x64@1.0.2':
|
||||||
resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==}
|
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.0.1':
|
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||||
resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==}
|
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||||
resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==}
|
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.1':
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
|
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.1':
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
|
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
|
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.1':
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
|
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.1':
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
|
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.1':
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
|
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.1':
|
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||||
resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
|
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.1':
|
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||||
resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==}
|
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [wasm32]
|
cpu: [wasm32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.0.1':
|
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||||
resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==}
|
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||||
resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==}
|
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1520,6 +1520,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1620,8 +1624,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rolldown@1.0.1:
|
rolldown@1.0.2:
|
||||||
resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==}
|
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -1778,8 +1782,8 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
vite@8.0.13:
|
vite@8.0.14:
|
||||||
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
|
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2065,7 +2069,7 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
'@oxc-project/types@0.130.0': {}
|
'@oxc-project/types@0.132.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
'@playwright/test@1.60.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2453,53 +2457,53 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.1':
|
'@rolldown/binding-android-arm64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.0.1':
|
'@rolldown/binding-darwin-arm64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.1':
|
'@rolldown/binding-darwin-x64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.0.1':
|
'@rolldown/binding-freebsd-x64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.1':
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.1':
|
'@rolldown/binding-linux-arm64-gnu@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.1':
|
'@rolldown/binding-linux-arm64-musl@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.1':
|
'@rolldown/binding-linux-s390x-gnu@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.1':
|
'@rolldown/binding-linux-x64-gnu@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.1':
|
'@rolldown/binding-linux-x64-musl@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.1':
|
'@rolldown/binding-openharmony-arm64@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.1':
|
'@rolldown/binding-wasm32-wasi@1.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
'@emnapi/runtime': 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)
|
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.0.1':
|
'@rolldown/binding-win32-arm64-msvc@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.0.1':
|
'@rolldown/binding-win32-x64-msvc@1.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
@@ -2549,10 +2553,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
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:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@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)':
|
'@vitest/coverage-v8@4.1.6(vitest@4.1.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2566,7 +2570,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.1.0
|
std-env: 4.1.0
|
||||||
tinyrainbow: 3.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':
|
'@vitest/expect@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2577,13 +2581,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.6
|
'@vitest/spy': 4.1.6
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3144,6 +3148,12 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
@@ -3226,26 +3236,26 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rolldown@1.0.1:
|
rolldown@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.130.0
|
'@oxc-project/types': 0.132.0
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rolldown/binding-android-arm64': 1.0.1
|
'@rolldown/binding-android-arm64': 1.0.2
|
||||||
'@rolldown/binding-darwin-arm64': 1.0.1
|
'@rolldown/binding-darwin-arm64': 1.0.2
|
||||||
'@rolldown/binding-darwin-x64': 1.0.1
|
'@rolldown/binding-darwin-x64': 1.0.2
|
||||||
'@rolldown/binding-freebsd-x64': 1.0.1
|
'@rolldown/binding-freebsd-x64': 1.0.2
|
||||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.1
|
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
|
||||||
'@rolldown/binding-linux-arm64-gnu': 1.0.1
|
'@rolldown/binding-linux-arm64-gnu': 1.0.2
|
||||||
'@rolldown/binding-linux-arm64-musl': 1.0.1
|
'@rolldown/binding-linux-arm64-musl': 1.0.2
|
||||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.1
|
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
|
||||||
'@rolldown/binding-linux-s390x-gnu': 1.0.1
|
'@rolldown/binding-linux-s390x-gnu': 1.0.2
|
||||||
'@rolldown/binding-linux-x64-gnu': 1.0.1
|
'@rolldown/binding-linux-x64-gnu': 1.0.2
|
||||||
'@rolldown/binding-linux-x64-musl': 1.0.1
|
'@rolldown/binding-linux-x64-musl': 1.0.2
|
||||||
'@rolldown/binding-openharmony-arm64': 1.0.1
|
'@rolldown/binding-openharmony-arm64': 1.0.2
|
||||||
'@rolldown/binding-wasm32-wasi': 1.0.1
|
'@rolldown/binding-wasm32-wasi': 1.0.2
|
||||||
'@rolldown/binding-win32-arm64-msvc': 1.0.1
|
'@rolldown/binding-win32-arm64-msvc': 1.0.2
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.0.1
|
'@rolldown/binding-win32-x64-msvc': 1.0.2
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3395,22 +3405,22 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
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:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
postcss: 8.5.14
|
postcss: 8.5.15
|
||||||
rolldown: 1.0.1
|
rolldown: 1.0.2
|
||||||
tinyglobby: 0.2.16
|
tinyglobby: 0.2.16
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 25.7.0
|
'@types/node': 25.7.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 1.21.7
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.6
|
'@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/pretty-format': 4.1.6
|
||||||
'@vitest/runner': 4.1.6
|
'@vitest/runner': 4.1.6
|
||||||
'@vitest/snapshot': 4.1.6
|
'@vitest/snapshot': 4.1.6
|
||||||
@@ -3427,7 +3437,7 @@ snapshots:
|
|||||||
tinyexec: 1.1.2
|
tinyexec: 1.1.2
|
||||||
tinyglobby: 0.2.16
|
tinyglobby: 0.2.16
|
||||||
tinyrainbow: 3.1.0
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 25.7.0
|
'@types/node': 25.7.0
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
|||||||
import ProfilePage from "../features/profile/ProfilePage";
|
import ProfilePage from "../features/profile/ProfilePage";
|
||||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
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[] = [
|
export const devFrontRoutes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
@@ -25,27 +41,17 @@ export const devFrontRoutes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <AuthGuard />,
|
element:
|
||||||
children: [
|
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
|
||||||
{
|
children:
|
||||||
element: <AppLayout />,
|
import.meta.env.MODE === "development"
|
||||||
children: [
|
? devFrontAppChildren
|
||||||
{ index: true, element: <GlobalOverviewPage /> },
|
: [
|
||||||
{ path: "clients", element: <ClientsPage /> },
|
{
|
||||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
element: <AppLayout />,
|
||||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
children: devFrontAppChildren,
|
||||||
{ 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 /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
icon: LayoutDashboard,
|
||||||
end: true,
|
end: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
labelKey: "ui.dev.nav.developer_request",
|
|
||||||
labelFallback: "Developer Access Request",
|
|
||||||
to: "/developer-requests",
|
|
||||||
icon: ClipboardCheck,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.nav.clients",
|
labelKey: "ui.dev.nav.clients",
|
||||||
labelFallback: "Clients",
|
labelFallback: "Clients",
|
||||||
@@ -63,6 +57,12 @@ const navItems: ShellSidebarNavItem[] = [
|
|||||||
to: "/audit-logs",
|
to: "/audit-logs",
|
||||||
icon: NotebookTabs,
|
icon: NotebookTabs,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.developer_request",
|
||||||
|
labelFallback: "Developer Access Request",
|
||||||
|
to: "/developer-requests",
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type SessionStatusProps = {
|
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 type { AxiosError } from "axios";
|
||||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||||
import * as React from "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 { parseAuditDetails } from "../../../../common/core/audit";
|
||||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||||
import { PageHeader } from "../../../../common/core/components/page";
|
import { PageHeader } from "../../../../common/core/components/page";
|
||||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
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 { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +24,8 @@ import { Input } from "../../components/ui/input";
|
|||||||
import type { DevAuditLog } from "../../lib/devApi";
|
import type { DevAuditLog } from "../../lib/devApi";
|
||||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
|
||||||
function toCsv(logs: DevAuditLog[]) {
|
function toCsv(logs: DevAuditLog[]) {
|
||||||
const header = [
|
const header = [
|
||||||
@@ -65,6 +71,13 @@ function downloadCsv(content: string, filename: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuditLogsPage() {
|
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 [searchClientId, setSearchClientId] = React.useState("");
|
||||||
const [searchAction, setSearchAction] = React.useState("");
|
const [searchAction, setSearchAction] = React.useState("");
|
||||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||||
@@ -73,6 +86,24 @@ function AuditLogsPage() {
|
|||||||
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
||||||
const deferredSearchAction = React.useDeferredValue(searchAction.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({
|
const query = useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"dev-audit-logs",
|
"dev-audit-logs",
|
||||||
@@ -88,6 +119,7 @@ function AuditLogsPage() {
|
|||||||
}),
|
}),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||||
|
enabled: hasDeveloperAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs =
|
const logs =
|
||||||
@@ -101,6 +133,42 @@ function AuditLogsPage() {
|
|||||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
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) {
|
if (query.error) {
|
||||||
const axiosError = query.error as AxiosError<{ error?: string }>;
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||||
if (axiosError.response?.status === 403) {
|
if (axiosError.response?.status === 403) {
|
||||||
|
|||||||
@@ -1,10 +1,60 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { userManager } from "../../lib/auth";
|
||||||
|
import { findPersistedOidcUser } from "../../lib/oidcStorage";
|
||||||
|
|
||||||
export default function AuthGuard() {
|
export default function AuthGuard() {
|
||||||
const auth = useAuth();
|
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>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +76,7 @@ export default function AuthGuard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated && !hasStoredUser) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ vi.mock("react-oidc-context", () => ({
|
|||||||
|
|
||||||
vi.mock("../../lib/auth", () => ({
|
vi.mock("../../lib/auth", () => ({
|
||||||
userManager: {
|
userManager: {
|
||||||
|
getUser: vi.fn(async () => undefined),
|
||||||
signinPopupCallback: vi.fn(async () => undefined),
|
signinPopupCallback: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
|
|
||||||
interface ScopeItem {
|
interface ScopeItem {
|
||||||
@@ -326,12 +327,19 @@ function ClientGeneralPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
const clientId = params.id;
|
const clientId = params.id;
|
||||||
const isCreate = !clientId;
|
const isCreate = !clientId;
|
||||||
const currentUserId = auth.user?.profile.sub;
|
|
||||||
const systemRole = resolveProfileRole(
|
const systemRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId as string),
|
queryFn: () => fetchClient(clientId as string),
|
||||||
@@ -569,7 +577,7 @@ function ClientGeneralPage() {
|
|||||||
const securityProfile: SecurityProfile =
|
const securityProfile: SecurityProfile =
|
||||||
clientType === "pkce" ? "pkce" : "private";
|
clientType === "pkce" ? "pkce" : "private";
|
||||||
const canEditExistingClientGeneralSettings =
|
const canEditExistingClientGeneralSettings =
|
||||||
systemRole === "super_admin" ||
|
effectiveSystemRole === "super_admin" ||
|
||||||
relationData?.items?.some(
|
relationData?.items?.some(
|
||||||
(item: ClientRelation) =>
|
(item: ClientRelation) =>
|
||||||
item.subject === `User:${currentUserId}` &&
|
item.subject === `User:${currentUserId}` &&
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
import { fetchMe } from "../auth/authApi";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
|
|
||||||
const relationOptions = [
|
const relationOptions = [
|
||||||
@@ -91,6 +92,13 @@ function ClientRelationsPage() {
|
|||||||
const systemRole = resolveProfileRole(
|
const systemRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
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({
|
const { data: clientData } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
@@ -109,7 +117,7 @@ function ClientRelationsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Calculate permissions for UI hints and button states
|
// 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 myUserId = auth.user?.profile.sub;
|
||||||
const isRpAdmin = useMemo(() => {
|
const isRpAdmin = useMemo(() => {
|
||||||
if (isSuperAdmin) return true;
|
if (isSuperAdmin) return true;
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react";
|
||||||
BookOpenText,
|
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
ShieldHalf,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@@ -29,11 +22,6 @@ import {
|
|||||||
commonTableViewportClass,
|
commonTableViewportClass,
|
||||||
} from "../../../../common/ui/table";
|
} from "../../../../common/ui/table";
|
||||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "../../components/ui/avatar";
|
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +33,6 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import { Separator } from "../../components/ui/separator";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -57,7 +44,10 @@ import {
|
|||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
type ClientSummary,
|
type ClientSummary,
|
||||||
|
type DevAuditLog,
|
||||||
|
fetchDevUser,
|
||||||
fetchClients,
|
fetchClients,
|
||||||
|
fetchDevAuditLogs,
|
||||||
fetchDeveloperRequestStatus,
|
fetchDeveloperRequestStatus,
|
||||||
fetchDevStats,
|
fetchDevStats,
|
||||||
fetchMyTenants,
|
fetchMyTenants,
|
||||||
@@ -69,9 +59,197 @@ import { cn } from "../../lib/utils";
|
|||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
||||||
import { ClientLogo } from "./components/ClientLogo";
|
import { ClientLogo } from "./components/ClientLogo";
|
||||||
|
import {
|
||||||
|
formatAuditDateParts,
|
||||||
|
formatAuditValue,
|
||||||
|
parseAuditDetails,
|
||||||
|
resolveAuditActor,
|
||||||
|
} from "../../../../common/core/audit";
|
||||||
|
|
||||||
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
|
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() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -97,6 +275,14 @@ function ClientsPage() {
|
|||||||
enabled: hasAccessToken,
|
enabled: hasAccessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: me, isLoading: isLoadingMe } = useQuery({
|
||||||
|
queryKey: ["userMe"],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
enabled: hasAccessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileRole = me?.role?.trim() || role;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: requestStatus,
|
data: requestStatus,
|
||||||
isLoading: isLoadingRequest,
|
isLoading: isLoadingRequest,
|
||||||
@@ -104,21 +290,18 @@ function ClientsPage() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["developer-request", tenantId],
|
queryKey: ["developer-request", tenantId],
|
||||||
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
queryFn: () => fetchDeveloperRequestStatus(tenantId),
|
||||||
enabled: hasAccessToken && (role === "user" || role === "tenant_member"),
|
enabled:
|
||||||
|
hasAccessToken &&
|
||||||
|
(profileRole === "user" || profileRole === "tenant_member"),
|
||||||
});
|
});
|
||||||
const { data: tenants } = useQuery({
|
const { data: tenants } = useQuery({
|
||||||
queryKey: ["myTenants"],
|
queryKey: ["myTenants"],
|
||||||
queryFn: fetchMyTenants,
|
queryFn: fetchMyTenants,
|
||||||
enabled: hasAccessToken,
|
enabled: hasAccessToken,
|
||||||
});
|
});
|
||||||
const { data: me } = useQuery({
|
|
||||||
queryKey: ["userMe"],
|
|
||||||
queryFn: fetchMe,
|
|
||||||
enabled: hasAccessToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAccessState = resolveClientCreateAccess({
|
const createAccessState = resolveClientCreateAccess({
|
||||||
role,
|
role: profileRole,
|
||||||
requestStatus: requestStatus?.status,
|
requestStatus: requestStatus?.status,
|
||||||
});
|
});
|
||||||
const canCreateClient = createAccessState === "can_create";
|
const canCreateClient = createAccessState === "can_create";
|
||||||
@@ -131,6 +314,10 @@ function ClientsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||||
|
const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
|
||||||
|
useState(recentClientChangesInitialCount);
|
||||||
const [sortConfig, setSortConfig] =
|
const [sortConfig, setSortConfig] =
|
||||||
useState<SortConfig<ClientSortKey> | null>({
|
useState<SortConfig<ClientSortKey> | null>({
|
||||||
key: "createdAt",
|
key: "createdAt",
|
||||||
@@ -138,6 +325,62 @@ function ClientsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clients = data?.items || [];
|
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<
|
const clientSortResolvers = useMemo<
|
||||||
SortResolverMap<ClientSummary, ClientSortKey>
|
SortResolverMap<ClientSummary, ClientSortKey>
|
||||||
>(
|
>(
|
||||||
@@ -193,7 +436,6 @@ function ClientsPage() {
|
|||||||
(userProfile?.phone as string | undefined) ||
|
(userProfile?.phone as string | undefined) ||
|
||||||
(userProfile?.phone_number as string | undefined) ||
|
(userProfile?.phone_number as string | undefined) ||
|
||||||
"";
|
"";
|
||||||
const profileRole = me?.role || role;
|
|
||||||
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
||||||
|
|
||||||
type StatTone = "up" | "down" | "stable";
|
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) => {
|
const requestSort = (key: ClientSortKey) => {
|
||||||
setSortConfig((current) => toggleSort(current, key));
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
@@ -700,82 +1042,163 @@ function ClientsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
<Card className="glass-panel">
|
||||||
<Card className="glass-panel">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
|
||||||
<CardHeader className="pb-2">
|
<div>
|
||||||
<CardTitle className="text-lg font-bold">
|
<div className="flex items-center gap-1.5">
|
||||||
{t(
|
<CardTitle className="text-xl font-semibold">
|
||||||
"ui.dev.clients.help.title",
|
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
|
||||||
"Need help with OIDC configuration?",
|
</CardTitle>
|
||||||
)}
|
<Button
|
||||||
</CardTitle>
|
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>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.help.subtitle",
|
"msg.dev.clients.recent_changes.description",
|
||||||
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
|
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
|
||||||
|
{ count: recentChangedClientCount },
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||||
<CardContent className="flex items-center justify-between">
|
{t(
|
||||||
<div className="flex items-center gap-4">
|
"msg.dev.clients.recent_changes.permission_note",
|
||||||
<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>
|
</p>
|
||||||
<div>
|
{isRecentChangesGuideOpen && (
|
||||||
<p className="font-semibold">
|
<div className="mt-3 rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
|
<p className="text-sm font-semibold text-foreground">
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.help.docs_body",
|
"ui.dev.clients.recent_changes.guide_title",
|
||||||
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
|
"최근 변경 항목 안내",
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
<Button variant="secondary">
|
) : (
|
||||||
{t("ui.dev.clients.help.view_guides", "View guides")}
|
visibleRecentClientChanges.map((item) => {
|
||||||
</Button>
|
const { date, time } = formatAuditDateParts(item.timestamp);
|
||||||
</CardContent>
|
return (
|
||||||
</Card>
|
<div
|
||||||
|
key={item.eventId}
|
||||||
<Card className="glass-panel">
|
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"
|
||||||
<CardHeader className="pb-2">
|
>
|
||||||
<CardTitle className="text-lg font-semibold">
|
<div className="space-y-2">
|
||||||
{t("ui.dev.clients.owner.title", "Owner")}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</CardTitle>
|
<Link
|
||||||
<CardDescription>
|
to={`/clients/${item.clientId}`}
|
||||||
{t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")}
|
className="font-semibold transition-colors hover:text-primary"
|
||||||
</CardDescription>
|
>
|
||||||
</CardHeader>
|
{item.clientName}
|
||||||
<CardContent className="flex items-center justify-between">
|
</Link>
|
||||||
<div className="flex items-center gap-3">
|
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
<Avatar>
|
{item.clientId}
|
||||||
<AvatarImage
|
</code>
|
||||||
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
|
<span className="font-semibold">{item.actorName}</span>
|
||||||
alt={t("ui.dev.clients.owner.avatar_alt", "ops user")}
|
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||||
/>
|
{item.actorId}
|
||||||
<AvatarFallback>AR</AvatarFallback>
|
</code>
|
||||||
</Avatar>
|
<Badge variant="muted">{item.actionLabel}</Badge>
|
||||||
<div>
|
</div>
|
||||||
<p className="font-semibold">
|
<div className="flex flex-wrap gap-2">
|
||||||
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
|
{item.detailLabels.length > 0 ? (
|
||||||
</p>
|
item.detailLabels.map((detail) => (
|
||||||
<p className="text-xs text-muted-foreground">
|
<Badge
|
||||||
{t("ui.dev.clients.owner.email", "admin@brsw.kr")}
|
key={`${item.eventId}-${detail.label}`}
|
||||||
</p>
|
variant="outline"
|
||||||
</div>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
<Separator className="mx-4 hidden h-10 w-px md:block" />
|
) : null}
|
||||||
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
|
</CardContent>
|
||||||
<span>
|
</Card>
|
||||||
{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>
|
|
||||||
|
|
||||||
<RequestAccessModal
|
<RequestAccessModal
|
||||||
isOpen={isRequestModalOpen}
|
isOpen={isRequestModalOpen}
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ describe("client create access", () => {
|
|||||||
).toBe("request_required");
|
).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", () => {
|
it("shows pending state while a developer request is under review", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveClientCreateAccess({
|
resolveClientCreateAccess({
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export function resolveClientCreateAccess({
|
|||||||
role,
|
role,
|
||||||
requestStatus,
|
requestStatus,
|
||||||
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
|
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
|
||||||
|
if (!role.trim()) {
|
||||||
|
return "request_required";
|
||||||
|
}
|
||||||
|
|
||||||
if (!canSelfRequestDeveloperAccess(role)) {
|
if (!canSelfRequestDeveloperAccess(role)) {
|
||||||
return "can_create";
|
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() {
|
export default function DeveloperRequestPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||||
const role = resolveProfileRole(userProfile);
|
const role = resolveProfileRole(userProfile);
|
||||||
const isSuperAdmin = role === "super_admin";
|
|
||||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
const tenantId = userProfile?.tenant_id as string | undefined;
|
||||||
const companyCode = userProfile?.companyCode as string | undefined;
|
const companyCode = userProfile?.companyCode as string | undefined;
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ export default function DeveloperRequestPage() {
|
|||||||
const { data: me } = useQuery({
|
const { data: me } = useQuery({
|
||||||
queryKey: ["userMe"],
|
queryKey: ["userMe"],
|
||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
enabled: !!auth.user?.access_token,
|
enabled: hasAccessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTenant = tenants?.find(
|
const currentTenant = tenants?.find(
|
||||||
@@ -87,7 +87,8 @@ export default function DeveloperRequestPage() {
|
|||||||
(userProfile?.phone as string | undefined) ||
|
(userProfile?.phone as string | undefined) ||
|
||||||
(userProfile?.phone_number 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 profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import {
|
|||||||
OverviewMetric,
|
OverviewMetric,
|
||||||
OverviewSelectionChips,
|
OverviewSelectionChips,
|
||||||
} from "../../../../common/core/components/overview";
|
} from "../../../../common/core/components/overview";
|
||||||
|
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||||||
|
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||||
import {
|
import {
|
||||||
type ClientSummary,
|
type ClientSummary,
|
||||||
fetchClients,
|
fetchClients,
|
||||||
fetchDeveloperRequestStatus,
|
|
||||||
fetchDevRPUsageDaily,
|
fetchDevRPUsageDaily,
|
||||||
fetchDevStats,
|
fetchDevStats,
|
||||||
type RPUsageDailyMetric,
|
type RPUsageDailyMetric,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
|
||||||
type ClientDistribution = {
|
type ClientDistribution = {
|
||||||
activeClients: number;
|
activeClients: number;
|
||||||
@@ -480,14 +482,15 @@ function GlobalOverviewPage() {
|
|||||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||||
const role = resolveProfileRole(userProfile);
|
const role = resolveProfileRole(userProfile);
|
||||||
const tenantId = userProfile?.tenant_id as string | undefined;
|
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 [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||||
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
|
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
|
||||||
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
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({
|
const statsQuery = useQuery({
|
||||||
queryKey: ["dev-dashboard-stats"],
|
queryKey: ["dev-dashboard-stats"],
|
||||||
queryFn: fetchDevStats,
|
queryFn: fetchDevStats,
|
||||||
@@ -509,17 +512,17 @@ function GlobalOverviewPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clients = clientsQuery.data?.items ?? [];
|
const clients = clientsQuery.data?.items ?? [];
|
||||||
const hasDeveloperAccess =
|
const {
|
||||||
role === "super_admin" ||
|
hasDeveloperAccess,
|
||||||
role === "tenant_admin" ||
|
isDeveloperRequestPending,
|
||||||
role === "rp_admin" ||
|
canRequestDeveloperAccess,
|
||||||
requestStatus?.status === "approved";
|
isLoadingDeveloperAccessGate,
|
||||||
const isDeveloperRequestPending = requestStatus?.status === "pending";
|
} = useDeveloperAccessGate({
|
||||||
const canRequestDeveloperAccess =
|
hasAccessToken,
|
||||||
(role === "user" || role === "tenant_member") &&
|
profileRole,
|
||||||
!isLoadingRequestStatus &&
|
tenantId,
|
||||||
!hasDeveloperAccess &&
|
isLoadingIdentity: isLoadingMe,
|
||||||
!isDeveloperRequestPending;
|
});
|
||||||
const distribution = useMemo(
|
const distribution = useMemo(
|
||||||
() => buildClientDistribution(clients),
|
() => buildClientDistribution(clients),
|
||||||
[clients],
|
[clients],
|
||||||
@@ -607,7 +610,7 @@ function GlobalOverviewPage() {
|
|||||||
setSelectedClientIds([]);
|
setSelectedClientIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((role === "user" || role === "tenant_member") && isLoadingRequestStatus) {
|
if (isLoadingDeveloperAccessGate) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("ui.common.loading", "Loading...")}
|
{t("ui.common.loading", "Loading...")}
|
||||||
@@ -617,46 +620,29 @@ function GlobalOverviewPage() {
|
|||||||
|
|
||||||
if (!hasDeveloperAccess) {
|
if (!hasDeveloperAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
|
<DeveloperAccessRequestCard
|
||||||
<div className="space-y-3">
|
title={t("ui.common.overview.title", "운영 현황")}
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
isPending={isDeveloperRequestPending}
|
||||||
{t("ui.common.overview.title", "운영 현황")}
|
canRequest={canRequestDeveloperAccess}
|
||||||
</h2>
|
pendingMessage={t(
|
||||||
<p className="font-medium text-foreground">
|
"msg.dev.dashboard.access_pending",
|
||||||
{isDeveloperRequestPending
|
"개발자 권한 신청을 검토 중입니다.",
|
||||||
? t(
|
)}
|
||||||
"msg.dev.dashboard.access_pending",
|
deniedMessage={t(
|
||||||
"개발자 권한 신청을 검토 중입니다.",
|
"msg.dev.dashboard.access_denied",
|
||||||
)
|
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||||
: t(
|
)}
|
||||||
"msg.dev.dashboard.access_denied",
|
pendingDetailMessage={t(
|
||||||
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
|
"msg.dev.dashboard.access_pending_detail",
|
||||||
)}
|
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
||||||
</p>
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
deniedDetailMessage={t(
|
||||||
{isDeveloperRequestPending
|
"msg.dev.dashboard.access_denied_detail",
|
||||||
? t(
|
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||||
"msg.dev.dashboard.access_pending_detail",
|
)}
|
||||||
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||||
)
|
onAction={() => navigate("/developer-requests")}
|
||||||
: 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
|||||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||||
import { userManager } from "./auth";
|
import { userManager } from "./auth";
|
||||||
|
import { findPersistedOidcUser } from "./oidcStorage";
|
||||||
|
|
||||||
let isRedirectingToLogin = false;
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
@@ -12,9 +13,14 @@ const apiClient = axios.create({
|
|||||||
"/api/v1",
|
"/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) => {
|
apiClient.interceptors.request.use(async (config) => {
|
||||||
// OIDC Access Token 주입
|
// OIDC Access Token 주입
|
||||||
const user = await userManager.getUser();
|
const user = (await userManager.getUser()) ?? findPersistedOidcUser();
|
||||||
if (user?.access_token) {
|
if (user?.access_token) {
|
||||||
config.headers.Authorization = `Bearer ${user.access_token}`;
|
config.headers.Authorization = `Bearer ${user.access_token}`;
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,13 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDevelopmentMode || isTestMode) {
|
||||||
|
console.warn(
|
||||||
|
"[apiClient] Auth failure detected, but local redirects are disabled.",
|
||||||
|
);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldSuppressDevelopmentSessionRedirect({
|
shouldSuppressDevelopmentSessionRedirect({
|
||||||
appMode: import.meta.env.MODE,
|
appMode: import.meta.env.MODE,
|
||||||
|
|||||||
@@ -177,6 +177,27 @@ export type DevAssignableUserListResponse = {
|
|||||||
items: DevAssignableUser[];
|
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 = {
|
export type ConsentSummary = {
|
||||||
subject: string;
|
subject: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
@@ -290,6 +311,13 @@ export async function fetchDevUsers(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDevUser(userId: string) {
|
||||||
|
const { data } = await apiClient.get<DevUserSummary>(
|
||||||
|
`/admin/users/${userId}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addClientRelation(
|
export async function addClientRelation(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
payload: ClientRelationUpsertRequest,
|
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."
|
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."
|
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]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Body"
|
body = "Body"
|
||||||
title_emphasis = "Title Emphasis"
|
title_emphasis = "Title Emphasis"
|
||||||
@@ -1365,6 +1369,34 @@ search_placeholder = "Search by app name or ID..."
|
|||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
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]
|
[ui.dev.clients.badge]
|
||||||
admin_session = "Admin Session"
|
admin_session = "Admin Session"
|
||||||
dev_session = "DevFront 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"
|
label = "Status Change"
|
||||||
description = "Change the active or inactive state of the RP."
|
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]
|
[ui.dev.clients.list]
|
||||||
title = "Connected Applications"
|
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]
|
[ui.dev.clients.registry]
|
||||||
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
|
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
|
||||||
subtitle = "Applications"
|
subtitle = "Applications"
|
||||||
|
|||||||
@@ -511,6 +511,10 @@ access_pending = "개발자 권한 신청을 검토 중입니다."
|
|||||||
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
|
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
|
||||||
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
|
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
access_denied = "감사 로그는 개발자 권한이 있어야 볼 수 있습니다."
|
||||||
|
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
||||||
title_emphasis = " 하나의 화면"
|
title_emphasis = " 하나의 화면"
|
||||||
@@ -1365,6 +1369,34 @@ search_placeholder = "연동 앱 이름/ID로 검색..."
|
|||||||
tenant_scoped = "Tenant-scoped"
|
tenant_scoped = "Tenant-scoped"
|
||||||
untitled = "Untitled"
|
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]
|
[ui.dev.clients.badge]
|
||||||
admin_session = "관리자 세션"
|
admin_session = "관리자 세션"
|
||||||
dev_session = "DevFront 세션"
|
dev_session = "DevFront 세션"
|
||||||
@@ -1632,25 +1664,9 @@ permits_info = "이 RP에서 발생한 모든 설정 변경 및 운영 작업에
|
|||||||
label = "상태 변경"
|
label = "상태 변경"
|
||||||
description = "RP 활성/비활성 상태를 변경합니다."
|
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]
|
[ui.dev.clients.list]
|
||||||
title = "연동 앱 목록"
|
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]
|
[ui.dev.clients.registry]
|
||||||
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||||
subtitle = "연동 앱"
|
subtitle = "연동 앱"
|
||||||
|
|||||||
@@ -549,6 +549,10 @@ access_pending = ""
|
|||||||
access_pending_detail = ""
|
access_pending_detail = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
|
||||||
|
[msg.dev.audit]
|
||||||
|
access_denied = ""
|
||||||
|
access_denied_detail = ""
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = ""
|
body = ""
|
||||||
title_emphasis = ""
|
title_emphasis = ""
|
||||||
@@ -1421,6 +1425,34 @@ search_placeholder = ""
|
|||||||
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 = ""
|
||||||
|
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]
|
[ui.dev.clients.badge]
|
||||||
admin_session = ""
|
admin_session = ""
|
||||||
dev_session = ""
|
dev_session = ""
|
||||||
@@ -1689,25 +1721,9 @@ label = ""
|
|||||||
description = ""
|
description = ""
|
||||||
permits_info = ""
|
permits_info = ""
|
||||||
|
|
||||||
[ui.dev.clients.help]
|
|
||||||
docs_body = ""
|
|
||||||
docs_title = ""
|
|
||||||
subtitle = ""
|
|
||||||
title = ""
|
|
||||||
view_guides = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.list]
|
[ui.dev.clients.list]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.dev.clients.owner]
|
|
||||||
avatar_alt = ""
|
|
||||||
email = ""
|
|
||||||
name = ""
|
|
||||||
role = ""
|
|
||||||
scope = ""
|
|
||||||
subtitle = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.registry]
|
[ui.dev.clients.registry]
|
||||||
description = ""
|
description = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
|
type DevAssignableUser,
|
||||||
|
type AuditLog,
|
||||||
type Consent,
|
type Consent,
|
||||||
installDevApiMock,
|
installDevApiMock,
|
||||||
makeClient,
|
makeClient,
|
||||||
@@ -14,7 +16,7 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clients page loads correctly", async ({ page }) => {
|
test("clients page loads correctly", async ({ page }) => {
|
||||||
await seedAuth(page);
|
await seedAuth(page, "super_admin");
|
||||||
await installDevApiMock(page, {
|
await installDevApiMock(page, {
|
||||||
clients: [
|
clients: [
|
||||||
makeClient("client-playwright", {
|
makeClient("client-playwright", {
|
||||||
@@ -44,3 +46,170 @@ test("clients page loads correctly", async ({ page }) => {
|
|||||||
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
|
page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }),
|
||||||
).toBeVisible();
|
).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);
|
.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 ({
|
test("user can enter and sees empty RP list", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await seedAuth(page, "user");
|
await seedAuth(page, "user");
|
||||||
await installDevApiMock(page, {
|
await installDevApiMock(page, {
|
||||||
clients: [],
|
clients: [],
|
||||||
@@ -39,6 +37,69 @@ test.describe("DevFront role report", () => {
|
|||||||
await captureEvidence(page, testInfo, "role-user-empty-rps");
|
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 ({
|
test("rp_admin sees only assigned Gitea app and its logs", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -66,8 +127,12 @@ test.describe("DevFront role report", () => {
|
|||||||
await installDevApiMock(page, state);
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
await page.goto("/clients");
|
await page.goto("/clients");
|
||||||
await expect(page.getByRole("link", { name: /Gitea/ })).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByText("gitea-client")).toBeVisible();
|
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 captureEvidence(page, testInfo, "role-rp-admin-clients");
|
||||||
|
|
||||||
await page.goto("/audit-logs");
|
await page.goto("/audit-logs");
|
||||||
|
|||||||
@@ -137,4 +137,86 @@ test.describe("DevFront security and isolation", () => {
|
|||||||
page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i),
|
page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i),
|
||||||
).toBeVisible();
|
).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(
|
await page.addInitScript(
|
||||||
({ issuedAt, injectedRole }) => {
|
({ issuedAt, injectedRole }) => {
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
|
||||||
const mockOidcUser = {
|
const mockOidcUser = {
|
||||||
id_token: "playwright-id-token",
|
id_token: "playwright-id-token",
|
||||||
session_state: "playwright-session",
|
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") {
|
if (pathname === "/api/v1/dev/clients" && method === "GET") {
|
||||||
return json(route, {
|
return json(route, {
|
||||||
items: state.clients.map((client) => ({
|
items: state.clients.map((client) => ({
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ unknown_error = "unknown error"
|
|||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
[msg.dev.audit]
|
[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."
|
empty = "No audit logs found."
|
||||||
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
||||||
load_error = "Error loading audit logs: {{error}}"
|
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_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."
|
approved_remote = "Your requested sign-in is complete."
|
||||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||||
|
close_hint = "You can close this window now."
|
||||||
success = "Sign-in approval completed."
|
success = "Sign-in approval completed."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -2530,10 +2533,10 @@ title = "Account not found"
|
|||||||
action_label = "Done"
|
action_label = "Done"
|
||||||
action_label_remote = "Go to sign-in window"
|
action_label_remote = "Go to sign-in window"
|
||||||
action_label_close = "Close Window"
|
action_label_close = "Close Window"
|
||||||
page_title = "Sign-in approval"
|
page_title = "Baron SW Portal"
|
||||||
title = "Approval complete"
|
title = "Approval complete"
|
||||||
title_pending = "Checking approval"
|
title_pending = "Checking approval"
|
||||||
title_remote = "Sign-in approved"
|
title_remote = "Sign-in Approved"
|
||||||
|
|
||||||
[ui.shell.nav]
|
[ui.shell.nav]
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
|
|||||||
title = "{{resource}} 접근 권한 없음"
|
title = "{{resource}} 접근 권한 없음"
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
|
access_denied = "감사 로그는 개발자 권한이 있어야 볼 수 있습니다."
|
||||||
|
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
|
||||||
empty = "조회된 감사 로그가 없습니다."
|
empty = "조회된 감사 로그가 없습니다."
|
||||||
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
||||||
load_error = "감사 로그 조회 실패: {{error}}"
|
load_error = "감사 로그 조회 실패: {{error}}"
|
||||||
@@ -1298,6 +1300,7 @@ approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩
|
|||||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||||
approved_remote = "요청하신 로그인이 완료되었습니다"
|
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||||
|
close_hint = "이 창은 이제 닫으셔도 됩니다."
|
||||||
success = "로그인 승인에 성공했습니다."
|
success = "로그인 승인에 성공했습니다."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -2954,7 +2957,7 @@ title = "미등록 회원"
|
|||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "확인"
|
action_label = "확인"
|
||||||
action_label_remote = "로그인 창으로 이동하기"
|
action_label_remote = "로그인 창으로 이동하기"
|
||||||
page_title = "로그인 승인"
|
page_title = "Baron SW 포탈"
|
||||||
title = "승인 완료"
|
title = "승인 완료"
|
||||||
action_label_close = "창 닫기"
|
action_label_close = "창 닫기"
|
||||||
title_pending = "로그인 승인 확인 중"
|
title_pending = "로그인 승인 확인 중"
|
||||||
|
|||||||
@@ -734,6 +734,8 @@ unknown_error = ""
|
|||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
|
access_denied = ""
|
||||||
|
access_denied_detail = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
forbidden = ""
|
forbidden = ""
|
||||||
load_error = ""
|
load_error = ""
|
||||||
@@ -1158,6 +1160,7 @@ approved = ""
|
|||||||
approved_local = ""
|
approved_local = ""
|
||||||
approved_remote = ""
|
approved_remote = ""
|
||||||
pending_remote = ""
|
pending_remote = ""
|
||||||
|
close_hint = ""
|
||||||
success = ""
|
success = ""
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"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"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"install:browsers": "playwright install firefox",
|
||||||
"test:ui": "playwright test --ui",
|
"test": "npm run install:browsers && playwright test",
|
||||||
|
"test:ui": "npm run install:browsers && playwright test --ui",
|
||||||
"serve:build": "node ./scripts/serve-userfront-build.mjs",
|
"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",
|
"build:userfront:wasm": "cd ../userfront && flutter build web --wasm --release && cd .. && node userfront/scripts/optimize-web-build.mjs userfront/build/web",
|
||||||
"lint": "biome check .",
|
"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;
|
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> {
|
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.close = () => {
|
window.close = () => {
|
||||||
@@ -180,20 +189,19 @@ async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickVerificationAction(page: Page): Promise<void> {
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(300);
|
||||||
if (page.isClosed() || !page.url().includes("/verify-complete")) {
|
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||||
return;
|
const placeholder = page.locator("flt-semantics-placeholder").first();
|
||||||
}
|
|
||||||
|
|
||||||
const viewport = page.viewportSize();
|
await button.click({ force: true, timeout: 1_000 }).catch(async () => {
|
||||||
if (!viewport) {
|
await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => {
|
||||||
throw new Error("Viewport size was not available.");
|
await placeholder.evaluate((node) => {
|
||||||
}
|
(node as HTMLElement).click();
|
||||||
await page.mouse.click(
|
});
|
||||||
viewport.width / 2,
|
});
|
||||||
Math.min(viewport.height - 24, viewport.height / 2 + 120),
|
});
|
||||||
);
|
await page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("UserFront WASM auth routing", () => {
|
test.describe("UserFront WASM auth routing", () => {
|
||||||
@@ -262,7 +270,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
expect(approvedRef).toBe("e2e-approve-ref");
|
expect(approvedRef).toBe("e2e-approve-ref");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다", async ({
|
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let userMeCalls = 0;
|
let userMeCalls = 0;
|
||||||
@@ -286,8 +294,6 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
await page.goto("/ko/l/AB123456");
|
await page.goto("/ko/l/AB123456");
|
||||||
|
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
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(
|
expect(verifyRequests[0].path).toContain(
|
||||||
"/api/v1/auth/login/code/verify-short",
|
"/api/v1/auth/login/code/verify-short",
|
||||||
);
|
);
|
||||||
@@ -301,10 +307,14 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(300);
|
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 ({
|
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||||
@@ -331,7 +341,8 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
await clickVerificationAction(page);
|
await enableFlutterAccessibility(page);
|
||||||
|
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
@@ -342,7 +353,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다", async ({
|
test('verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let verifyCalls = 0;
|
let verifyCalls = 0;
|
||||||
@@ -360,7 +371,18 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
|
|
||||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
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(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -389,9 +411,10 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
|
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
|
||||||
);
|
);
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
|
||||||
expect(userMeCalls).toBe(0);
|
'/ko/verify-complete',
|
||||||
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({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
loginId: "e2e@example.com",
|
loginId: "e2e@example.com",
|
||||||
code: "654321",
|
code: "654321",
|
||||||
@@ -427,8 +450,9 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
|
|
||||||
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
|
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
|
||||||
expect(userMeCalls).toBe(0);
|
'/ko/verify-complete',
|
||||||
|
);
|
||||||
expect(verifyRequests[0].body).toMatchObject({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
loginId: "e2e@example.com",
|
loginId: "e2e@example.com",
|
||||||
code: "999999",
|
code: "999999",
|
||||||
@@ -481,7 +505,10 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
||||||
try {
|
try {
|
||||||
await clickVerificationAction(popup);
|
await enableFlutterAccessibility(popup);
|
||||||
|
await popup
|
||||||
|
.getByRole("button", { name: "로그인 창으로 이동하기" })
|
||||||
|
.click();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -519,15 +546,14 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
await page.goto("/ko/verify/e2e-email-token");
|
await page.goto("/ko/verify/e2e-email-token");
|
||||||
|
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
|
||||||
expect(userMeCalls).toBe(0);
|
|
||||||
expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify");
|
|
||||||
expect(verifyRequests[0].body).toMatchObject({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
token: "e2e-email-token",
|
token: "e2e-email-token",
|
||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickVerificationAction(page);
|
await enableFlutterAccessibility(page);
|
||||||
|
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
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.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||||
expect(userMeCalls).toBe(0);
|
|
||||||
expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify");
|
|
||||||
expect(verifyRequests[0].body).toMatchObject({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
loginId: "e2e@example.com",
|
loginId: "e2e@example.com",
|
||||||
code: "654321",
|
code: "654321",
|
||||||
@@ -570,7 +594,8 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickVerificationAction(page);
|
await enableFlutterAccessibility(page);
|
||||||
|
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
|
|||||||
@@ -279,6 +279,5 @@ test.describe("UserFront login performance budget", () => {
|
|||||||
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
|
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
|
||||||
);
|
);
|
||||||
expect(rootIndex).toBeGreaterThanOrEqual(0);
|
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 ({
|
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const logs: string[] = [];
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
const text = msg.text();
|
|
||||||
logs.push(text);
|
|
||||||
console.log(`[Browser] ${text}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.isNavigationRequest()) {
|
if (request.isNavigationRequest()) {
|
||||||
@@ -70,16 +63,8 @@ test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
|
|||||||
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
|
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
|
||||||
expect(signinNavigations.length).toBeLessThanOrEqual(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(
|
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_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."
|
approved_remote = "Your requested sign-in is complete."
|
||||||
pending_remote = "Checking the sign-in approval request. Please wait."
|
pending_remote = "Checking the sign-in approval request. Please wait."
|
||||||
|
close_hint = "You can close this window now."
|
||||||
success = "Sign-in approval completed."
|
success = "Sign-in approval completed."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -584,10 +585,10 @@ title = "Account not found"
|
|||||||
action_label = "Done"
|
action_label = "Done"
|
||||||
action_label_remote = "Go to sign-in window"
|
action_label_remote = "Go to sign-in window"
|
||||||
action_label_close = "Close Window"
|
action_label_close = "Close Window"
|
||||||
page_title = "Sign-in approval"
|
page_title = "Baron SW Portal"
|
||||||
title = "Approval complete"
|
title = "Approval complete"
|
||||||
title_pending = "Checking approval"
|
title_pending = "Checking approval"
|
||||||
title_remote = "Sign-in approved"
|
title_remote = "Sign-in Approved"
|
||||||
|
|
||||||
[ui.userfront.login_success]
|
[ui.userfront.login_success]
|
||||||
later = "Do this later (go to dashboard)"
|
later = "Do this later (go to dashboard)"
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩
|
|||||||
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
|
||||||
approved_remote = "요청하신 로그인이 완료되었습니다"
|
approved_remote = "요청하신 로그인이 완료되었습니다"
|
||||||
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
|
||||||
|
close_hint = "이 창은 이제 닫으셔도 됩니다."
|
||||||
success = "로그인 승인에 성공했습니다."
|
success = "로그인 승인에 성공했습니다."
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
@@ -805,7 +806,7 @@ title = "미등록 회원"
|
|||||||
[ui.userfront.login.verification]
|
[ui.userfront.login.verification]
|
||||||
action_label = "확인"
|
action_label = "확인"
|
||||||
action_label_remote = "로그인 창으로 이동하기"
|
action_label_remote = "로그인 창으로 이동하기"
|
||||||
page_title = "로그인 승인"
|
page_title = "Baron SW 포탈"
|
||||||
title = "승인 완료"
|
title = "승인 완료"
|
||||||
action_label_close = "창 닫기"
|
action_label_close = "창 닫기"
|
||||||
title_pending = "로그인 승인 확인 중"
|
title_pending = "로그인 승인 확인 중"
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ approved = ""
|
|||||||
approved_local = ""
|
approved_local = ""
|
||||||
approved_remote = ""
|
approved_remote = ""
|
||||||
pending_remote = ""
|
pending_remote = ""
|
||||||
|
close_hint = ""
|
||||||
success = ""
|
success = ""
|
||||||
|
|
||||||
[msg.userfront.login_success]
|
[msg.userfront.login_success]
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ class AuthTokenStore {
|
|||||||
authTokenStore.setPendingProvider(null);
|
authTokenStore.setPendingProvider(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void skipNextCookieSessionCheck() {
|
||||||
|
authTokenStore.skipNextCookieSessionCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool consumeSkipCookieSessionCheck() {
|
||||||
|
return authTokenStore.consumeSkipCookieSessionCheck();
|
||||||
|
}
|
||||||
|
|
||||||
static void clear() {
|
static void clear() {
|
||||||
authTokenStore.clear();
|
authTokenStore.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class AuthTokenStoreBackend {
|
|||||||
static const _providerKey = 'baron_auth_provider';
|
static const _providerKey = 'baron_auth_provider';
|
||||||
static const _cookieModeKey = 'baron_auth_cookie_mode';
|
static const _cookieModeKey = 'baron_auth_cookie_mode';
|
||||||
static const _pendingProviderKey = 'baron_auth_pending_provider';
|
static const _pendingProviderKey = 'baron_auth_pending_provider';
|
||||||
|
static const _skipCookieSessionCheckKey =
|
||||||
|
'baron_auth_skip_cookie_session_check';
|
||||||
|
|
||||||
final List<AuthTokenStorageTarget> _targets;
|
final List<AuthTokenStorageTarget> _targets;
|
||||||
|
|
||||||
@@ -41,6 +43,14 @@ class AuthTokenStoreBackend {
|
|||||||
|
|
||||||
String? getPendingProvider() => _readFirst(_pendingProviderKey);
|
String? getPendingProvider() => _readFirst(_pendingProviderKey);
|
||||||
|
|
||||||
|
bool consumeSkipCookieSessionCheck() {
|
||||||
|
final shouldSkip = _readFirst(_skipCookieSessionCheckKey) == '1';
|
||||||
|
if (shouldSkip) {
|
||||||
|
_removeAll(_skipCookieSessionCheckKey);
|
||||||
|
}
|
||||||
|
return shouldSkip;
|
||||||
|
}
|
||||||
|
|
||||||
void setPendingProvider(String? provider) {
|
void setPendingProvider(String? provider) {
|
||||||
if (provider == null || provider.isEmpty) {
|
if (provider == null || provider.isEmpty) {
|
||||||
_removeAll(_pendingProviderKey);
|
_removeAll(_pendingProviderKey);
|
||||||
@@ -54,6 +64,11 @@ class AuthTokenStoreBackend {
|
|||||||
_removeAll(_providerKey);
|
_removeAll(_providerKey);
|
||||||
_removeAll(_cookieModeKey);
|
_removeAll(_cookieModeKey);
|
||||||
_removeAll(_pendingProviderKey);
|
_removeAll(_pendingProviderKey);
|
||||||
|
_removeAll(_skipCookieSessionCheckKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipNextCookieSessionCheck() {
|
||||||
|
_writeAll(_skipCookieSessionCheckKey, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _readFirst(String key) {
|
String? _readFirst(String key) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ class AuthTokenStore {
|
|||||||
String? _provider;
|
String? _provider;
|
||||||
bool _cookieMode = false;
|
bool _cookieMode = false;
|
||||||
String? _pendingProvider;
|
String? _pendingProvider;
|
||||||
|
bool _skipCookieSessionCheck = false;
|
||||||
|
|
||||||
String? getToken() => _token;
|
String? getToken() => _token;
|
||||||
|
|
||||||
@@ -26,15 +27,26 @@ class AuthTokenStore {
|
|||||||
|
|
||||||
String? getPendingProvider() => _pendingProvider;
|
String? getPendingProvider() => _pendingProvider;
|
||||||
|
|
||||||
|
bool consumeSkipCookieSessionCheck() {
|
||||||
|
final shouldSkip = _skipCookieSessionCheck;
|
||||||
|
_skipCookieSessionCheck = false;
|
||||||
|
return shouldSkip;
|
||||||
|
}
|
||||||
|
|
||||||
void setPendingProvider(String? provider) {
|
void setPendingProvider(String? provider) {
|
||||||
_pendingProvider = provider;
|
_pendingProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void skipNextCookieSessionCheck() {
|
||||||
|
_skipCookieSessionCheck = true;
|
||||||
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_token = null;
|
_token = null;
|
||||||
_provider = null;
|
_provider = null;
|
||||||
_cookieMode = false;
|
_cookieMode = false;
|
||||||
_pendingProvider = null;
|
_pendingProvider = null;
|
||||||
|
_skipCookieSessionCheck = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,14 +79,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _verificationApproved = false;
|
bool _verificationApproved = false;
|
||||||
bool _dismissedOverlays = false;
|
bool _dismissedOverlays = false;
|
||||||
bool _localNavigationCompleted = false;
|
bool _localNavigationCompleted = false;
|
||||||
String _verificationMessage = '';
|
String? _verificationMessageKey;
|
||||||
String _verificationTitle = tr('ui.userfront.login.verification.title');
|
String _verificationTitleKey = 'ui.userfront.login.verification.title';
|
||||||
String _verificationPageTitle = tr(
|
String _verificationPageTitleKey =
|
||||||
'ui.userfront.login.verification.page_title',
|
'ui.userfront.login.verification.page_title';
|
||||||
);
|
String _verificationActionLabelKey =
|
||||||
String _verificationActionLabel = tr(
|
'ui.userfront.login.verification.action_label';
|
||||||
'ui.userfront.login.verification.action_label',
|
|
||||||
);
|
|
||||||
Timer? _verificationRedirectTimer;
|
Timer? _verificationRedirectTimer;
|
||||||
bool _noticeHandled = false;
|
bool _noticeHandled = false;
|
||||||
bool _drySendEnabled = false;
|
bool _drySendEnabled = false;
|
||||||
@@ -144,11 +142,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (widget.verificationCompleteOnly) {
|
if (widget.verificationCompleteOnly) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
tr('msg.userfront.login.verification.approved_remote'),
|
'msg.userfront.login.verification.approved_remote',
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
titleKey: 'ui.userfront.login.verification.title_remote',
|
||||||
actionLabel: tr(
|
actionLabelKey: 'ui.userfront.login.verification.action_label_remote',
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -286,6 +282,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
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 loginChallenge = _loginChallenge;
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
if (!shouldPromoteCookieSession(
|
if (!shouldPromoteCookieSession(
|
||||||
@@ -805,35 +807,38 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
final localeCode =
|
final localeCode =
|
||||||
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
||||||
context.go(buildLocalizedVerificationCompletePath(localeCode));
|
final target = buildLocalizedVerificationCompletePath(localeCode);
|
||||||
|
if (mounted) {
|
||||||
|
context.go(target);
|
||||||
|
} else {
|
||||||
|
webWindow.redirectTo(target);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _markVerificationApproved(
|
void _markVerificationApproved(
|
||||||
String message, {
|
String messageKey, {
|
||||||
String? title,
|
String? titleKey,
|
||||||
String? pageTitle,
|
String? pageTitleKey,
|
||||||
String? actionLabel,
|
String? actionLabelKey,
|
||||||
String actionPath = '/',
|
String actionPath = '/',
|
||||||
bool autoRedirect = false,
|
bool autoRedirect = false,
|
||||||
Duration redirectDelay = const Duration(seconds: 2),
|
Duration redirectDelay = const Duration(seconds: 2),
|
||||||
VoidCallback? onAction,
|
VoidCallback? onAction,
|
||||||
}) {
|
}) {
|
||||||
if (!mounted) return;
|
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()) {
|
if (_moveVerificationOnlyResultToCleanRoute()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessageKey = messageKey;
|
||||||
_verificationTitle = resolvedTitle;
|
_verificationTitleKey =
|
||||||
_verificationPageTitle = resolvedPageTitle;
|
titleKey ?? 'ui.userfront.login.verification.title';
|
||||||
_verificationActionLabel = resolvedActionLabel;
|
_verificationPageTitleKey =
|
||||||
|
pageTitleKey ?? 'ui.userfront.login.verification.page_title';
|
||||||
|
_verificationActionLabelKey =
|
||||||
|
actionLabelKey ?? 'ui.userfront.login.verification.action_label';
|
||||||
_onVerificationAction = onAction;
|
_onVerificationAction = onAction;
|
||||||
});
|
});
|
||||||
_verificationRedirectTimer?.cancel();
|
_verificationRedirectTimer?.cancel();
|
||||||
@@ -856,9 +861,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _closeVerificationWindowIfPossible() {
|
void _closeVerificationWindowIfPossible() {
|
||||||
if (webWindow.hasOpener()) {
|
webWindow.close();
|
||||||
webWindow.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _moveToSigninOrCloseVerificationWindow() {
|
void _moveToSigninOrCloseVerificationWindow() {
|
||||||
@@ -866,84 +869,198 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
webWindow.close();
|
webWindow.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
AuthTokenStore.skipNextCookieSessionCheck();
|
||||||
context.go(buildLocalizedSigninPath(Uri.base));
|
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() {
|
Widget _buildVerificationResultView() {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final theme = Theme.of(context);
|
||||||
return Center(
|
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(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
child: ConstrainedBox(
|
child: Center(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
child: ConstrainedBox(
|
||||||
child: Material(
|
constraints: const BoxConstraints(maxWidth: 468),
|
||||||
color: colorScheme.surface,
|
child: LayoutBuilder(
|
||||||
elevation: 12,
|
builder: (context, constraints) {
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.18),
|
final isCompact = constraints.maxWidth < 360;
|
||||||
borderRadius: BorderRadius.circular(24),
|
final iconBoxSize = isCompact ? 76.0 : 88.0;
|
||||||
child: Padding(
|
final iconSize = isCompact ? 48.0 : 56.0;
|
||||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
final verticalGap = isCompact ? 16.0 : 20.0;
|
||||||
child: Column(
|
final controlsOffset = isCompact ? 150.0 : 170.0;
|
||||||
mainAxisSize: MainAxisSize.min,
|
final cardRadius = isCompact ? 24.0 : 28.0;
|
||||||
children: [
|
final cardPadding = isCompact
|
||||||
Icon(
|
? const EdgeInsets.fromLTRB(20, 24, 20, 22)
|
||||||
Icons.check_circle_outline,
|
: const EdgeInsets.fromLTRB(30, 34, 30, 28);
|
||||||
color: colorScheme.primary,
|
|
||||||
size: 72,
|
return Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
Text(
|
SizedBox(height: controlsOffset),
|
||||||
_verificationTitle,
|
DecoratedBox(
|
||||||
textAlign: TextAlign.center,
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: colorScheme.surface,
|
||||||
fontSize: 22,
|
borderRadius: BorderRadius.circular(cardRadius),
|
||||||
fontWeight: FontWeight.bold,
|
border: Border.all(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.outlineVariant.withValues(
|
||||||
),
|
alpha: 0.7,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Text(
|
boxShadow: [
|
||||||
_verificationMessage.isEmpty
|
BoxShadow(
|
||||||
? tr('msg.userfront.login.verification.success')
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||||
: _verificationMessage,
|
blurRadius: 28,
|
||||||
textAlign: TextAlign.center,
|
offset: const Offset(0, 16),
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
SizedBox(
|
child: Padding(
|
||||||
width: double.infinity,
|
padding: cardPadding,
|
||||||
child: FilledButton(
|
child: Column(
|
||||||
onPressed: () {
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (_onVerificationAction != null) {
|
children: [
|
||||||
_runVerificationExitAction();
|
Container(
|
||||||
return;
|
width: iconBoxSize,
|
||||||
}
|
height: iconBoxSize,
|
||||||
if (_verificationOnly) {
|
decoration: BoxDecoration(
|
||||||
_closeVerificationWindowIfPossible();
|
shape: BoxShape.circle,
|
||||||
return;
|
gradient: LinearGradient(
|
||||||
}
|
colors: [
|
||||||
final hasLocalSession =
|
colorScheme.primary.withValues(alpha: 0.18),
|
||||||
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
|
colorScheme.tertiary.withValues(
|
||||||
AuthTokenStore.usesCookie();
|
alpha: 0.14,
|
||||||
final target = hasLocalSession
|
),
|
||||||
? buildLocalizedHomePath(Uri.base)
|
],
|
||||||
: buildLocalizedSigninPath(Uri.base);
|
begin: Alignment.topLeft,
|
||||||
if (mounted) {
|
end: Alignment.bottomRight,
|
||||||
setState(() {
|
),
|
||||||
_verificationOnly = false;
|
),
|
||||||
_verificationApproved = false;
|
alignment: Alignment.center,
|
||||||
});
|
child: Icon(
|
||||||
}
|
Icons.person,
|
||||||
context.go(target);
|
size: iconSize,
|
||||||
},
|
color: colorScheme.primary,
|
||||||
child: Text(
|
),
|
||||||
_verificationActionLabel,
|
),
|
||||||
textAlign: TextAlign.center,
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
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)],
|
actions: const [ThemeToggleButton(compact: true)],
|
||||||
),
|
),
|
||||||
body: _verificationApproved
|
body: _verificationApproved
|
||||||
@@ -996,13 +1119,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
Future<void> _verifyToken(String token) async {
|
Future<void> _verifyToken(String token) async {
|
||||||
debugPrint("[Auth] Starting verification for token: $token");
|
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 {
|
try {
|
||||||
// Use Backend to verify the token (Backend-Driven Flow)
|
// Use Backend to verify the token (Backend-Driven Flow)
|
||||||
final res = await AuthProxyService.verifyMagicLink(
|
final res = await AuthProxyService.verifyMagicLink(
|
||||||
@@ -1019,22 +1135,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
|
if (_verificationOnly) {
|
||||||
|
_markRemoteVerificationApproved();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
localSessionMessage,
|
'msg.userfront.login.verification.approved_local',
|
||||||
actionPath: actionPath,
|
actionPath: actionPath,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1044,7 +1157,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
_markVerificationApproved(
|
||||||
|
'msg.userfront.login.verification.approved',
|
||||||
|
actionPath: actionPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $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('already_verified') ||
|
||||||
errorStr.contains('session_active')) {
|
errorStr.contains('session_active')) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1087,12 +1196,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
"[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 {
|
try {
|
||||||
final res = await AuthProxyService.verifyLoginCode(
|
final res = await AuthProxyService.verifyLoginCode(
|
||||||
sanitizedLoginId,
|
sanitizedLoginId,
|
||||||
@@ -1112,14 +1215,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1127,20 +1223,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
localSessionMessage,
|
'msg.userfront.login.verification.approved_local',
|
||||||
actionPath: actionPath,
|
actionPath: actionPath,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
||||||
@@ -1148,14 +1237,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1168,14 +1250,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
errorStr.contains('already_verified') ||
|
errorStr.contains('already_verified') ||
|
||||||
errorStr.contains('session_active')) {
|
errorStr.contains('session_active')) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1195,12 +1270,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final sanitized = shortCode.trim().toUpperCase();
|
final sanitized = shortCode.trim().toUpperCase();
|
||||||
if (sanitized.isEmpty) return;
|
if (sanitized.isEmpty) return;
|
||||||
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
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 {
|
try {
|
||||||
final res = await AuthProxyService.verifyLoginShortCode(
|
final res = await AuthProxyService.verifyLoginShortCode(
|
||||||
sanitized,
|
sanitized,
|
||||||
@@ -1216,14 +1285,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1231,20 +1293,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
localSessionMessage,
|
'msg.userfront.login.verification.approved_local',
|
||||||
actionPath: actionPath,
|
actionPath: actionPath,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
||||||
@@ -1252,14 +1307,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||||
@@ -1270,14 +1318,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
errorStr.contains('already_verified') ||
|
errorStr.contains('already_verified') ||
|
||||||
errorStr.contains('session_active')) {
|
errorStr.contains('session_active')) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markRemoteVerificationApproved();
|
||||||
remoteApprovedMessage,
|
|
||||||
title: tr('ui.userfront.login.verification.title_remote'),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.verification.action_label_remote',
|
|
||||||
),
|
|
||||||
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ class SignupScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SignupScreenState extends State<SignupScreen> {
|
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 _agreementDesktopBreakpoint = 1024.0;
|
||||||
static const _agreementCardMaxWidth = 880.0;
|
static const _agreementCardMaxWidth = 880.0;
|
||||||
static const _stepIndicatorDesktopBreakpoint = 1024.0;
|
static const _stepIndicatorDesktopBreakpoint = 1024.0;
|
||||||
@@ -69,6 +63,64 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Timer? _phoneTimer;
|
Timer? _phoneTimer;
|
||||||
int _phoneSeconds = 0;
|
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 _renderTranslatedText(
|
||||||
String key, {
|
String key, {
|
||||||
String? fallback,
|
String? fallback,
|
||||||
@@ -454,7 +506,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final circleRadius = isDesktop ? 17.0 : 12.0;
|
final circleRadius = isDesktop ? 17.0 : 12.0;
|
||||||
final labelStyle = TextStyle(
|
final labelStyle = TextStyle(
|
||||||
fontSize: isDesktop ? 12 : 9,
|
fontSize: isDesktop ? 12 : 9,
|
||||||
color: isCurrent ? _signupInk : (isDone ? _signupDone : _signupMuted),
|
color: isCurrent ? _signupAccent : (isDone ? _signupDone : _signupMuted),
|
||||||
fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
);
|
);
|
||||||
@@ -469,13 +521,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDone
|
color: isDone
|
||||||
? _signupDone
|
? _signupDone
|
||||||
: (isCurrent ? _signupInk : _signupSurface),
|
: (isCurrent ? _signupAccent : _signupSurface),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isDone
|
color: isDone
|
||||||
? _signupDone
|
? _signupDone
|
||||||
: (isCurrent ? _signupInk : _signupBorder),
|
: (isCurrent ? _signupAccent : _signupBorder),
|
||||||
width: isCurrent ? 1.5 : 1,
|
width: isDone ? 0 : (isCurrent ? 1.5 : 1),
|
||||||
),
|
),
|
||||||
boxShadow: isDesktop && (isCurrent || isDone)
|
boxShadow: isDesktop && (isCurrent || isDone)
|
||||||
? const [
|
? const [
|
||||||
@@ -497,7 +549,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
: Text(
|
: Text(
|
||||||
'$step',
|
'$step',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isCurrent ? Colors.white : _signupMuted,
|
color: isCurrent ? _signupOnAccent : _signupMuted,
|
||||||
fontSize: isDesktop ? 13 : 10,
|
fontSize: isDesktop ? 13 : 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -564,9 +616,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
),
|
),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
boxShadow: isDesktop
|
boxShadow: isDesktop
|
||||||
? const [
|
? const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -647,58 +699,60 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _signupSurface,
|
color: _signupSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
||||||
child: Column(
|
child: CheckboxTheme(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
data: _signupCheckboxTheme,
|
||||||
children: [
|
child: Column(
|
||||||
CheckboxListTile(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
title: Text(
|
children: [
|
||||||
tr('ui.userfront.signup.agreement.all'),
|
CheckboxListTile(
|
||||||
style: TextStyle(
|
title: Text(
|
||||||
fontSize: isDesktop ? 17 : 15,
|
tr('ui.userfront.signup.agreement.all'),
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
color: _signupInk,
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,
|
const SizedBox(height: 8),
|
||||||
onChanged: (val) {
|
Text(
|
||||||
setState(() {
|
tr(
|
||||||
final next = val ?? false;
|
'msg.userfront.signup.agreement.progress',
|
||||||
_termsAccepted = next;
|
params: {'count': '$acceptedCount', 'total': '2'},
|
||||||
_privacyAccepted = next;
|
),
|
||||||
});
|
style: TextStyle(
|
||||||
},
|
fontSize: 12,
|
||||||
contentPadding: EdgeInsets.zero,
|
fontWeight: FontWeight.w600,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
color: _signupMuted,
|
||||||
activeColor: _signupInk,
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.agreement.progress',
|
|
||||||
params: {'count': '$acceptedCount', 'total': '2'},
|
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
],
|
||||||
fontSize: 12,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _signupMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -716,72 +770,74 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
|
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
||||||
child: Column(
|
child: CheckboxTheme(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
data: _signupCheckboxTheme,
|
||||||
children: [
|
child: Column(
|
||||||
Row(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: CheckboxListTile(
|
children: [
|
||||||
title: Text(
|
Expanded(
|
||||||
title,
|
child: CheckboxListTile(
|
||||||
style: TextStyle(
|
title: Text(
|
||||||
fontSize: isDesktop ? 16 : 14,
|
title,
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
color: _signupInk,
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
const SizedBox(width: 12),
|
_buildRequiredBadge(),
|
||||||
_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),
|
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
const SizedBox(height: 12),
|
||||||
child: SelectableText(
|
Container(
|
||||||
content,
|
height: contentHeight,
|
||||||
style: TextStyle(
|
width: double.infinity,
|
||||||
fontSize: isDesktop ? 13 : 12,
|
padding: EdgeInsets.all(isDesktop ? 18 : 14),
|
||||||
color: _signupMuted,
|
decoration: BoxDecoration(
|
||||||
height: 1.6,
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEEF2FF),
|
color: _signupRequiredSurface,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: const Color(0xFFC7D2FE)),
|
border: Border.all(color: _signupRequiredBorder),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.signup.agreement.required'),
|
tr('ui.userfront.signup.agreement.required'),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
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(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupBorder),
|
||||||
boxShadow: isDesktop
|
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}) {
|
Widget _buildAuthNoticeCard({required bool isDesktop}) {
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: _signupSurface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: _signupBorder),
|
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,
|
width: isDesktop ? 36 : 32,
|
||||||
height: isDesktop ? 36 : 32,
|
height: isDesktop ? 36 : 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFDBEAFE),
|
color: _signupAccentSurface,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.info_outline,
|
Icons.info_outline,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF1D4ED8),
|
color: _signupAccentInk,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -1360,7 +1416,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: isDesktop ? 14 : 12,
|
fontSize: isDesktop ? 14 : 12,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
color: const Color(0xFF1E3A8A),
|
color: _signupAccent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1394,9 +1450,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
}) {
|
}) {
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
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,
|
width: 108,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: buttonEnabled ? onRequestCode : null,
|
onPressed: buttonEnabled ? onRequestCode : null,
|
||||||
style: FilledButton.styleFrom(
|
style: _signupPrimaryButtonStyle,
|
||||||
backgroundColor: _signupInk,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
disabledBackgroundColor: const Color(0xFFE5E7EB),
|
|
||||||
disabledForegroundColor: const Color(0xFF9CA3AF),
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -1497,12 +1548,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
height: 52,
|
height: 52,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: buttonEnabled ? onRequestCode : null,
|
onPressed: buttonEnabled ? onRequestCode : null,
|
||||||
style: FilledButton.styleFrom(
|
style: _signupPrimaryButtonStyle,
|
||||||
backgroundColor: _signupInk,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
disabledBackgroundColor: const Color(0xFFE5E7EB),
|
|
||||||
disabledForegroundColor: const Color(0xFF9CA3AF),
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -1592,11 +1638,11 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _signupDoneSurface,
|
color: _signupDoneSurface,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: const Color(0xFFA7F3D0)),
|
border: Border.all(color: _signupDone.withValues(alpha: 0.35)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
text.replaceFirst('✅ ', ''),
|
text.replaceFirst('✅ ', ''),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _signupDone,
|
color: _signupDone,
|
||||||
@@ -1611,16 +1657,16 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _signupDoneSurface,
|
color: _signupDoneSurface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFFA7F3D0)),
|
border: Border.all(color: _signupDone.withValues(alpha: 0.35)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle, size: 18, color: _signupDone),
|
Icon(Icons.check_circle, size: 18, color: _signupDone),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text.replaceFirst('✅ ', ''),
|
text.replaceFirst('✅ ', ''),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: _signupDone,
|
color: _signupDone,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -1647,9 +1693,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
),
|
),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
boxShadow: isDesktop
|
boxShadow: isDesktop
|
||||||
? const [
|
? const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -1820,13 +1866,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
width: isDesktop ? 36 : 32,
|
width: isDesktop ? 36 : 32,
|
||||||
height: isDesktop ? 36 : 32,
|
height: isDesktop ? 36 : 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEEF2FF),
|
color: _signupAccentSurface,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.badge_outlined,
|
Icons.badge_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF4338CA),
|
color: _signupAccentInk,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -1836,7 +1882,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: isDesktop ? 14 : 12,
|
fontSize: isDesktop ? 14 : 12,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
color: _signupInk,
|
color: _signupAccent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1856,9 +1902,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
}) {
|
}) {
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
color: _signupMuted,
|
color: _signupMuted,
|
||||||
@@ -2018,9 +2064,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
),
|
),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
borderRadius: BorderRadius.circular(isDesktop ? 24 : 18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
boxShadow: isDesktop
|
boxShadow: isDesktop
|
||||||
? const [
|
? const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -2154,13 +2200,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
width: isDesktop ? 36 : 32,
|
width: isDesktop ? 36 : 32,
|
||||||
height: isDesktop ? 36 : 32,
|
height: isDesktop ? 36 : 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFDBEAFE),
|
color: _signupAccentSurface,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.security_rounded,
|
Icons.security_rounded,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Color(0xFF1D4ED8),
|
color: _signupAccentInk,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -2170,7 +2216,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: isDesktop ? 14 : 12,
|
fontSize: isDesktop ? 14 : 12,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
color: const Color(0xFF1E3A8A),
|
color: _signupAccent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2189,9 +2235,9 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
}) {
|
}) {
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _signupCard,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style: const TextStyle(
|
style: TextStyle(fontSize: 13, height: 1.45, color: _signupMuted),
|
||||||
fontSize: 13,
|
|
||||||
height: 1.45,
|
|
||||||
color: _signupMuted,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(height: isDesktop ? 18 : 14),
|
SizedBox(height: isDesktop ? 18 : 14),
|
||||||
child,
|
child,
|
||||||
@@ -2231,7 +2273,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _signupSurface,
|
color: _signupSurface,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: _signupBorder),
|
border: Border.all(color: _signupSummaryBorder),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(isDesktop ? 16 : 14),
|
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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: _scheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.signup.title'),
|
tr('ui.userfront.signup.title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: _signupCard,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: _signupInk,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
shape: Border(bottom: _signupDividerSide),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -2309,14 +2353,8 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
style: OutlinedButton.styleFrom(
|
style: _signupSecondaryButtonStyle,
|
||||||
minimumSize: const Size.fromHeight(55),
|
child: Text(tr('ui.common.prev')),
|
||||||
side: const BorderSide(color: Colors.black),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
tr('ui.common.prev'),
|
|
||||||
style: const TextStyle(color: Colors.black),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -2328,16 +2366,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte
|
|||||||
? () => setState(() => _currentStep++)
|
? () => setState(() => _currentStep++)
|
||||||
: null)
|
: null)
|
||||||
: (_isLoading ? null : _handleSignup),
|
: (_isLoading ? null : _handleSignup),
|
||||||
style: FilledButton.styleFrom(
|
style: _signupPrimaryButtonStyle,
|
||||||
minimumSize: const Size.fromHeight(55),
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: _scheme.onPrimary,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -342,6 +342,8 @@ const Map<String, String> koStrings = {
|
|||||||
"msg.common.requesting": "요청 중...",
|
"msg.common.requesting": "요청 중...",
|
||||||
"msg.common.saving": "저장 중...",
|
"msg.common.saving": "저장 중...",
|
||||||
"msg.common.unknown_error": "알 수 없는 오류",
|
"msg.common.unknown_error": "알 수 없는 오류",
|
||||||
|
"msg.dev.audit.access_denied": "감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||||
|
"msg.dev.audit.access_denied_detail": "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||||
"msg.dev.audit.empty": "조회된 감사 로그가 없습니다.",
|
"msg.dev.audit.empty": "조회된 감사 로그가 없습니다.",
|
||||||
"msg.dev.audit.forbidden": "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.",
|
"msg.dev.audit.forbidden": "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.",
|
||||||
"msg.dev.audit.load_error": "감사 로그 조회 실패: {{error}}",
|
"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_local":
|
||||||
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
||||||
"msg.userfront.login.verification.approved_remote": "요청하신 로그인이 완료되었습니다",
|
"msg.userfront.login.verification.approved_remote": "요청하신 로그인이 완료되었습니다",
|
||||||
|
"msg.userfront.login.verification.close_hint": "이 창은 이제 닫으셔도 됩니다.",
|
||||||
"msg.userfront.login.verification.pending_remote":
|
"msg.userfront.login.verification.pending_remote":
|
||||||
"승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요.",
|
"승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요.",
|
||||||
"msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.",
|
"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": "확인",
|
||||||
"ui.userfront.login.verification.action_label_close": "창 닫기",
|
"ui.userfront.login.verification.action_label_close": "창 닫기",
|
||||||
"ui.userfront.login.verification.action_label_remote": "로그인 창으로 이동하기",
|
"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": "승인 완료",
|
||||||
"ui.userfront.login.verification.title_pending": "로그인 승인 확인 중",
|
"ui.userfront.login.verification.title_pending": "로그인 승인 확인 중",
|
||||||
"ui.userfront.login.verification.title_remote": "로그인 승인 완료",
|
"ui.userfront.login.verification.title_remote": "로그인 승인 완료",
|
||||||
@@ -2692,6 +2695,10 @@ const Map<String, String> enStrings = {
|
|||||||
"msg.common.requesting": "Requesting...",
|
"msg.common.requesting": "Requesting...",
|
||||||
"msg.common.saving": "Saving...",
|
"msg.common.saving": "Saving...",
|
||||||
"msg.common.unknown_error": "unknown error",
|
"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.empty": "No audit logs found.",
|
||||||
"msg.dev.audit.forbidden":
|
"msg.dev.audit.forbidden":
|
||||||
"You do not have permission to view audit logs. Please request access from an administrator.",
|
"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.",
|
"Approved. This device is already signed in, and the remote window will be signed in shortly.",
|
||||||
"msg.userfront.login.verification.approved_remote":
|
"msg.userfront.login.verification.approved_remote":
|
||||||
"Your requested sign-in is complete.",
|
"Your requested sign-in is complete.",
|
||||||
|
"msg.userfront.login.verification.close_hint":
|
||||||
|
"You can close this window now.",
|
||||||
"msg.userfront.login.verification.pending_remote":
|
"msg.userfront.login.verification.pending_remote":
|
||||||
"Checking the sign-in approval request. Please wait.",
|
"Checking the sign-in approval request. Please wait.",
|
||||||
"msg.userfront.login.verification.success": "Sign-in approval completed.",
|
"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": "Done",
|
||||||
"ui.userfront.login.verification.action_label_close": "Close Window",
|
"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.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": "Approval complete",
|
||||||
"ui.userfront.login.verification.title_pending": "Checking approval",
|
"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.later": "Do this later (go to dashboard)",
|
||||||
"ui.userfront.login_success.qr": "Use QR approval",
|
"ui.userfront.login_success.qr": "Use QR approval",
|
||||||
"ui.userfront.login_success.title": "Sign-in complete",
|
"ui.userfront.login_success.title": "Sign-in complete",
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -268,6 +268,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -320,18 +328,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -653,26 +661,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.12"
|
||||||
toml:
|
toml:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user