1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/af-ui

This commit is contained in:
2026-03-24 16:48:56 +09:00
42 changed files with 3353 additions and 1644 deletions

View File

@@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# OIDC 클라이언트 callback (콤마 구분)
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정
ADMINFRONT_URL=http://localhost:5173
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정
VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback

View File

@@ -87,6 +87,8 @@ jobs:
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
@@ -161,14 +163,11 @@ jobs:
docker compose -f staging_pull_compose.yaml pull
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
docker compose -f staging_pull_compose.yaml down || true
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
docker compose -f staging_pull_compose.yaml build --pull
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
docker compose -f staging_pull_compose.yaml up -d --force-recreate init-rp
docker compose -f staging_pull_compose.yaml up -d init-rp
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10

View File

@@ -117,6 +117,10 @@ endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
FLUTTER_TEST_CONCURRENCY ?= 1
code-check: code-check-lint code-check-test-jobs
@echo "code-check complete."
@@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c
code-check-test-jobs:
@echo "==> run CI-equivalent test jobs (parallel)"
@$(MAKE) --no-print-directory -j5 --output-sync=target \
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \
code-check-userfront-tests \
code-check-userfront-e2e-tests \
@@ -203,11 +207,11 @@ code-check-userfront-tests:
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
fi; \
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
cd "$$tmp_dir/userfront" && flutter test
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests:
@echo "==> adminfront tests"
./scripts/run_adminfront_ci_tests.sh adminfront-tests
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
code-check-devfront-tests:
@echo "==> devfront tests"
@@ -219,7 +223,7 @@ code-check-devfront-tests:
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && npm test) || status=$$?; \
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
@@ -267,7 +271,7 @@ code-check-userfront-e2e-tests:
if [ $$status -eq 0 ]; then \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
echo "==> userfront-e2e using PORT=$$port"; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \

View File

@@ -322,6 +322,7 @@ KETO_WRITE_URL = "http://keto:4467"
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
## 🧪 Code Check CI

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -24,7 +28,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -1101,6 +1101,9 @@ components:
type: string
phone:
type: string
sessionAuthenticatedAt:
type: string
format: date-time
department:
type: string
affiliationType:

View File

@@ -68,19 +68,20 @@ type SignupRequest struct {
// User Profile Models
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
}
type UpdateUserRequest struct {

View File

@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = clientID
}
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
lastAuth := time.Time{}
if session.AuthenticatedAt != nil {
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = client.ClientID
}
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
clientURL := resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
@@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
}
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken)
return identityID, traits, err
}
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
return "", nil, "", err
}
req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
return "", nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
Identity struct {
AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
return "", nil, "", err
}
return result.Identity.ID, result.Identity.Traits, nil
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
@@ -4996,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
}
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie)
return identityID, traits, err
}
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
return "", nil, "", err
}
req.Header.Set("Cookie", cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
return "", nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
Identity struct {
AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
return "", nil, "", err
}
return result.Identity.ID, result.Identity.Traits, nil
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
}
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
@@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile
}
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse {
if profile == nil {
return nil
}
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
return profile
}
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
return h.applySessionAuthenticatedAtFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
), nil
}
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
return h.applySessionAuthenticatedAtFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
), nil
}
// UpdateMe - Updates current user's profile with phone verification check
@@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
return ""
}
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
switch strings.TrimSpace(clientID) {
case "adminfront":
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
return value
}
case "devfront":
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
return value
}
}
clientURL := strings.TrimSpace(clientURI)
if clientURL != "" {
return clientURL
}
if len(redirectURIs) > 0 {
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
return ""
}
func mergeScopes(current []string, next []string) []string {
if len(next) == 0 {
return current

View File

@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) {
const (
token = "token-session"
identityID = "user-session"
sessionAuthenticated = "2026-03-23T15:30:00Z"
)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "kratos.test" &&
r.URL.Path == "/sessions/whoami" &&
r.Method == http.MethodGet {
require.Equal(t, token, r.Header.Get("X-Session-Token"))
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "kratos-session-1",
"authenticated_at": sessionAuthenticated,
"identity": map[string]any{
"id": identityID,
"traits": map[string]any{
"email": "qa@example.com",
"name": "QA User",
"department": "Platform",
"affiliationType": "GENERAL",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
setDefaultHTTPClientForTest(t, transport)
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{}
app := fiber.New()
app.Get("/api/v1/user/me", h.GetMe)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
}
func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) {
const (
cookieHeader = "ory_kratos_session=session-cookie"
identityID = "user-cookie"
sessionAuthenticated = "2026-03-24T01:20:00Z"
)
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "kratos.test" &&
r.URL.Path == "/sessions/whoami" &&
r.Method == http.MethodGet {
require.Equal(t, cookieHeader, r.Header.Get("Cookie"))
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "kratos-session-cookie",
"authenticated_at": sessionAuthenticated,
"identity": map[string]any{
"id": identityID,
"traits": map[string]any{
"email": "cookie@example.com",
"name": "Cookie User",
"department": "Platform",
"affiliationType": "GENERAL",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
setDefaultHTTPClientForTest(t, transport)
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{}
app := fiber.New()
app.Get("/api/v1/user/me", h.GetMe)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil)
req.Header.Set("Cookie", cookieHeader)
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"])
}

View File

@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"`
}
var protectedSystemClientIDs = map[string]struct{}{
"oathkeeper-introspect": {},
}
func normalizeUserRole(role string) string {
return domain.NormalizeRole(role)
}
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
return strings.TrimSpace(profile.Role)
}
func isProtectedSystemClientID(clientID string) bool {
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
return ok
}
func isProtectedSystemClient(client domain.HydraClient) bool {
return isProtectedSystemClientID(client.ClientID)
}
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil {
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*client) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
summary := h.mapClientSummary(*client)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if clientID == "" {
clientID = uuid.NewString()
}
if isProtectedSystemClientID(clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
}
name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" {
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
currentSummary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c)
if profile == nil {
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
var totalClients int64
if err == nil {
for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue

View File

@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res clientListResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, "client-1", res.Items[0].ID)
}
func TestUpdateClientStatus_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
assert.Equal(t, "inactive", res.Client.Status)
}
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestDeleteClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
assert.Error(t, err)
}
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "oathkeeper-introspect",
"client_name": "Internal Client",
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: new(devMockKetoService),
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestRotateClientSecret_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
}), nil
}

