From 118e0042949b15a7f2abbad4fc717e410919a82a Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 11:19:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?/api/v1/user/me=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20userfront=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?Unknown=20=EC=84=B8=EC=85=98=20=EC=8B=9C=EA=B0=84=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/openapi.yaml | 3 + backend/internal/domain/auth_models.go | 27 ++--- backend/internal/handler/auth_handler.go | 58 +++++++--- .../auth_handler_session_profile_test.go | 105 ++++++++++++++++++ .../issue-434-dashboard-session-start-time.md | 45 ++++++++ .../domain/session_time_resolver.dart | 50 +++++++++ .../presentation/dashboard_screen.dart | 32 +----- .../data/models/user_profile_model.dart | 5 + .../dashboard_session_time_resolver_test.dart | 35 ++++++ 9 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 backend/internal/handler/auth_handler_session_profile_test.go create mode 100644 docs/trouble-shooting/issue-434-dashboard-session-start-time.md create mode 100644 userfront/lib/features/dashboard/domain/session_time_resolver.dart create mode 100644 userfront/test/dashboard_session_time_resolver_test.dart diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 10ced54f..60679946 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -1101,6 +1101,9 @@ components: type: string phone: type: string + sessionAuthenticatedAt: + type: string + format: date-time department: type: string affiliationType: diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b81181fe..f947413c 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -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 { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3fabec6e..5b6b98ae 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4886,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) { @@ -4993,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) { @@ -5158,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 diff --git a/backend/internal/handler/auth_handler_session_profile_test.go b/backend/internal/handler/auth_handler_session_profile_test.go new file mode 100644 index 00000000..6b657b7d --- /dev/null +++ b/backend/internal/handler/auth_handler_session_profile_test.go @@ -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"]) +} diff --git a/docs/trouble-shooting/issue-434-dashboard-session-start-time.md b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md new file mode 100644 index 00000000..13067edf --- /dev/null +++ b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md @@ -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 문구를 유지합니다. diff --git a/userfront/lib/features/dashboard/domain/session_time_resolver.dart b/userfront/lib/features/dashboard/domain/session_time_resolver.dart new file mode 100644 index 00000000..feb0210f --- /dev/null +++ b/userfront/lib/features/dashboard/domain/session_time_resolver.dart @@ -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; + 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; + } +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 342d0940..d4a79714 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -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'; @@ -404,32 +405,6 @@ class _DashboardScreenState extends ConsumerState { } } - 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; - 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'); @@ -716,7 +691,10 @@ class _DashboardScreenState extends ConsumerState { 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, diff --git a/userfront/lib/features/profile/data/models/user_profile_model.dart b/userfront/lib/features/profile/data/models/user_profile_model.dart index 9485a836..eb73b30d 100644 --- a/userfront/lib/features/profile/data/models/user_profile_model.dart +++ b/userfront/lib/features/profile/data/models/user_profile_model.dart @@ -33,6 +33,7 @@ class UserProfile { final String department; final String affiliationType; final String companyCode; + final String? sessionAuthenticatedAt; final Map? 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.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, ); } diff --git a/userfront/test/dashboard_session_time_resolver_test.dart b/userfront/test/dashboard_session_time_resolver_test.dart new file mode 100644 index 00000000..f3b72384 --- /dev/null +++ b/userfront/test/dashboard_session_time_resolver_test.dart @@ -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'); + }); +} From 5bb10ba1e610792dd4472ebb04887e532abba46c Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 13:00:24 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EC=A0=91=EC=86=8D=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20Session=20ID=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=B5=9C=EB=8C=80=ED=8F=AD=20=EC=A0=9C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/presentation/dashboard_screen.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d4a79714..fc6a9767 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1440,9 +1440,12 @@ class _DashboardScreenState extends ConsumerState { } double _historySessionColumnWidth(double maxWidth) { - return math.max( - _historySessionMinWidth, - maxWidth - _historyOtherColumnsBaselineWidth, + return math.min( + 200.0, + math.max( + _historySessionMinWidth, + maxWidth - _historyOtherColumnsBaselineWidth, + ), ); } From 650c65c8882f0727f99fc2066aea0e1a3edd9069 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 13:24:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B0=80=EC=9E=85=20=EC=8B=9C=20External=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=86=8C=EC=86=8D=20=ED=91=9C=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/lib/features/auth/presentation/signup_screen.dart | 4 +--- .../lib/features/dashboard/presentation/dashboard_screen.dart | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 715c993d..0a406182 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -316,9 +316,7 @@ class _SignupScreenState extends State { phone: _phoneController.text.trim(), affiliationType: _affiliationType, companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null, - department: _deptController.text.trim().isEmpty - ? (_affiliationType == 'GENERAL' ? 'External' : '') - : _deptController.text.trim(), + department: _deptController.text.trim(), termsAccepted: true, ); if (mounted) _showSuccessDialog(); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index fc6a9767..87e58cdb 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -687,7 +687,7 @@ class _DashboardScreenState extends ConsumerState { 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'); From 1951336307a72a783b48b0f45b47220e82de60b9 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 13:43:20 +0900 Subject: [PATCH 4/6] =?UTF-8?q?oathkeeper-introspect=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EC=95=B1=20=EB=AA=A9=EB=A1=9D=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 43 ++++++ backend/internal/handler/dev_handler_test.go | 132 +++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index ec3cb21a..61d08585 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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 diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d42b99f5..a7c03619 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -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 } From 839fabd056c652514cc885ddbca5c2b80e830a38 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 14:36:04 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20Caps?= =?UTF-8?q?=20Lock=20=ED=99=9C=EC=84=B1=ED=99=94=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/assets/translations/en.toml | 1 + userfront/assets/translations/ko.toml | 1 + userfront/assets/translations/template.toml | 1 + .../auth/presentation/login_screen.dart | 85 +++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 538eeb5b..8a92c33a 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -159,6 +159,7 @@ resend_wait = "You can resend in {time}." short_code_help = "You can also sign in with the last 2 letters and 6 digits from the link you received." [msg.userfront.login.password] +caps_lock_on = "Caps Lock is on." failed = "Sign-in failed: {error}" missing_credentials = "Enter both your email or phone number and your password." diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 18d2b303..8b686a44 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -158,6 +158,7 @@ resend_wait = "재발송은 {time} 후 가능합니다." short_code_help = "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다." [msg.userfront.login.password] +caps_lock_on = "Caps Lock이 켜져 있습니다." failed = "로그인 실패: {error}" missing_credentials = "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 44c85800..9659ea55 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -158,6 +158,7 @@ resend_wait = "" short_code_help = "" [msg.userfront.login.password] +caps_lock_on = "" failed = "" missing_credentials = "" diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 89365e12..dad569bf 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -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'; @@ -43,8 +44,10 @@ class _LoginScreenState extends ConsumerState final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final FocusNode _passwordFocusNode = FocusNode(); String? _redirectUrl; String? _loginChallenge; + bool _isPasswordCapsLockOn = false; // QR Login Variables String? _qrImageBase64; @@ -93,6 +96,8 @@ class _LoginScreenState extends ConsumerState _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; @@ -154,6 +159,40 @@ class _LoginScreenState extends ConsumerState }); } + 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 _tryCookieSession({bool silent = true}) async { final loginChallenge = _loginChallenge; final token = AuthTokenStore.getToken(); @@ -936,6 +975,10 @@ class _LoginScreenState extends ConsumerState _linkIdController.dispose(); _passwordLoginIdController.dispose(); _passwordController.dispose(); + _passwordFocusNode + ..removeListener(_handlePasswordFocusChange) + ..dispose(); + HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent); _shortCodePrefixController.dispose(); _shortCodeDigitsController.dispose(); _linkResendTimer?.cancel(); @@ -1299,6 +1342,24 @@ class _LoginScreenState extends ConsumerState ); } + 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) { @@ -1410,6 +1471,7 @@ class _LoginScreenState extends ConsumerState key: const ValueKey( 'password_login_password_input', ), + focusNode: _passwordFocusNode, controller: _passwordController, obscureText: true, decoration: InputDecoration( @@ -1423,6 +1485,29 @@ class _LoginScreenState extends ConsumerState ), 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( From 5a768d938a2e384fd9842d24694b664883b02d78 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 15:53:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?code=20check=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/assets/translations/en.toml | 1 - userfront/assets/translations/ko.toml | 1 - userfront/assets/translations/template.toml | 1 - .../lib/features/dashboard/presentation/dashboard_screen.dart | 3 ++- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 8a92c33a..538eeb5b 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -159,7 +159,6 @@ resend_wait = "You can resend in {time}." short_code_help = "You can also sign in with the last 2 letters and 6 digits from the link you received." [msg.userfront.login.password] -caps_lock_on = "Caps Lock is on." failed = "Sign-in failed: {error}" missing_credentials = "Enter both your email or phone number and your password." diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 8b686a44..18d2b303 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -158,7 +158,6 @@ resend_wait = "재발송은 {time} 후 가능합니다." short_code_help = "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다." [msg.userfront.login.password] -caps_lock_on = "Caps Lock이 켜져 있습니다." failed = "로그인 실패: {error}" missing_credentials = "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 9659ea55..44c85800 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -158,7 +158,6 @@ resend_wait = "" short_code_help = "" [msg.userfront.login.password] -caps_lock_on = "" failed = "" missing_credentials = "" diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 87e58cdb..11f80c7a 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -687,7 +687,8 @@ class _DashboardScreenState extends ConsumerState { profile?.email ?? profile?.phone ?? tr('ui.userfront.profile.user_fallback', fallback: 'User'); - final departmentValue = profile?.tenant?.name ?? profile?.department ?? ''; + final departmentValue = + profile?.tenant?.name ?? profile?.department ?? ''; final department = departmentValue.isNotEmpty ? departmentValue : tr('ui.userfront.profile.department_empty');