forked from baron/baron-sso
Merge branch 'dev' into feature/af-ui
This commit is contained in:
@@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
|
||||
# OIDC 클라이언트 callback (콤마 구분)
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
|
||||
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
|
||||
@@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
||||
CSRF_COOKIE_SECRET=localcsrf123
|
||||
|
||||
# AdminFront OIDC 설정
|
||||
ADMINFRONT_URL=http://localhost:5173
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
|
||||
|
||||
# DevFront OIDC 설정
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
DEVFRONT_URL=http://localhost:5174
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
|
||||
|
||||
@@ -87,6 +87,8 @@ jobs:
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
@@ -161,14 +163,11 @@ jobs:
|
||||
|
||||
docker compose -f staging_pull_compose.yaml pull
|
||||
|
||||
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
|
||||
docker compose -f staging_pull_compose.yaml down || true
|
||||
|
||||
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
|
||||
docker compose -f staging_pull_compose.yaml build --pull
|
||||
|
||||
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
|
||||
docker compose -f staging_pull_compose.yaml up -d --force-recreate init-rp
|
||||
docker compose -f staging_pull_compose.yaml up -d init-rp
|
||||
|
||||
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
|
||||
sleep 10
|
||||
|
||||
14
Makefile
14
Makefile
@@ -117,6 +117,10 @@ endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
CODE_CHECK_TEST_JOBS ?= 1
|
||||
PLAYWRIGHT_WORKERS ?= 1
|
||||
FLUTTER_TEST_CONCURRENCY ?= 1
|
||||
|
||||
code-check: code-check-lint code-check-test-jobs
|
||||
@echo "code-check complete."
|
||||
|
||||
@@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c
|
||||
|
||||
code-check-test-jobs:
|
||||
@echo "==> run CI-equivalent test jobs (parallel)"
|
||||
@$(MAKE) --no-print-directory -j5 --output-sync=target \
|
||||
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
|
||||
code-check-backend-tests \
|
||||
code-check-userfront-tests \
|
||||
code-check-userfront-e2e-tests \
|
||||
@@ -203,11 +207,11 @@ code-check-userfront-tests:
|
||||
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
|
||||
fi; \
|
||||
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
|
||||
cd "$$tmp_dir/userfront" && flutter test
|
||||
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
|
||||
|
||||
code-check-adminfront-tests:
|
||||
@echo "==> adminfront tests"
|
||||
./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
|
||||
|
||||
code-check-devfront-tests:
|
||||
@echo "==> devfront tests"
|
||||
@@ -219,7 +223,7 @@ code-check-devfront-tests:
|
||||
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
|
||||
fi; \
|
||||
if [ $$status -eq 0 ]; then \
|
||||
(cd devfront && npm test) || status=$$?; \
|
||||
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||
@@ -267,7 +271,7 @@ code-check-userfront-e2e-tests:
|
||||
if [ $$status -eq 0 ]; then \
|
||||
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
|
||||
echo "==> userfront-e2e using PORT=$$port"; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
|
||||
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
|
||||
fi; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
|
||||
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
|
||||
|
||||
@@ -322,6 +322,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
||||
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다.
|
||||
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
|
||||
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
|
||||
|
||||
## 🧪 Code Check CI
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
@@ -24,7 +28,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -1101,6 +1101,9 @@ components:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
sessionAuthenticatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
department:
|
||||
type: string
|
||||
affiliationType:
|
||||
|
||||
@@ -68,19 +68,20 @@ type SignupRequest struct {
|
||||
// User Profile Models
|
||||
|
||||
type UserProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
|
||||
@@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
name = clientID
|
||||
}
|
||||
|
||||
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
clientURL := resolveLinkedRPURL(
|
||||
client.ClientID,
|
||||
client.ClientURI,
|
||||
client.RedirectURIs,
|
||||
)
|
||||
|
||||
lastAuth := time.Time{}
|
||||
if session.AuthenticatedAt != nil {
|
||||
@@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
name = client.ClientID
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
clientURL := resolveLinkedRPURL(
|
||||
client.ClientID,
|
||||
client.ClientURI,
|
||||
client.RedirectURIs,
|
||||
)
|
||||
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
@@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
||||
identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken)
|
||||
return identityID, traits, err
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) {
|
||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||
if kratosURL == "" {
|
||||
kratosURL = "http://kratos:4433"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Identity struct {
|
||||
AuthenticatedAt string `json:"authenticated_at"`
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||
@@ -4996,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
||||
identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||
return identityID, traits, err
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) {
|
||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||
if kratosURL == "" {
|
||||
kratosURL = "http://kratos:4433"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
req.Header.Set("Cookie", cookie)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Identity struct {
|
||||
AuthenticatedAt string `json:"authenticated_at"`
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
} `json:"identity"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
return result.Identity.ID, result.Identity.Traits, nil
|
||||
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
||||
@@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
||||
return profile
|
||||
}
|
||||
|
||||
func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
|
||||
return profile
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
||||
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
||||
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||
return h.applySessionAuthenticatedAtFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
||||
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
|
||||
identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||
return h.applySessionAuthenticatedAtFromWhoami(
|
||||
h.mapKratosIdentityToProfile(identityID, traits),
|
||||
authenticatedAt,
|
||||
), nil
|
||||
}
|
||||
|
||||
// UpdateMe - Updates current user's profile with phone verification check
|
||||
@@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
|
||||
switch strings.TrimSpace(clientID) {
|
||||
case "adminfront":
|
||||
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
case "devfront":
|
||||
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(clientURI)
|
||||
if clientURL != "" {
|
||||
return clientURL
|
||||
}
|
||||
|
||||
if len(redirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
|
||||
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeScopes(current []string, next []string) []string {
|
||||
if len(next) == 0 {
|
||||
return current
|
||||
|
||||
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal file
105
backend/internal/handler/auth_handler_session_profile_test.go
Normal 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"])
|
||||
}
|
||||
@@ -142,6 +142,10 @@ type clientUpsertRequest struct {
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
var protectedSystemClientIDs = map[string]struct{}{
|
||||
"oathkeeper-introspect": {},
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
return domain.NormalizeRole(role)
|
||||
}
|
||||
@@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string {
|
||||
return strings.TrimSpace(profile.Role)
|
||||
}
|
||||
|
||||
func isProtectedSystemClientID(clientID string) bool {
|
||||
_, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isProtectedSystemClient(client domain.HydraClient) bool {
|
||||
return isProtectedSystemClientID(client.ClientID)
|
||||
}
|
||||
|
||||
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if (!ok || profile == nil) && h.Auth != nil {
|
||||
@@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if isProtectedSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(client)
|
||||
|
||||
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||
@@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*client) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*client)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if clientID == "" {
|
||||
clientID = uuid.NewString()
|
||||
}
|
||||
if isProtectedSystemClientID(clientID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(valueOr(req.Name, ""))
|
||||
if name == "" {
|
||||
@@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
currentSummary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
if isProtectedSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||
var totalClients int64
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if isProtectedSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
if isSuperAdmin {
|
||||
totalClients++
|
||||
continue
|
||||
|
||||
@@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
||||
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res clientListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Len(t, res.Items, 1)
|
||||
assert.Equal(t, "client-1", res.Items[0].ID)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
assert.Equal(t, "inactive", res.Client.Status)
|
||||
}
|
||||
|
||||
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "oathkeeper-introspect",
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestDeleteClient_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
|
||||
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||
"client_id": "oathkeeper-introspect",
|
||||
"client_name": "Internal Client",
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestRotateClientSecret_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
@@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
||||
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id adminfront \
|
||||
--name "AdminFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
@@ -220,6 +221,7 @@ services:
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id devfront \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
@@ -20,7 +24,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -275,31 +275,36 @@ services:
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint http://hydra:4445 $${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
|
||||
# Function to create or update OAuth2 client (Idempotency)
|
||||
upsert_client() {
|
||||
ID=$1
|
||||
shift
|
||||
if hydra get oauth2-client --endpoint http://hydra:4445 "$ID" >/dev/null 2>&1; then
|
||||
echo "Updating existing client: $ID"
|
||||
hydra update oauth2-client --endpoint http://hydra:4445 "$ID" "$@"
|
||||
else
|
||||
echo "Creating new client: $ID"
|
||||
hydra create oauth2-client --endpoint http://hydra:4445 --id "$ID" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id adminfront \
|
||||
upsert_client "adminfront" \
|
||||
--name "AdminFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id devfront \
|
||||
upsert_client "devfront" \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}"
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint http://hydra:4445 \
|
||||
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
|
||||
upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
|
||||
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
|
||||
--grant-type client_credentials \
|
||||
--response-type token \
|
||||
|
||||
19
docs/i18n.md
19
docs/i18n.md
@@ -111,6 +111,14 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
|
||||
#### 5.2.2 관리 프로세스 (Template & CI)
|
||||
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
|
||||
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
|
||||
3. **UserFront 런타임 리소스 동기화**:
|
||||
* UserFront는 런타임에 `userfront/assets/translations/*.toml`을 직접 읽습니다.
|
||||
* 따라서 `locales/ko.toml`, `locales/en.toml`, `locales/template.toml`을 수정한 뒤에는 반드시 아래 동기화 스크립트를 실행해야 합니다.
|
||||
* 실행 명령:
|
||||
```bash
|
||||
./scripts/sync_userfront_locales.sh
|
||||
```
|
||||
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
|
||||
3. **CI 검증 (Verification)**:
|
||||
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
|
||||
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
|
||||
@@ -244,3 +252,14 @@ UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시
|
||||
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
|
||||
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
|
||||
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.
|
||||
|
||||
### 6.1 UserFront 번역 수정 체크포인트
|
||||
|
||||
UserFront 번역을 수정할 때는 아래 순서를 기본 절차로 사용합니다.
|
||||
|
||||
1. `locales/*.toml` 수정
|
||||
2. `./scripts/sync_userfront_locales.sh` 실행
|
||||
3. UserFront 회귀 테스트 실행
|
||||
- 예: `cd userfront && flutter test test/english_locale_placeholder_test.dart`
|
||||
4. 전체 키 정합성 점검
|
||||
- 예: `node tools/i18n-scanner/index.js`
|
||||
|
||||
@@ -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 문구를 유지합니다.
|
||||
841
locales/en.toml
841
locales/en.toml
File diff suppressed because one or more lines are too long
@@ -849,6 +849,11 @@ symbol = "특수문자 1개 이상"
|
||||
uppercase = "대문자 1개 이상"
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||
progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료"
|
||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -1352,6 +1357,7 @@ security = "보안"
|
||||
[ui.userfront.signup.agreement]
|
||||
all = "모두 동의합니다"
|
||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||
required = "필수"
|
||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
|
||||
@@ -849,6 +849,11 @@ symbol = ""
|
||||
uppercase = ""
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = ""
|
||||
description = ""
|
||||
privacy_summary = ""
|
||||
progress = ""
|
||||
tos_summary = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -1352,6 +1357,7 @@ security = ""
|
||||
[ui.userfront.signup.agreement]
|
||||
all = ""
|
||||
privacy_title = ""
|
||||
required = ""
|
||||
tos_title = ""
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
|
||||
@@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
|
||||
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
|
||||
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
|
||||
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}"
|
||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}"
|
||||
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
|
||||
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
|
||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
|
||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
|
||||
|
||||
declare -a WARNINGS=()
|
||||
@@ -382,12 +384,21 @@ run_validation() {
|
||||
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
|
||||
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
|
||||
validate_dotenv_line_safety "KRATOS_UI_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_URL"
|
||||
validate_dotenv_line_safety "DEVFRONT_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
|
||||
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
|
||||
|
||||
if [[ -n "$ADMINFRONT_URL" ]]; then
|
||||
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
|
||||
fi
|
||||
if [[ -n "$DEVFRONT_URL" ]]; then
|
||||
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
|
||||
fi
|
||||
|
||||
collect_values
|
||||
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
|
||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}"
|
||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
|
||||
validate_gateway_mapping
|
||||
build_allowed_return_urls
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI;
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
|
||||
@@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
@@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await page.unroute('**/api/v1/**');
|
||||
});
|
||||
|
||||
test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
@@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
expect(state.putBodies[0]?.department).toBe('QA-Updated');
|
||||
@@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
expect(state.department).toBe('QA');
|
||||
});
|
||||
|
||||
test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
|
||||
const state: ProfileState = {
|
||||
department: 'QA',
|
||||
getMeCount: 0,
|
||||
@@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
await page.reload();
|
||||
@@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
||||
await blurDepartmentEditor(page);
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||
|
||||
expect(state.putBodies[0]?.department).toBe('QA-1');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..."
|
||||
tos_full = "서비스 이용약관 전문..."
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다."
|
||||
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
|
||||
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
|
||||
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
|
||||
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
|
||||
title = "서비스 이용을 위해\n약관에 동의해주세요"
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -583,6 +588,7 @@ title = "회원가입"
|
||||
[ui.userfront.signup.agreement]
|
||||
all = "모두 동의합니다"
|
||||
privacy_title = "개인정보 수집 및 이용 동의 (필수)"
|
||||
required = "필수"
|
||||
tos_title = "바론 소프트웨어 이용약관 (필수)"
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -616,4 +622,3 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -277,6 +277,11 @@ privacy_full = ""
|
||||
tos_full = ""
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
all_hint = ""
|
||||
description = ""
|
||||
privacy_summary = ""
|
||||
progress = ""
|
||||
tos_summary = ""
|
||||
title = ""
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
@@ -583,6 +588,7 @@ title = ""
|
||||
[ui.userfront.signup.agreement]
|
||||
all = ""
|
||||
privacy_title = ""
|
||||
required = ""
|
||||
tos_title = ""
|
||||
|
||||
[ui.userfront.signup.auth]
|
||||
@@ -616,4 +622,3 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
|
||||
234
userfront/lib/core/ui/toast_service.dart
Normal file
234
userfront/lib/core/ui/toast_service.dart
Normal 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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class CreateUserScreen extends StatefulWidget {
|
||||
const CreateUserScreen({super.key});
|
||||
@@ -86,12 +87,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password. Access Denied.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
ToastService.error('Invalid Password. Access Denied.');
|
||||
context.go(buildLocalizedHomePath(Uri.base)); // Kick out
|
||||
}
|
||||
}
|
||||
@@ -144,12 +140,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('User created successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success('User created successfully!');
|
||||
_formKey.currentState!.reset();
|
||||
_loginIdController.clear();
|
||||
_emailController.clear();
|
||||
@@ -158,9 +149,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error('Error: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
|
||||
class UserManagementScreen extends StatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
@@ -108,12 +109,7 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Invalid Password'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
ToastService.error('Invalid Password');
|
||||
context.go(buildLocalizedHomePath(Uri.base));
|
||||
}
|
||||
}
|
||||
@@ -343,16 +339,12 @@ class _UserManagementScreenState extends State<UserManagementScreen>
|
||||
// --- UI Helpers ---
|
||||
void _showError(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
|
||||
ToastService.error(msg);
|
||||
}
|
||||
|
||||
void _showSuccess(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
|
||||
ToastService.success(msg);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
class ConsentScreen extends StatefulWidget {
|
||||
final String consentChallenge;
|
||||
@@ -187,16 +188,11 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback:
|
||||
'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.consent.cancel.error',
|
||||
fallback: 'An error occurred while cancelling consent: {{error}}',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -237,10 +233,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientName =
|
||||
_consentInfo?['client']?['client_name'] ??
|
||||
tr('msg.userfront.consent.client_unknown');
|
||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||
final clientRawName = _consentInfo?['client']?['client_name'] as String?;
|
||||
final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-';
|
||||
final clientName = (clientRawName != null && clientRawName.isNotEmpty)
|
||||
? clientRawName
|
||||
: (clientId != '-'
|
||||
? clientId
|
||||
: tr('msg.userfront.consent.client_unknown'));
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
@@ -419,7 +418,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
)
|
||||
: Text(
|
||||
tr('ui.userfront.consent.accept'),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
@@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.forgot.sent')),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
bool _parseBoolParam(String? value) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
@@ -18,6 +19,7 @@ import '../domain/cookie_session_policy.dart';
|
||||
import '../domain/login_link_route_policy.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../../../core/services/web_window.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
final String? verificationToken;
|
||||
@@ -42,8 +44,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final TextEditingController _passwordLoginIdController =
|
||||
TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
String? _redirectUrl;
|
||||
String? _loginChallenge;
|
||||
bool _isPasswordCapsLockOn = false;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
@@ -92,6 +96,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
_redirectUrl = widget.redirectUrl;
|
||||
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
||||
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final uri = Uri.base;
|
||||
@@ -153,6 +159,40 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _handlePasswordFocusChange() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (_passwordFocusNode.hasFocus) {
|
||||
_syncPasswordCapsLockState();
|
||||
return;
|
||||
}
|
||||
if (_isPasswordCapsLockOn) {
|
||||
setState(() {
|
||||
_isPasswordCapsLockOn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleHardwareKeyEvent(KeyEvent event) {
|
||||
if (_passwordFocusNode.hasFocus) {
|
||||
_syncPasswordCapsLockState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _syncPasswordCapsLockState() {
|
||||
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
|
||||
KeyboardLockMode.capsLock,
|
||||
);
|
||||
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isPasswordCapsLockOn = isEnabled;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||
final loginChallenge = _loginChallenge;
|
||||
final token = AuthTokenStore.getToken();
|
||||
@@ -935,6 +975,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_linkIdController.dispose();
|
||||
_passwordLoginIdController.dispose();
|
||||
_passwordController.dispose();
|
||||
_passwordFocusNode
|
||||
..removeListener(_handlePasswordFocusChange)
|
||||
..dispose();
|
||||
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
|
||||
_shortCodePrefixController.dispose();
|
||||
_shortCodeDigitsController.dispose();
|
||||
_linkResendTimer?.cancel();
|
||||
@@ -1153,9 +1197,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
try {
|
||||
AuthProxyService.logError(message);
|
||||
} catch (e) {
|
||||
@@ -1165,9 +1207,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _showInfo(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
||||
);
|
||||
ToastService.success(message);
|
||||
}
|
||||
|
||||
void _logTokenDetails(String jwt) {
|
||||
@@ -1302,6 +1342,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _capsLockWarningText(BuildContext context) {
|
||||
const key = 'msg.userfront.login.password.caps_lock_on';
|
||||
final languageCode = Localizations.localeOf(context).languageCode;
|
||||
if (languageCode == 'ko') {
|
||||
final translated = tr(key);
|
||||
if (translated != key) {
|
||||
return translated;
|
||||
}
|
||||
return 'Caps Lock이 켜져 있습니다.';
|
||||
}
|
||||
|
||||
final translated = tr(key, fallback: 'Caps Lock is on.');
|
||||
if (translated != key) {
|
||||
return translated;
|
||||
}
|
||||
return 'Caps Lock is on.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_verificationOnly && _verificationApproved) {
|
||||
@@ -1413,6 +1471,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
key: const ValueKey(
|
||||
'password_login_password_input',
|
||||
),
|
||||
focusNode: _passwordFocusNode,
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -1426,6 +1485,29 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
if (_isPasswordCapsLockOn) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.keyboard_capslock_rounded,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_capsLockWarningText(context),
|
||||
style: const TextStyle(
|
||||
color: Colors.orange,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
key: const ValueKey(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import 'package:userfront/core/ui/toast_service.dart';
|
||||
|
||||
import 'qr_scan_route.dart';
|
||||
|
||||
@@ -23,15 +24,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
void _submit() {
|
||||
final raw = _controller.text.trim();
|
||||
if (raw.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.qr.permission_required',
|
||||
fallback: '카메라 권한이 필요합니다.',
|
||||
),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
@@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.reset.success')),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
ToastService.error(message);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../domain/session_time_resolver.dart';
|
||||
import '../domain/providers/linked_rps_provider.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
@@ -15,6 +16,7 @@ import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/widgets/language_selector.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../domain/dashboard_providers.dart';
|
||||
import '../domain/models.dart' hide LinkedRp;
|
||||
@@ -104,14 +106,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
try {
|
||||
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.success',
|
||||
params: {'app': appName},
|
||||
),
|
||||
),
|
||||
ToastService.success(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.success',
|
||||
params: {'app': appName},
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
@@ -121,15 +119,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.dashboard.revoke.error',
|
||||
params: {'error': '$e'},
|
||||
),
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -414,32 +405,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload) as Map<String, dynamic>;
|
||||
final iatValue = data['iat'] ?? data['auth_time'];
|
||||
if (iatValue is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
iatValue.toInt() * 1000,
|
||||
).toLocal();
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final yyyy = dateTime.year.toString().padLeft(4, '0');
|
||||
final mm = dateTime.month.toString().padLeft(2, '0');
|
||||
@@ -547,12 +512,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -626,12 +587,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
),
|
||||
),
|
||||
ToastService.info(
|
||||
tr('msg.userfront.dashboard.session_id_copied'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -730,11 +687,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
profile?.email ??
|
||||
profile?.phone ??
|
||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
||||
final departmentValue = profile?.department ?? '';
|
||||
final departmentValue =
|
||||
profile?.tenant?.name ?? profile?.department ?? '';
|
||||
final department = departmentValue.isNotEmpty
|
||||
? departmentValue
|
||||
: tr('ui.userfront.profile.department_empty');
|
||||
final sessionIssuedAt = _getJwtIssuedAt();
|
||||
final sessionIssuedAt = resolveDashboardSessionIssuedAt(
|
||||
token: AuthTokenStore.getToken(),
|
||||
profile: profile,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _subtle,
|
||||
@@ -1280,7 +1241,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final itemUrl = item.url;
|
||||
if (itemUrl != null && itemUrl.isNotEmpty) {
|
||||
final uri = Uri.parse(itemUrl);
|
||||
@@ -1290,18 +1250,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
await launchUrl(uri);
|
||||
return;
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.dashboard.link_open_error')),
|
||||
),
|
||||
);
|
||||
ToastService.error(tr('msg.userfront.dashboard.link_open_error'));
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.dashboard.link_missing')),
|
||||
),
|
||||
);
|
||||
ToastService.info(tr('msg.userfront.dashboard.link_missing'));
|
||||
}
|
||||
},
|
||||
child: opaqueCard,
|
||||
@@ -1489,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
double _historySessionColumnWidth(double maxWidth) {
|
||||
return math.max(
|
||||
_historySessionMinWidth,
|
||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||
return math.min(
|
||||
200.0,
|
||||
math.max(
|
||||
_historySessionMinWidth,
|
||||
maxWidth - _historyOtherColumnsBaselineWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class UserProfile {
|
||||
final String department;
|
||||
final String affiliationType;
|
||||
final String companyCode;
|
||||
final String? sessionAuthenticatedAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final Tenant? tenant;
|
||||
|
||||
@@ -44,6 +45,7 @@ class UserProfile {
|
||||
required this.department,
|
||||
required this.affiliationType,
|
||||
required this.companyCode,
|
||||
this.sessionAuthenticatedAt,
|
||||
this.metadata,
|
||||
this.tenant,
|
||||
});
|
||||
@@ -57,6 +59,7 @@ class UserProfile {
|
||||
department: json['department'] ?? '',
|
||||
affiliationType: json['affiliationType'] ?? '',
|
||||
companyCode: json['companyCode'] ?? '',
|
||||
sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, dynamic>.from(json['metadata'])
|
||||
: null,
|
||||
@@ -73,6 +76,7 @@ class UserProfile {
|
||||
'department': department,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
'sessionAuthenticatedAt': sessionAuthenticatedAt,
|
||||
'metadata': metadata,
|
||||
'tenant': tenant?.toJson(),
|
||||
};
|
||||
@@ -87,6 +91,7 @@ class UserProfile {
|
||||
department: department ?? this.department,
|
||||
affiliationType: affiliationType,
|
||||
companyCode: companyCode,
|
||||
sessionAuthenticatedAt: sessionAuthenticatedAt,
|
||||
tenant: tenant,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
import '../../../../core/widgets/language_selector.dart';
|
||||
import '../../data/models/user_profile_model.dart';
|
||||
import '../../domain/notifiers/profile_notifier.dart';
|
||||
@@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
final FocusNode _departmentFocus = FocusNode();
|
||||
final FocusNode _phoneFocus = FocusNode();
|
||||
final FocusNode _phoneCodeFocus = FocusNode();
|
||||
bool _nameTouched = false;
|
||||
bool _departmentTouched = false;
|
||||
bool _phoneTouched = false;
|
||||
bool _phoneCodeTouched = false;
|
||||
bool _isSavingField = false;
|
||||
String? _skipAutoSaveField;
|
||||
String? _fieldSaveError;
|
||||
|
||||
String _initialPhone = '';
|
||||
bool _isPhoneChanged = false;
|
||||
@@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameFocus.addListener(_onNameFocusChange);
|
||||
_departmentFocus.addListener(_onDepartmentFocusChange);
|
||||
_phoneFocus.addListener(_onPhoneFocusChange);
|
||||
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
|
||||
}
|
||||
|
||||
void _debugLog(
|
||||
@@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_log.fine(parts.join(' '));
|
||||
}
|
||||
|
||||
void _onNameFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
||||
});
|
||||
} else if (_nameFocus.hasFocus) {
|
||||
_nameTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDepartmentFocusChange() {
|
||||
if (!mounted) return;
|
||||
_debugLog(
|
||||
'department_focus_change',
|
||||
field: 'department',
|
||||
hasFocus: _departmentFocus.hasFocus,
|
||||
);
|
||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
||||
});
|
||||
} else if (_departmentFocus.hasFocus) {
|
||||
_departmentTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPhoneFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneFocus.hasFocus && _phoneTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneFocus.hasFocus) {
|
||||
_phoneTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPhoneCodeFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneCodeFocus.hasFocus) {
|
||||
_phoneCodeTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController?.dispose();
|
||||
@@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isCodeSent = false;
|
||||
_isVerifying = false;
|
||||
_codeController?.clear();
|
||||
_phoneTouched = false;
|
||||
_phoneCodeTouched = false;
|
||||
}
|
||||
|
||||
void _startEditing(String field, UserProfile profile) {
|
||||
_debugLog('start_editing', field: field);
|
||||
setState(() {
|
||||
_editingField = field;
|
||||
_fieldSaveError = null;
|
||||
if (field == 'name') {
|
||||
_nameController?.text = profile.name;
|
||||
} else if (field == 'department') {
|
||||
@@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,21 +203,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))),
|
||||
);
|
||||
ToastService.info(tr('msg.userfront.profile.phone.code_sent'));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.send_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.send_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -303,24 +231,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_isVerifying = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))),
|
||||
);
|
||||
}
|
||||
if (_editingField == 'phone') {
|
||||
await _saveField(profile);
|
||||
ToastService.success(tr('msg.userfront.profile.phone.verified'));
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isVerifying = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.verify_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.phone.verify_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_newPasswordController?.clear();
|
||||
_confirmPasswordController?.clear();
|
||||
setState(() {
|
||||
_passwordSuccess = tr('msg.userfront.profile.password.changed');
|
||||
_passwordSuccess = null;
|
||||
});
|
||||
ToastService.success(tr('msg.userfront.profile.password.changed'));
|
||||
} catch (e) {
|
||||
final message = e.toString().replaceFirst('Exception: ', '');
|
||||
setState(() {
|
||||
@@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
params: {'error': message},
|
||||
);
|
||||
});
|
||||
ToastService.error(
|
||||
tr(
|
||||
'msg.userfront.profile.password.change_failed',
|
||||
params: {'error': message},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isPasswordSaving = false);
|
||||
@@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||
if (_editingField != field) return;
|
||||
if (_skipAutoSaveField == field) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'skip_flag');
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'verifying');
|
||||
return;
|
||||
}
|
||||
if (_isSavingField) {
|
||||
_debugLog('autosave_skip', field: field, reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
if (!_hasFieldChanged(profile, field)) {
|
||||
_debugLog(
|
||||
'autosave_skip',
|
||||
field: field,
|
||||
reason: 'unchanged',
|
||||
changed: false,
|
||||
);
|
||||
setState(() {
|
||||
if (field == 'phone') {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
if (field == 'name') {
|
||||
_nameTouched = false;
|
||||
} else if (field == 'department') {
|
||||
_departmentTouched = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_debugLog('autosave_trigger', field: field, changed: true);
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
void _handlePhoneFocusChange(UserProfile profile) {
|
||||
if (_editingField != 'phone') return;
|
||||
if (_skipAutoSaveField == 'phone') {
|
||||
_skipAutoSaveField = null;
|
||||
return;
|
||||
}
|
||||
if (_isVerifying) return;
|
||||
if (_isSavingField) return;
|
||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||
if (!_hasFieldChanged(profile, 'phone')) {
|
||||
setState(() {
|
||||
_resetPhoneState();
|
||||
_editingField = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
_saveField(profile);
|
||||
}
|
||||
|
||||
bool _hasFieldChanged(UserProfile profile, String field) {
|
||||
if (field == 'name') {
|
||||
return (_nameController?.text.trim() ?? '') != profile.name;
|
||||
@@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_debugLog('save_skip', reason: 'saving_in_flight');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
|
||||
final currentField = _editingField!;
|
||||
|
||||
final nextName = currentField == 'name'
|
||||
@@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
if (currentField == 'name' && nextName.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_name');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.name_required'))),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.name_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentField == 'department' && nextDepartment.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_department');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.department_required')),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.department_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentField == 'phone') {
|
||||
if (nextPhone.isEmpty) {
|
||||
_debugLog('save_skip', field: currentField, reason: 'empty_phone');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.phone_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||
@@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
field: currentField,
|
||||
reason: 'phone_not_verified',
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(tr('msg.userfront.profile.phone_verify_required')),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr('msg.userfront.profile.phone_verify_required');
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_isSavingField = true;
|
||||
setState(() {
|
||||
_isSavingField = true;
|
||||
});
|
||||
|
||||
_debugLog('save_dispatch', field: currentField, changed: true);
|
||||
|
||||
try {
|
||||
@@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
_resetPhoneState();
|
||||
}
|
||||
_editingField = null;
|
||||
_nameTouched = false;
|
||||
_departmentTouched = false;
|
||||
});
|
||||
_debugLog('save_success', field: currentField);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('msg.userfront.profile.update_success'))),
|
||||
);
|
||||
ToastService.success(tr('msg.userfront.profile.update_success'));
|
||||
}
|
||||
} catch (e) {
|
||||
_debugLog('save_failed', field: currentField, reason: e.toString());
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.profile.update_failed',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_fieldSaveError = tr(
|
||||
'msg.userfront.profile.update_failed',
|
||||
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||
);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_isSavingField = false;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSavingField = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasChanged = _hasFieldChanged(profile, field);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, field),
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
errorText: _fieldSaveError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = field;
|
||||
},
|
||||
child: OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: Key('profile-$field-save-button'),
|
||||
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasChanged = _hasFieldChanged(profile, 'phone');
|
||||
final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
focusNode: _phoneFocus,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'),
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: '01012345678',
|
||||
errorText: _fieldSaveError,
|
||||
suffixIcon: _isPhoneVerified
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: null,
|
||||
@@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
_skipAutoSaveField = 'phone';
|
||||
},
|
||||
child: OutlinedButton(
|
||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isUpdating || !canSave || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart';
|
||||
import 'core/i18n/locale_registry.dart';
|
||||
import 'core/i18n/locale_utils.dart';
|
||||
import 'core/i18n/toml_asset_loader.dart';
|
||||
import 'core/ui/toast_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'features/auth/presentation/consent_screen.dart';
|
||||
import 'i18n.dart';
|
||||
@@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget {
|
||||
localizationsDelegates: delegates,
|
||||
supportedLocales: supportedLocales,
|
||||
locale: locale,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [if (child != null) child, const ToastViewport()],
|
||||
);
|
||||
},
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||
|
||||
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal file
35
userfront/test/dashboard_session_time_resolver_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
106
userfront/test/english_locale_placeholder_test.dart
Normal file
106
userfront/test/english_locale_placeholder_test.dart
Normal 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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
131
userfront/test/profile_page_edit_flow_test.dart
Normal file
131
userfront/test/profile_page_edit_flow_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user