View File

@@ -211,6 +211,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
@@ -220,6 +221,7 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -20,7 +24,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -275,31 +275,36 @@ services:
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 $${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
# Function to create or update OAuth2 client (Idempotency)
upsert_client() {
ID=$1
shift
if hydra get oauth2-client --endpoint http://hydra:4445 "$ID" >/dev/null 2>&1; then
echo "Updating existing client: $ID"
hydra update oauth2-client --endpoint http://hydra:4445 "$ID" "$@"
else
echo "Creating new client: $ID"
hydra create oauth2-client --endpoint http://hydra:4445 --id "$ID" "$@"
fi
}
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
upsert_client "adminfront" \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
upsert_client "devfront" \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}"
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
--grant-type client_credentials \
--response-type token \

View File

@@ -111,6 +111,14 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
#### 5.2.2 관리 프로세스 (Template & CI)
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
3. **UserFront 런타임 리소스 동기화**:
* UserFront는 런타임에 `userfront/assets/translations/*.toml`을 직접 읽습니다.
* 따라서 `locales/ko.toml`, `locales/en.toml`, `locales/template.toml`을 수정한 뒤에는 반드시 아래 동기화 스크립트를 실행해야 합니다.
* 실행 명령:
```bash
./scripts/sync_userfront_locales.sh
```
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
@@ -244,3 +252,14 @@ UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.
### 6.1 UserFront 번역 수정 체크포인트
UserFront 번역을 수정할 때는 아래 순서를 기본 절차로 사용합니다.
1. `locales/*.toml` 수정
2. `./scripts/sync_userfront_locales.sh` 실행
3. UserFront 회귀 테스트 실행
- 예: `cd userfront && flutter test test/english_locale_placeholder_test.dart`
4. 전체 키 정합성 점검
- 예: `node tools/i18n-scanner/index.js`

View File

