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로 라우팅합니다. # Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc 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 allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[] KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123 CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정 # AdminFront OIDC 설정
ADMINFRONT_URL=http://localhost:5173
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정 # DevFront OIDC 설정
VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc 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_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }} ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }} USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }} BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }} OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }} ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
@@ -161,14 +163,11 @@ jobs:
docker compose -f staging_pull_compose.yaml pull docker compose -f staging_pull_compose.yaml pull
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
docker compose -f staging_pull_compose.yaml down || true
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등) # 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
docker compose -f staging_pull_compose.yaml build --pull 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 --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 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 .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 code-check: code-check-lint code-check-test-jobs
@echo "code-check complete." @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: code-check-test-jobs:
@echo "==> run CI-equivalent test jobs (parallel)" @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-backend-tests \
code-check-userfront-tests \ code-check-userfront-tests \
code-check-userfront-e2e-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"; \ rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
fi; \ fi; \
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ 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: code-check-adminfront-tests:
@echo "==> 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: code-check-devfront-tests:
@echo "==> devfront tests" @echo "==> devfront tests"
@@ -219,7 +223,7 @@ code-check-devfront-tests:
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \ fi; \
if [ $$status -eq 0 ]; then \ if [ $$status -eq 0 ]; then \
(cd devfront && npm test) || status=$$?; \ (cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \ fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \ [ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -d devfront/test-results ] && cp -R devfront/test-results 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 \ 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();});")"; \ 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"; \ 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; \ 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/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; \ [ -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`과 항상 동기화합니다. - **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`로 로드합니다. - **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`에 사전 생성합니다. - **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`로 코드-키-로케일 동기화 상태를 점검합니다. - **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
## 🧪 Code Check CI ## 🧪 Code Check CI

View File

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

View File

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

View File

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

View File