@@ -0,0 +1,45 @@
# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨
## 기준 시점
- 2026-03-24 KST
- 대상 화면: UserFront 대시보드 상단 세션 정보 칩
## 증상
- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨
- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨
## 원인
1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다.
2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다.
3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다.
## 수정 방향
1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami``authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다.
2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다.
- JWT의 `iat` 또는 `auth_time`
- profile의 `sessionAuthenticatedAt`
3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다.
## 반영 파일
- `backend/internal/domain/auth_models.go`
- `backend/internal/handler/auth_handler.go`
- `backend/docs/openapi.yaml`
- `userfront/lib/features/profile/data/models/user_profile_model.dart`
- `userfront/lib/features/dashboard/domain/session_time_resolver.dart`
- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
## 회귀 테스트
- Backend
- `backend/internal/handler/auth_handler_session_profile_test.go`
- UserFront
- `userfront/test/dashboard_session_time_resolver_test.dart`
- `userfront/test/dashboard_screen_smoke_test.dart`
## 검증 명령
- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1`
- `flutter test test/dashboard_session_time_resolver_test.dart`
- `flutter test test/dashboard_screen_smoke_test.dart`
## 남은 참고사항
- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다.
- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다.

File diff suppressed because one or more lines are too long

View File

@@ -849,6 +849,11 @@ symbol = "특수문자 1개 이상"
uppercase = "대문자 1개 이상"
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth]
@@ -1352,6 +1357,7 @@ security = "보안"
[ui.userfront.signup.agreement]
all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth]

View File

@@ -849,6 +849,11 @@ symbol = ""
uppercase = ""
[msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = ""
[msg.userfront.signup.auth]
@@ -1352,6 +1357,7 @@ security = ""
[ui.userfront.signup.agreement]
all = ""
privacy_title = ""
required = ""
tos_title = ""
[ui.userfront.signup.auth]

View File

@@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}"
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=()
@@ -382,12 +384,21 @@ run_validation() {
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
validate_dotenv_line_safety "KRATOS_UI_URL"
validate_dotenv_line_safety "ADMINFRONT_URL"
validate_dotenv_line_safety "DEVFRONT_URL"
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
if [[ -n "$ADMINFRONT_URL" ]]; then
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
fi
if [[ -n "$DEVFRONT_URL" ]]; then
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
fi
collect_values
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}"
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
validate_gateway_mapping
build_allowed_return_urls
}

View File

@@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI;
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
export default defineConfig({
testDir: './tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
use: {
baseURL,

View File

@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
await page.waitForTimeout(250);
}
async function submitDepartmentEditor(page: Page): Promise<void> {
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
}
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
await page.unroute('**/api/v1/**');
});
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
page,
}) => {
const state: ProfileState = {
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
expect(state.putBodies[0]?.department).toBe('QA-Updated');
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
expect(state.department).toBe('QA');
});
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
const state: ProfileState = {
department: 'QA',
getMeCount: 0,
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
await page.reload();
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
await blurDepartmentEditor(page);
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(2);
expect(state.putBodies[0]?.department).toBe('QA-1');

File diff suppressed because one or more lines are too long

View File

@@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = "회원가입"
[ui.userfront.signup.agreement]
all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"

View File

@@ -277,6 +277,11 @@ privacy_full = ""
tos_full = ""
[msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = ""
[msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = ""
[ui.userfront.signup.agreement]
all = ""
privacy_title = ""
required = ""
tos_title = ""
[ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = ""
[ui.userfront.signup.success]
action = ""

View File

@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
enum ToastType { success, error, info }
class _ToastItem {
const _ToastItem({
required this.id,
required this.message,
required this.type,
});
final String id;
final String message;
final ToastType type;
}
class ToastService {
static const Duration _displayDuration = Duration(milliseconds: 3000);
static final ValueNotifier<List<_ToastItem>> _toasts =
ValueNotifier<List<_ToastItem>>(<_ToastItem>[]);
static void success(String message) {
show(message, type: ToastType.success);
}
static void error(String message) {
show(message, type: ToastType.error);
}
static void info(String message) {
show(message, type: ToastType.info);
}
static void show(String message, {ToastType type = ToastType.success}) {
final trimmed = message.trim();
if (trimmed.isEmpty) {
return;
}
final item = _ToastItem(
id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}',
message: trimmed,
type: type,
);
_toasts.value = [..._toasts.value, item];
unawaited(
Future<void>.delayed(_displayDuration, () {
_remove(item.id);
}),
);
}
static void _remove(String id) {
final next = _toasts.value.where((toast) => toast.id != id).toList();
if (next.length == _toasts.value.length) {
return;
}
_toasts.value = next;
}
}
class ToastViewport extends StatelessWidget {
const ToastViewport({super.key});
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: true,
child: SafeArea(
child: ValueListenableBuilder<List<_ToastItem>>(
valueListenable: ToastService._toasts,
builder: (context, toasts, _) {
if (toasts.isEmpty) {
return const SizedBox.shrink();
}
final media = MediaQuery.of(context);
final width = math.min(320.0, media.size.width - 32);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 16, bottom: 16),
child: SizedBox(
width: width > 0 ? width : 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final toast in toasts)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _ToastCard(item: toast),
),
],
),
),
),
);
},
),
),
);
}
}
class _ToastCard extends StatefulWidget {
const _ToastCard({required this.item});
final _ToastItem item;
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> {
bool _visible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
setState(() {
_visible = true;
});
});
}
@override
Widget build(BuildContext context) {
final scheme = _toastColorScheme(widget.item.type);
final icon = _toastIcon(widget.item.type);
return AnimatedSlide(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
offset: _visible ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 220),
opacity: _visible ? 1 : 0,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: scheme.border),
boxShadow: const [
BoxShadow(
color: Color(0x26000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: scheme.foreground),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.item.message,
style: TextStyle(
color: scheme.foreground,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
decoration: TextDecoration.none,
),
),
),
],
),
),
),
),
);
}
_ToastColorScheme _toastColorScheme(ToastType type) {
switch (type) {
case ToastType.success:
return const _ToastColorScheme(
background: Color(0xFFECFDF5),
border: Color(0xFFA7F3D0),
foreground: Color(0xFF065F46),
);
case ToastType.error:
return const _ToastColorScheme(
background: Color(0xFFFFF1F2),
border: Color(0xFFFDA4AF),
foreground: Color(0xFF9F1239),
);
case ToastType.info:
return const _ToastColorScheme(
background: Color(0xFFEFF6FF),
border: Color(0xFFBFDBFE),
foreground: Color(0xFF1E40AF),
);
}
}
IconData _toastIcon(ToastType type) {
switch (type) {
case ToastType.success:
return Icons.check_circle_outline;
case ToastType.error:
return Icons.error_outline;
case ToastType.info:
return Icons.info_outline;
}
}
}
class _ToastColorScheme {
const _ToastColorScheme({
required this.background,
required this.border,
required this.foreground,
});
final Color background;
final Color border;
final Color foreground;
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/ui/toast_service.dart';
class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key});
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
);
ToastService.error('Invalid Password. Access Denied.');
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
}
}
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
);
ToastService.success('User created successfully!');
_formKey.currentState!.reset();
_loginIdController.clear();
_emailController.clear();
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
ToastService.error('Error: $e');
}
} finally {
if (mounted) setState(() => _isLoading = false);

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/ui/toast_service.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@@ -108,12 +109,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password'),
backgroundColor: Colors.red,
),
);
ToastService.error('Invalid Password');
context.go(buildLocalizedHomePath(Uri.base));
}
}
@@ -343,16 +339,12 @@ class _UserManagementScreenState extends State<UserManagementScreen>
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
ToastService.error(msg);
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
ToastService.success(msg);
}
@override

View File

@@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart';
import 'package:userfront/core/i18n/locale_utils.dart';
import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart';
import 'package:userfront/core/ui/toast_service.dart';
class ConsentScreen extends StatefulWidget {
final String consentChallenge;
@@ -187,16 +188,11 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) {
setState(() => _isSubmitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.consent.cancel.error',
fallback:
'An error occurred while cancelling consent: {{error}}',
params: {'error': '$e'},
),
),
ToastService.error(
tr(
'msg.userfront.consent.cancel.error',
fallback: 'An error occurred while cancelling consent: {{error}}',
params: {'error': '$e'},
),
);
}
@@ -237,10 +233,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
}
Widget _buildConsentCard(BuildContext context) {
final clientName =
_consentInfo?['client']?['client_name'] ??
tr('msg.userfront.consent.client_unknown');
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
? clientRawName
: (clientId != '-'
? clientId
: tr('msg.userfront.consent.client_unknown'));
final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
@@ -419,7 +418,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
)
: Text(
tr('ui.userfront.consent.accept'),
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/ui/toast_service.dart';
import 'package:userfront/i18n.dart';
class ForgotPasswordScreen extends StatefulWidget {
@@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
drySend: _drySendEnabled,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.forgot.sent')),
backgroundColor: Colors.green,
),
);
ToastService.success(tr('msg.userfront.forgot.sent'));
Navigator.of(context).pop();
}
} catch (e) {
@@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
}
bool _parseBoolParam(String? value) {

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
@@ -18,6 +19,7 @@ import '../domain/cookie_session_policy.dart';
import '../domain/login_link_route_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
import '../../../core/ui/toast_service.dart';
class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
@@ -42,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final FocusNode _passwordFocusNode = FocusNode();
String? _redirectUrl;
String? _loginChallenge;
bool _isPasswordCapsLockOn = false;
// QR Login Variables
String? _qrImageBase64;
@@ -92,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl;
_passwordFocusNode.addListener(_handlePasswordFocusChange);
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
@@ -153,6 +159,40 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
});
}
void _handlePasswordFocusChange() {
if (!mounted) {
return;
}
if (_passwordFocusNode.hasFocus) {
_syncPasswordCapsLockState();
return;
}
if (_isPasswordCapsLockOn) {
setState(() {
_isPasswordCapsLockOn = false;
});
}
}
bool _handleHardwareKeyEvent(KeyEvent event) {
if (_passwordFocusNode.hasFocus) {
_syncPasswordCapsLockState();
}
return false;
}
void _syncPasswordCapsLockState() {
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
KeyboardLockMode.capsLock,
);
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
return;
}
setState(() {
_isPasswordCapsLockOn = isEnabled;
});
}
Future<void> _tryCookieSession({bool silent = true}) async {
final loginChallenge = _loginChallenge;
final token = AuthTokenStore.getToken();
@@ -935,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_linkIdController.dispose();
_passwordLoginIdController.dispose();
_passwordController.dispose();
_passwordFocusNode
..removeListener(_handlePasswordFocusChange)
..dispose();
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
_shortCodePrefixController.dispose();
_shortCodeDigitsController.dispose();
_linkResendTimer?.cancel();
@@ -1153,9 +1197,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
try {
AuthProxyService.logError(message);
} catch (e) {
@@ -1165,9 +1207,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showInfo(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.green),
);
ToastService.success(message);
}
void _logTokenDetails(String jwt) {
@@ -1302,6 +1342,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
}
String _capsLockWarningText(BuildContext context) {
const key = 'msg.userfront.login.password.caps_lock_on';
final languageCode = Localizations.localeOf(context).languageCode;
if (languageCode == 'ko') {
final translated = tr(key);
if (translated != key) {
return translated;
}
return 'Caps Lock이 켜져 있습니다.';
}
final translated = tr(key, fallback: 'Caps Lock is on.');
if (translated != key) {
return translated;
}
return 'Caps Lock is on.';
}
@override
Widget build(BuildContext context) {
if (_verificationOnly && _verificationApproved) {
@@ -1413,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
key: const ValueKey(
'password_login_password_input',
),
focusNode: _passwordFocusNode,
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
@@ -1426,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
onSubmitted: (_) => _handlePasswordLogin(),
),
if (_isPasswordCapsLockOn) ...[
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.keyboard_capslock_rounded,
size: 18,
color: Colors.orange,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_capsLockWarningText(context),
style: const TextStyle(
color: Colors.orange,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
const SizedBox(height: 24),
FilledButton(
key: const ValueKey(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
import 'package:userfront/core/ui/toast_service.dart';
import 'qr_scan_route.dart';
@@ -23,15 +24,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
void _submit() {
final raw = _controller.text.trim();
if (raw.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.qr.permission_required',
fallback: '카메라 권한이 필요합니다.',
),
),
),
ToastService.info(
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
);
return;
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/ui/toast_service.dart';
import 'package:userfront/i18n.dart';
class ResetPasswordScreen extends StatefulWidget {
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.reset.success')),
backgroundColor: Colors.green,
),
);
ToastService.success(tr('msg.userfront.reset.success'));
context.go(buildLocalizedSigninPath(Uri.base));
}
} catch (e) {
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
ToastService.error(message);
}
String _buildPolicyDescription() {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import 'dart:convert';
import '../../profile/data/models/user_profile_model.dart';
DateTime? resolveDashboardSessionIssuedAt({
String? token,
UserProfile? profile,
}) {
final tokenIssuedAt = _getJwtIssuedAt(token);
if (tokenIssuedAt != null) {
return tokenIssuedAt;
}
return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt);
}
DateTime? _getJwtIssuedAt(String? token) {
if (token == null || token.isEmpty) {
return null;
}
try {
final parts = token.split('.');
if (parts.length != 3) {
return null;
}
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(
iatValue.toInt() * 1000,
).toLocal();
}
} catch (_) {
return null;
}
return null;
}
DateTime? _parseSessionAuthenticatedAt(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
try {
return DateTime.parse(value).toLocal();
} catch (_) {
return null;
}
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/session_time_resolver.dart';
import '../domain/providers/linked_rps_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_proxy_service.dart';
@@ -15,6 +16,7 @@ import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
import '../domain/models.dart' hide LinkedRp;
@@ -104,14 +106,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
try {
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.revoke.success',
params: {'app': appName},
),
),
ToastService.success(
tr(
'msg.userfront.dashboard.revoke.success',
params: {'app': appName},
),
);
setState(() {
@@ -121,15 +119,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.dashboard.revoke.error',
params: {'error': '$e'},
),
),
),
ToastService.error(
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
);
}
} finally {
@@ -414,32 +405,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
}
DateTime? _getJwtIssuedAt() {
final token = AuthTokenStore.getToken();
if (token == null || token.isEmpty) {
return null;
}
try {
final parts = token.split('.');
if (parts.length != 3) {
return null;
}
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(
iatValue.toInt() * 1000,
).toLocal();
}
} catch (_) {
return null;
}
return null;
}
String _formatDateTime(DateTime dateTime) {
final yyyy = dateTime.year.toString().padLeft(4, '0');
final mm = dateTime.month.toString().padLeft(2, '0');
@@ -547,12 +512,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
ToastService.info(
tr('msg.userfront.dashboard.session_id_copied'),
);
}
},
@@ -626,12 +587,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
ToastService.info(
tr('msg.userfront.dashboard.session_id_copied'),
);
}
},
@@ -730,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
final departmentValue = profile?.department ?? '';
final departmentValue =
profile?.tenant?.name ?? profile?.department ?? '';
final department = departmentValue.isNotEmpty
? departmentValue
: tr('ui.userfront.profile.department_empty');
final sessionIssuedAt = _getJwtIssuedAt();
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
token: AuthTokenStore.getToken(),
profile: profile,
);
return Scaffold(
backgroundColor: _subtle,
@@ -1280,7 +1241,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final itemUrl = item.url;
if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl);
@@ -1290,18 +1250,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
await launchUrl(uri);
return;
}
messenger.showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_open_error')),
),
);
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
} else {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_missing')),
),
);
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
}
},
child: opaqueCard,
@@ -1489,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
double _historySessionColumnWidth(double maxWidth) {
return math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
return math.min(
200.0,
math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
),
);
}

View File

@@ -33,6 +33,7 @@ class UserProfile {
final String department;
final String affiliationType;
final String companyCode;
final String? sessionAuthenticatedAt;
final Map<String, dynamic>? metadata;
final Tenant? tenant;
@@ -44,6 +45,7 @@ class UserProfile {
required this.department,
required this.affiliationType,
required this.companyCode,
this.sessionAuthenticatedAt,
this.metadata,
this.tenant,
});
@@ -57,6 +59,7 @@ class UserProfile {
department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '',
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
metadata: json['metadata'] != null
? Map<String, dynamic>.from(json['metadata'])
: null,
@@ -73,6 +76,7 @@ class UserProfile {
'department': department,
'affiliationType': affiliationType,
'companyCode': companyCode,
'sessionAuthenticatedAt': sessionAuthenticatedAt,
'metadata': metadata,
'tenant': tenant?.toJson(),
};
@@ -87,6 +91,7 @@ class UserProfile {
department: department ?? this.department,
affiliationType: affiliationType,
companyCode: companyCode,
sessionAuthenticatedAt: sessionAuthenticatedAt,
tenant: tenant,
);
}

View File

@@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../data/models/user_profile_model.dart';
import '../../domain/notifiers/profile_notifier.dart';
@@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final FocusNode _departmentFocus = FocusNode();
final FocusNode _phoneFocus = FocusNode();
final FocusNode _phoneCodeFocus = FocusNode();
bool _nameTouched = false;
bool _departmentTouched = false;
bool _phoneTouched = false;
bool _phoneCodeTouched = false;
bool _isSavingField = false;
String? _skipAutoSaveField;
String? _fieldSaveError;
String _initialPhone = '';
bool _isPhoneChanged = false;
@@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
@override
void initState() {
super.initState();
_nameFocus.addListener(_onNameFocusChange);
_departmentFocus.addListener(_onDepartmentFocusChange);
_phoneFocus.addListener(_onPhoneFocusChange);
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
}
void _debugLog(
@@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_log.fine(parts.join(' '));
}
void _onNameFocusChange() {
if (!mounted) return;
if (!_nameFocus.hasFocus && _nameTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'name');
});
} else if (_nameFocus.hasFocus) {
_nameTouched = true;
}
}
void _onDepartmentFocusChange() {
if (!mounted) return;
_debugLog(
'department_focus_change',
field: 'department',
hasFocus: _departmentFocus.hasFocus,
);
if (!_departmentFocus.hasFocus && _departmentTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'department');
});
} else if (_departmentFocus.hasFocus) {
_departmentTouched = true;
}
}
void _onPhoneFocusChange() {
if (!mounted) return;
if (!_phoneFocus.hasFocus && _phoneTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneFocus.hasFocus) {
_phoneTouched = true;
}
}
void _onPhoneCodeFocusChange() {
if (!mounted) return;
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
Future.microtask(() {
if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneCodeFocus.hasFocus) {
_phoneCodeTouched = true;
}
}
@override
void dispose() {
_nameController?.dispose();
@@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isCodeSent = false;
_isVerifying = false;
_codeController?.clear();
_phoneTouched = false;
_phoneCodeTouched = false;
}
void _startEditing(String field, UserProfile profile) {
_debugLog('start_editing', field: field);
setState(() {
_editingField = field;
_fieldSaveError = null;
if (field == 'name') {
_nameController?.text = profile.name;
} else if (field == 'department') {
@@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
_fieldSaveError = null;
});
}
@@ -269,21 +203,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
);
ToastService.info(tr('msg.userfront.profile.phone.code_sent'));
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.send_failed',
params: {'error': e.toString()},
),
),
ToastService.error(
tr(
'msg.userfront.profile.phone.send_failed',
params: {'error': e.toString()},
),
);
}
@@ -303,24 +231,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isVerifying = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
);
}
if (_editingField == 'phone') {
await _saveField(profile);
ToastService.success(tr('msg.userfront.profile.phone.verified'));
}
} catch (e) {
setState(() => _isVerifying = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verify_failed',
params: {'error': e.toString()},
),
),
ToastService.error(
tr(
'msg.userfront.profile.phone.verify_failed',
params: {'error': e.toString()},
),
);
}
@@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_newPasswordController?.clear();
_confirmPasswordController?.clear();
setState(() {
_passwordSuccess = tr('msg.userfront.profile.password.changed');
_passwordSuccess = null;
});
ToastService.success(tr('msg.userfront.profile.password.changed'));
} catch (e) {
final message = e.toString().replaceFirst('Exception: ', '');
setState(() {
@@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
params: {'error': message},
);
});
ToastService.error(
tr(
'msg.userfront.profile.password.change_failed',
params: {'error': message},
),
);
} finally {
if (mounted) {
setState(() => _isPasswordSaving = false);
@@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
}
void _autoSaveIfEditing(UserProfile profile, String field) {
if (_editingField != field) return;
if (_skipAutoSaveField == field) {
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
_skipAutoSaveField = null;
return;
}
if (_isVerifying) {
_debugLog('autosave_skip', field: field, reason: 'verifying');
return;
}
if (_isSavingField) {
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
return;
}
if (!_hasFieldChanged(profile, field)) {
_debugLog(
'autosave_skip',
field: field,
reason: 'unchanged',
changed: false,
);
setState(() {
if (field == 'phone') {
_resetPhoneState();
}
_editingField = null;
if (field == 'name') {
_nameTouched = false;
} else if (field == 'department') {
_departmentTouched = false;
}
});
return;
}
_debugLog('autosave_trigger', field: field, changed: true);
_saveField(profile);
}
void _handlePhoneFocusChange(UserProfile profile) {
if (_editingField != 'phone') return;
if (_skipAutoSaveField == 'phone') {
_skipAutoSaveField = null;
return;
}
if (_isVerifying) return;
if (_isSavingField) return;
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
if (!_hasFieldChanged(profile, 'phone')) {
setState(() {
_resetPhoneState();
_editingField = null;
});
return;
}
_saveField(profile);
}
bool _hasFieldChanged(UserProfile profile, String field) {
if (field == 'name') {
return (_nameController?.text.trim() ?? '') != profile.name;
@@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_debugLog('save_skip', reason: 'saving_in_flight');
return;
}
setState(() {
_fieldSaveError = null;
});
final currentField = _editingField!;
final nextName = currentField == 'name'
@@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (currentField == 'name' && nextName.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_name');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.name_required');
});
return;
}
if (currentField == 'department' && nextDepartment.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_department');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.profile.department_required')),
),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.department_required');
});
return;
}
if (currentField == 'phone') {
if (nextPhone.isEmpty) {
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.phone_required');
});
return;
}
if (_isPhoneChanged && !_isPhoneVerified) {
@@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
field: currentField,
reason: 'phone_not_verified',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tr('msg.userfront.profile.phone_verify_required')),
),
);
setState(() {
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
});
return;
}
}
@@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
});
return;
}
_isSavingField = true;
setState(() {
_isSavingField = true;
});
_debugLog('save_dispatch', field: currentField, changed: true);
try {
@@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_resetPhoneState();
}
_editingField = null;
_nameTouched = false;
_departmentTouched = false;
});
_debugLog('save_success', field: currentField);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
);
ToastService.success(tr('msg.userfront.profile.update_success'));
}
} catch (e) {
_debugLog('save_failed', field: currentField, reason: e.toString());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.update_failed',
params: {'error': e.toString()},
),
),
),
);
setState(() {
_fieldSaveError = tr(
'msg.userfront.profile.update_failed',
params: {'error': e.toString().replaceFirst('Exception: ', '')},
);
});
}
} finally {
_isSavingField = false;
if (mounted) {
setState(() {
_isSavingField = false;
});
}
}
}
@@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
final hasChanged = _hasFieldChanged(profile, field);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
@@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: label,
errorText: _fieldSaveError,
),
),
),
const SizedBox(width: 12),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = field;
},
child: OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
ElevatedButton(
key: Key('profile-$field-save-button'),
onPressed: isUpdating || !hasChanged || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
),
const SizedBox(width: 8),
OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
],
),
@@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
final hasChanged = _hasFieldChanged(profile, 'phone');
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
@@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: '01012345678',
errorText: _fieldSaveError,
suffixIcon: _isPhoneVerified
? const Icon(Icons.check_circle, color: Colors.green)
: null,
@@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(width: 8),
Listener(
onPointerDown: (_) {
_skipAutoSaveField = 'phone';
},
child: OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
ElevatedButton(
onPressed: isUpdating || !canSave || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
],
),

View File

@@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
import 'core/i18n/toml_asset_loader.dart';
import 'core/ui/toast_service.dart';
import 'package:logging/logging.dart';
import 'features/auth/presentation/consent_screen.dart';
import 'i18n.dart';
@@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget {
localizationsDelegates: delegates,
supportedLocales: supportedLocales,
locale: locale,
builder: (context, child) {
return Stack(
children: [if (child != null) child, const ToastViewport()],
);
},
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base

View File

@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/dashboard/domain/session_time_resolver.dart';
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
void main() {
test('JWT에 iat가 있으면 세션 시각으로 사용한다', () {
const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature';
final issuedAt = resolveDashboardSessionIssuedAt(token: token);
expect(issuedAt, isNotNull);
expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z');
});
test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () {
final profile = UserProfile(
id: 'user-1',
email: 'qa@example.com',
name: 'QA User',
phone: '01012345678',
department: 'Platform',
affiliationType: 'GENERAL',
companyCode: '',
sessionAuthenticatedAt: '2026-03-23T15:30:00Z',
);
final issuedAt = resolveDashboardSessionIssuedAt(
token: null,
profile: profile,
);
expect(issuedAt, isNotNull);
expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z');
});
}

View File

@@ -0,0 +1,106 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:toml/toml.dart';
const Set<String> _placeholderValues = {
'Action',
'Action Label',
'Approve Error',
'Approve Success',
'Body',
'Code Hint',
'Code Label',
'Confirm',
'Confirm Button',
'Description',
'Error',
'Heading',
'Input Label',
'Invalid',
'Label',
'Load Failed',
'Page Title',
'Request Code',
'Result Failure',
'Sent',
'Subtitle',
'Title',
'Update Success',
};
String? _readTomlValue(Map<String, dynamic> root, String key) {
dynamic cursor = root;
for (final part in key.split('.')) {
if (cursor is! Map<String, dynamic>) {
return null;
}
cursor = cursor[part];
}
return cursor is String ? cursor : null;
}
void main() {
test('critical english copy does not expose placeholder values', () {
final file = File('assets/translations/en.toml');
final document = TomlDocument.parse(file.readAsStringSync());
final translations = document.toMap();
const criticalKeys = <String>[
'ui.userfront.forgot.heading',
'ui.userfront.forgot.input_label',
'ui.userfront.forgot.title',
'msg.userfront.forgot.description',
'msg.userfront.forgot.sent',
'ui.userfront.login.link.action_label',
'ui.userfront.login.link.page_title',
'ui.userfront.login.link.title',
'ui.userfront.login.unregistered.action',
'ui.userfront.login.unregistered.title',
'ui.userfront.login.verification.action_label',
'ui.userfront.login.verification.page_title',
'ui.userfront.login.verification.title',
'msg.userfront.login.qr.load_failed',
'msg.userfront.login.short_code.invalid',
'msg.userfront.login.unregistered.body',
'ui.userfront.login_success.title',
'msg.userfront.login_success.subtitle',
'msg.userfront.profile.load_failed',
'msg.userfront.profile.update_success',
'ui.userfront.profile.password.title',
'ui.userfront.profile.phone.code_hint',
'msg.userfront.reset.invalid_body',
'msg.userfront.reset.invalid_title',
'ui.userfront.reset.subtitle',
'ui.userfront.reset.title',
'ui.userfront.signup.title',
'msg.userfront.signup.agreement.title',
'msg.userfront.signup.auth.title',
'ui.userfront.signup.auth.email.label',
'ui.userfront.signup.auth.email.title',
'msg.userfront.signup.password.title',
'msg.userfront.signup.profile.title',
'msg.userfront.signup.success.body',
'msg.userfront.signup.success.title',
'ui.userfront.signup.success.action',
];
final failures = <String>[];
for (final key in criticalKeys) {
final value = _readTomlValue(translations, key);
if (value == null || value.trim().isEmpty) {
failures.add('$key is missing');
continue;
}
if (_placeholderValues.contains(value.trim())) {
failures.add('$key uses placeholder "$value"');
}
}
expect(
failures,
isEmpty,
reason: failures.isEmpty ? null : failures.join('\n'),
);
});
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
// Mocking the profile notifier
class MockProfileNotifier extends ProfileNotifier {
UserProfile? _profile;
bool updateCalled = false;
String? updatedName;
@override
Future<UserProfile?> build() async {
_profile = UserProfile(
id: 'test-id',
email: 'test@example.com',
name: 'Original Name',
phone: '01012345678',
department: 'Dev',
affiliationType: 'employee',
companyCode: 'C100',
);
return _profile;
}
@override
Future<UserProfile?> loadProfile() async {
state = const AsyncValue.loading();
state = AsyncValue.data(_profile);
return _profile;
}
@override
Future<void> updateProfile({
String? name,
String? phone,
String? department,
}) async {
updateCalled = true;
updatedName = name;
_profile = _profile!.copyWith(
name: name ?? _profile!.name,
phone: phone ?? _profile!.phone,
department: department ?? _profile!.department,
);
state = AsyncValue.data(_profile);
}
}
void main() {
testWidgets(
'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)',
(tester) async {
final recordedErrors = <FlutterErrorDetails>[];
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
final text = details.exceptionAsString();
if (text.contains('A RenderFlex overflowed')) {
return;
}
recordedErrors.add(details);
};
addTearDown(() {
FlutterError.onError = previousOnError;
});
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final mockNotifier = MockProfileNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [profileProvider.overrideWith(() => mockNotifier)],
child: const MaterialApp(home: Scaffold(body: ProfilePage())),
),
);
await tester.pumpAndSettle();
// 1. Entering edit mode
final editButton = find.byKey(const Key('profile-name-edit-button'));
expect(editButton, findsOneWidget);
await tester.tap(editButton);
await tester.pumpAndSettle();
final inputField = find.byKey(const Key('profile-name-input'));
expect(inputField, findsOneWidget);
// 2. Testing cancel flow
await tester.enterText(inputField, 'Changed Name');
await tester.pumpAndSettle();
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
await tester.tap(cancelButton);
await tester.pumpAndSettle();
// After cancellation, the field should be read-only again.
expect(find.byKey(const Key('profile-name-input')), findsNothing);
// Find text could be part of ListTile
expect(find.text('Original Name'), findsWidgets);
// 3. Re-enter edit mode and explicitly save
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('profile-name-input')),
'Saved Name',
);
await tester.pumpAndSettle();
final saveButton = find.byKey(const Key('profile-name-save-button'));
await tester.tap(saveButton);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
FlutterError.onError = previousOnError;
// Verify the mock received the update
expect(mockNotifier.updateCalled, isTrue);
expect(mockNotifier.updatedName, 'Saved Name');
expect(recordedErrors, isEmpty);
},
);
}