@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = clientID name = clientID
} }
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback) clientURL := resolveLinkedRPURL(
clientURL := strings.TrimSpace(client.ClientURI) client.ClientID,
if clientURL == "" && len(client.RedirectURIs) > 0 { client.ClientURI,
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { client.RedirectURIs,
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) )
}
}
lastAuth := time.Time{} lastAuth := time.Time{}
if session.AuthenticatedAt != nil { if session.AuthenticatedAt != nil {
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
name = client.ClientID name = client.ClientID
} }
clientURL := strings.TrimSpace(client.ClientURI) clientURL := resolveLinkedRPURL(
if clientURL == "" && len(client.RedirectURIs) > 0 { client.ClientID,
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { client.ClientURI,
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) client.RedirectURIs,
} )
}
records[dc.ClientID] = &linkedRpRecord{ records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{ linkedRpSummary: linkedRpSummary{
@@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
} }
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { 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"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
} }
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", nil, err return "", nil, "", err
} }
req.Header.Set("X-Session-Token", sessionToken) req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", nil, err return "", nil, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) 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 { var result struct {
Identity struct { AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 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) { 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) { 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"), "/") kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" { if kratosURL == "" {
kratosURL = "http://kratos:4433" kratosURL = "http://kratos:4433"
} }
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", nil, err return "", nil, "", err
} }
req.Header.Set("Cookie", cookie) req.Header.Set("Cookie", cookie)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", nil, err return "", nil, "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) 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 { var result struct {
Identity struct { AuthenticatedAt string `json:"authenticated_at"`
Identity struct {
ID string `json:"id"` ID string `json:"id"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
} `json:"identity"` } `json:"identity"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 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) { func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
@@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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 // UpdateMe - Updates current user's profile with phone verification check
@@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
return "" 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 { func mergeScopes(current []string, next []string) []string {
if len(next) == 0 { if len(next) == 0 {
return current 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"` Metadata *map[string]interface{} `json:"metadata"`
} }
var protectedSystemClientIDs = map[string]struct{}{
"oathkeeper-introspect": {},
}
func normalizeUserRole(role string) string { func normalizeUserRole(role string) string {
return domain.NormalizeRole(role) return domain.NormalizeRole(role)
} }
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
return strings.TrimSpace(profile.Role) 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) { func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if (!ok || profile == nil) && h.Auth != nil { 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)) items := make([]clientSummary, 0, len(clients))
for _, client := range clients { for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
summary := h.mapClientSummary(client) summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager // 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()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if isProtectedSystemClient(*client) {
return errorJSON(c, fiber.StatusNotFound, "client not found")
}
summary := h.mapClientSummary(*client) summary := h.mapClientSummary(*client)
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current) summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
if clientID == "" { if clientID == "" {
clientID = uuid.NewString() clientID = uuid.NewString()
} }
if isProtectedSystemClientID(clientID) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
}
name := strings.TrimSpace(valueOr(req.Name, "")) name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" { if name == "" {
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
currentSummary := h.mapClientSummary(*current) currentSummary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current) summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if isProtectedSystemClient(*current) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
}
summary := h.mapClientSummary(*current) summary := h.mapClientSummary(*current)
profile := h.getCurrentProfile(c) profile := h.getCurrentProfile(c)
if profile == nil { if profile == nil {
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
var totalClients int64 var totalClients int64
if err == nil { if err == nil {
for _, client := range clients { for _, client := range clients {
if isProtectedSystemClient(client) {
continue
}
if isSuperAdmin { if isSuperAdmin {
totalClients++ totalClients++
continue continue

View File

@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) 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) { func TestUpdateClientStatus_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { 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) 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) { func TestDeleteClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { 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) 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) { func TestRotateClientSecret_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { 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{}{ return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
{"client_id": "c2", "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"}}, {"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
}), nil }), nil
} }

View File

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

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from "@playwright/test"; 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. * Read environment variables from file.
* https://github.com/motdotla/dotenv * https://github.com/motdotla/dotenv
@@ -20,7 +24,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* 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 to use. See https://playwright.dev/docs/test-reporters */
reporter: "html", reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* 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 tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz rm /tmp/hydra.tar.gz
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true # Function to create or update OAuth2 client (Idempotency)
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true upsert_client() {
hydra delete oauth2-client --endpoint http://hydra:4445 $${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true 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 \ upsert_client "adminfront" \
--endpoint http://hydra:4445 \ --name "AdminFront" \
--id adminfront \
--grant-type authorization_code,refresh_token \ --grant-type authorization_code,refresh_token \
--response-type code \ --response-type code \
--scope openid,offline_access,profile,email \ --scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \ --token-endpoint-auth-method none \
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
hydra create oauth2-client \ upsert_client "devfront" \
--endpoint http://hydra:4445 \ --name "DevFront" \
--id devfront \
--grant-type authorization_code,refresh_token \ --grant-type authorization_code,refresh_token \
--response-type code \ --response-type code \
--scope openid,offline_access,profile,email \ --scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \ --token-endpoint-auth-method none \
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}" --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}"
hydra create oauth2-client \ upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
--endpoint http://hydra:4445 \
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
--grant-type client_credentials \ --grant-type client_credentials \
--response-type token \ --response-type token \

View File

@@ -111,6 +111,14 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
#### 5.2.2 관리 프로세스 (Template & CI) #### 5.2.2 관리 프로세스 (Template & CI)
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다. 1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
2. **`ko.toml`, `en.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)**: 3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)** * **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다. * `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
@@ -244,3 +252,14 @@ UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`). 2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동. 3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동. 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개 이상" uppercase = "대문자 1개 이상"
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요" title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth] [msg.userfront.signup.auth]
@@ -1352,6 +1357,7 @@ security = "보안"
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "모두 동의합니다" all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)" privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)" tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth] [ui.userfront.signup.auth]

View File

@@ -849,6 +849,11 @@ symbol = ""
uppercase = "" uppercase = ""
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = "" title = ""
[msg.userfront.signup.auth] [msg.userfront.signup.auth]
@@ -1352,6 +1357,7 @@ security = ""
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "" all = ""
privacy_title = "" privacy_title = ""
required = ""
tos_title = "" tos_title = ""
[ui.userfront.signup.auth] [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}" OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}" HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}" 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:-}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=() declare -a WARNINGS=()
@@ -382,12 +384,21 @@ run_validation() {
validate_dotenv_line_safety "HYDRA_PUBLIC_URL" validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
validate_dotenv_line_safety "KRATOS_BROWSER_URL" validate_dotenv_line_safety "KRATOS_BROWSER_URL"
validate_dotenv_line_safety "KRATOS_UI_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 "ADMINFRONT_CALLBACK_URLS"
validate_dotenv_line_safety "DEVFRONT_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 collect_values
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" 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 validate_gateway_mapping
build_allowed_return_urls 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 defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI; const reuseExistingServer = !process.env.CI;
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: './tests',
fullyParallel: false, fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, 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', reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
use: { use: {
baseURL, baseURL,

View File

@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
await page.waitForTimeout(250); 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> { async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => { await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request(); const request = route.request();
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
await page.unroute('**/api/v1/**'); await page.unroute('**/api/v1/**');
}); });
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
page, page,
}) => { }) => {
const state: ProfileState = { const state: ProfileState = {
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page); await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); 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); await expect.poll(() => state.putBodies.length).toBe(1);
expect(state.putBodies[0]?.department).toBe('QA-Updated'); expect(state.putBodies[0]?.department).toBe('QA-Updated');
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
expect(state.department).toBe('QA'); expect(state.department).toBe('QA');
}); });
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => { test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
const state: ProfileState = { const state: ProfileState = {
department: 'QA', department: 'QA',
getMeCount: 0, getMeCount: 0,
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page); await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); 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 expect.poll(() => state.putBodies.length).toBe(1);
await page.reload(); await page.reload();
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
await openDepartmentEditor(page); await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); 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); await expect.poll(() => state.putBodies.length).toBe(2);
expect(state.putBodies[0]?.department).toBe('QA-1'); 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 = "서비스 이용약관 전문..." tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
title = "서비스 이용을 위해\n약관에 동의해주세요" title = "서비스 이용을 위해\n약관에 동의해주세요"
[msg.userfront.signup.auth] [msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = "회원가입"
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "모두 동의합니다" all = "모두 동의합니다"
privacy_title = "개인정보 수집 및 이용 동의 (필수)" privacy_title = "개인정보 수집 및 이용 동의 (필수)"
required = "필수"
tos_title = "바론 소프트웨어 이용약관 (필수)" tos_title = "바론 소프트웨어 이용약관 (필수)"
[ui.userfront.signup.auth] [ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"

View File

@@ -277,6 +277,11 @@ privacy_full = ""
tos_full = "" tos_full = ""
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
all_hint = ""
description = ""
privacy_summary = ""
progress = ""
tos_summary = ""
title = "" title = ""
[msg.userfront.signup.auth] [msg.userfront.signup.auth]
@@ -583,6 +588,7 @@ title = ""
[ui.userfront.signup.agreement] [ui.userfront.signup.agreement]
all = "" all = ""
privacy_title = "" privacy_title = ""
required = ""
tos_title = "" tos_title = ""
[ui.userfront.signup.auth] [ui.userfront.signup.auth]
@@ -616,4 +622,3 @@ verify = ""
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "" 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 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/ui/toast_service.dart';
class CreateUserScreen extends StatefulWidget { class CreateUserScreen extends StatefulWidget {
const CreateUserScreen({super.key}); const CreateUserScreen({super.key});
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} }
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.error('Invalid Password. Access Denied.');
const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
);
context.go(buildLocalizedHomePath(Uri.base)); // Kick out context.go(buildLocalizedHomePath(Uri.base)); // Kick out
} }
} }
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.success('User created successfully!');
const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
);
_formKey.currentState!.reset(); _formKey.currentState!.reset();
_loginIdController.clear(); _loginIdController.clear();
_emailController.clear(); _emailController.clear();
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.error('Error: $e');
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
} }
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.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 '../domain/login_link_route_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart'; import '../../../core/services/web_window.dart';
import '../../../core/ui/toast_service.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken; final String? verificationToken;
@@ -42,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final TextEditingController _passwordLoginIdController = final TextEditingController _passwordLoginIdController =
TextEditingController(); TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final FocusNode _passwordFocusNode = FocusNode();
String? _redirectUrl; String? _redirectUrl;
String? _loginChallenge; String? _loginChallenge;
bool _isPasswordCapsLockOn = false;
// QR Login Variables // QR Login Variables
String? _qrImageBase64; String? _qrImageBase64;
@@ -92,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_parseBoolParam(Uri.base.queryParameters['drySend']) && _parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv; !AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl; _redirectUrl = widget.redirectUrl;
_passwordFocusNode.addListener(_handlePasswordFocusChange);
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base; 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 { Future<void> _tryCookieSession({bool silent = true}) async {
final loginChallenge = _loginChallenge; final loginChallenge = _loginChallenge;
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
@@ -935,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_linkIdController.dispose(); _linkIdController.dispose();
_passwordLoginIdController.dispose(); _passwordLoginIdController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_passwordFocusNode
..removeListener(_handlePasswordFocusChange)
..dispose();
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
_shortCodePrefixController.dispose(); _shortCodePrefixController.dispose();
_shortCodeDigitsController.dispose(); _shortCodeDigitsController.dispose();
_linkResendTimer?.cancel(); _linkResendTimer?.cancel();
@@ -1153,9 +1197,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showError(String message) { void _showError(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ToastService.error(message);
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
try { try {
AuthProxyService.logError(message); AuthProxyService.logError(message);
} catch (e) { } catch (e) {
@@ -1165,9 +1207,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _showInfo(String message) { void _showInfo(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ToastService.success(message);
SnackBar(content: Text(message), backgroundColor: Colors.green),
);
} }
void _logTokenDetails(String jwt) { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_verificationOnly && _verificationApproved) { if (_verificationOnly && _verificationApproved) {
@@ -1413,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
key: const ValueKey( key: const ValueKey(
'password_login_password_input', 'password_login_password_input',
), ),
focusNode: _passwordFocusNode,
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
@@ -1426,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
onSubmitted: (_) => _handlePasswordLogin(), 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), const SizedBox(height: 24),
FilledButton( FilledButton(
key: const ValueKey( key: const ValueKey(

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/i18n/locale_utils.dart'; import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/ui/toast_service.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
class ResetPasswordScreen extends StatefulWidget { class ResetPasswordScreen extends StatefulWidget {
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.success(tr('msg.userfront.reset.success'));
SnackBar(
content: Text(tr('msg.userfront.reset.success')),
backgroundColor: Colors.green,
),
);
context.go(buildLocalizedSigninPath(Uri.base)); context.go(buildLocalizedSigninPath(Uri.base));
} }
} catch (e) { } catch (e) {
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
void _showError(String message) { void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.error(message);
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
} }
String _buildPolicyDescription() { 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:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/session_time_resolver.dart';
import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/linked_rps_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_proxy_service.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/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/language_selector.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart'; import '../domain/dashboard_providers.dart';
import '../domain/models.dart' hide LinkedRp; import '../domain/models.dart' hide LinkedRp;
@@ -104,14 +106,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
try { try {
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.success(
SnackBar( tr(
content: Text( 'msg.userfront.dashboard.revoke.success',
tr( params: {'app': appName},
'msg.userfront.dashboard.revoke.success',
params: {'app': appName},
),
),
), ),
); );
setState(() { setState(() {
@@ -121,15 +119,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.error(
SnackBar( tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
content: Text(
tr(
'msg.userfront.dashboard.revoke.error',
params: {'error': '$e'},
),
),
),
); );
} }
} finally { } 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) { String _formatDateTime(DateTime dateTime) {
final yyyy = dateTime.year.toString().padLeft(4, '0'); final yyyy = dateTime.year.toString().padLeft(4, '0');
final mm = dateTime.month.toString().padLeft(2, '0'); final mm = dateTime.month.toString().padLeft(2, '0');
@@ -547,12 +512,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async { : () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId)); await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.info(
SnackBar( tr('msg.userfront.dashboard.session_id_copied'),
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
); );
} }
}, },
@@ -626,12 +587,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: () async { : () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId)); await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ToastService.info(
SnackBar( tr('msg.userfront.dashboard.session_id_copied'),
content: Text(
tr('msg.userfront.dashboard.session_id_copied'),
),
),
); );
} }
}, },
@@ -730,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
profile?.email ?? profile?.email ??
profile?.phone ?? profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User'); tr('ui.userfront.profile.user_fallback', fallback: 'User');
final departmentValue = profile?.department ?? ''; final departmentValue =
profile?.tenant?.name ?? profile?.department ?? '';
final department = departmentValue.isNotEmpty final department = departmentValue.isNotEmpty
? departmentValue ? departmentValue
: tr('ui.userfront.profile.department_empty'); : tr('ui.userfront.profile.department_empty');
final sessionIssuedAt = _getJwtIssuedAt(); final sessionIssuedAt = resolveDashboardSessionIssuedAt(
token: AuthTokenStore.getToken(),
profile: profile,
);
return Scaffold( return Scaffold(
backgroundColor: _subtle, backgroundColor: _subtle,
@@ -1280,7 +1241,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () async { onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final itemUrl = item.url; final itemUrl = item.url;
if (itemUrl != null && itemUrl.isNotEmpty) { if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl); final uri = Uri.parse(itemUrl);
@@ -1290,18 +1250,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
await launchUrl(uri); await launchUrl(uri);
return; return;
} }
messenger.showSnackBar( ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_open_error')),
),
);
} else { } else {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( ToastService.info(tr('msg.userfront.dashboard.link_missing'));
SnackBar(
content: Text(tr('msg.userfront.dashboard.link_missing')),
),
);
} }
}, },
child: opaqueCard, child: opaqueCard,
@@ -1489,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
double _historySessionColumnWidth(double maxWidth) { double _historySessionColumnWidth(double maxWidth) {
return math.max( return math.min(
_historySessionMinWidth, 200.0,
maxWidth - _historyOtherColumnsBaselineWidth, math.max(
_historySessionMinWidth,
maxWidth - _historyOtherColumnsBaselineWidth,
),
); );
} }

View File

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

View File

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