diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 28259b32..d1195a30 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4246,11 +4246,10 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { continue } consent, ok := consentMap[clientID] - if !ok { - continue - } - if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { - continue + if ok { + if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { + continue + } } oathkeeperLogs = append(oathkeeperLogs, log) if len(oathkeeperLogs) >= fetchLimit { @@ -4299,36 +4298,75 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { return info, true } + clientCache := make(map[string]loginClientInfo) + resolveClientByID := func(cid string) (loginClientInfo, bool) { + cid = strings.TrimSpace(cid) + if cid == "" || h.Hydra == nil { + return loginClientInfo{}, false + } + if cached, ok := clientCache[cid]; ok { + return cached, cached.ClientID != "" + } + client, err := h.Hydra.GetClient(c.Context(), cid) + if err != nil || client == nil { + clientCache[cid] = loginClientInfo{} + return loginClientInfo{}, false + } + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = cid + } + info := loginClientInfo{ + ClientID: cid, + Name: name, + } + clientCache[cid] = info + return info, true + } + items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs)) for i := range authLogs { log := authLogs[i] appName := "Baron 로그인" clientID := "" path := strings.ToLower(extractAuditPath(log)) - if strings.Contains(path, "/api/v1/auth/oidc/login/accept") { - appName = "OIDC 로그인" - // 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강 - if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { - if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { - appName = strings.TrimSpace(name) - } - if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { - clientID = strings.TrimSpace(cid) - if appName == "OIDC 로그인" { - appName = clientID - } - } + + isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept") + isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") + + // 우선 audit details의 client 정보를 사용 + if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { + if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { + clientID = strings.TrimSpace(cid) } - if appName == "OIDC 로그인" { - loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) - if loginChallenge != "" { - if info, ok := resolveLoginClient(loginChallenge); ok { - appName = info.Name - clientID = info.ClientID - } + if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { + appName = strings.TrimSpace(name) + } + } + + // 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강 + if appName == "Baron 로그인" || appName == "" { + if isOidcAccept { + appName = "OIDC 로그인" + } + if clientID != "" { + appName = clientID + if info, ok := resolveClientByID(clientID); ok { + appName = info.Name } } } + + if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) { + loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) + if loginChallenge != "" { + if info, ok := resolveLoginClient(loginChallenge); ok { + appName = info.Name + clientID = info.ClientID + } + } + } + item := authTimelineItem{ EventID: log.EventID, Timestamp: log.Timestamp, @@ -4353,11 +4391,17 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if clientID == "" { continue } - consent := consentMap[clientID] - appName := consent.Name - if appName == "" { - appName = clientID + + appName := clientID + if consent, ok := consentMap[clientID]; ok { + appName = consent.Name } + if appName == "" || appName == clientID { + if info, ok := resolveClientByID(clientID); ok { + appName = info.Name + } + } + details := map[string]any{ "path": log.Path, "client_id": clientID, @@ -4374,6 +4418,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { EventID: eventID, Timestamp: log.Timestamp, UserID: profile.ID, + SessionID: extractSessionIDFromOathkeeperLog(log), EventType: fmt.Sprintf("%s %s", log.Method, log.Path), Status: status, AuthMethod: "세션 위임", @@ -5697,6 +5742,16 @@ func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { return parseClientIDFromRaw(log.Raw) } +func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { + if value := parseSessionIDFromURL(log.Target); value != "" { + return value + } + if value := parseSessionIDFromURL(log.Path); value != "" { + return value + } + return parseSessionIDFromRaw(log.Raw) +} + func parseClientIDFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5715,6 +5770,23 @@ func parseClientIDFromURL(raw string) string { return "" } +func parseSessionIDFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" { + return id + } + } + return "" +} + func parseClientIDFromRaw(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5766,15 +5838,7 @@ func extractSessionIDFromAuditDetails(details string) string { if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } - if raw, ok := payload["session_id"]; ok { - switch value := raw.(type) { - case string: - return value - default: - return fmt.Sprint(value) - } - } - return "" + return readSessionIDFromAny(payload) } func extractApprovedSessionIDFromAuditDetails(details string) string { @@ -5804,6 +5868,51 @@ func extractApprovedSessionIDFromAuditDetails(details string) string { return "" } +func parseSessionIDFromRaw(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "" + } + return readSessionIDFromAny(payload) +} + +func readSessionIDFromAny(payload any) string { + switch value := payload.(type) { + case map[string]any: + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if raw, ok := value[key]; ok { + switch sid := raw.(type) { + case string: + if strings.TrimSpace(sid) != "" { + return strings.TrimSpace(sid) + } + default: + rendered := strings.TrimSpace(fmt.Sprint(sid)) + if rendered != "" && rendered != "" { + return rendered + } + } + } + } + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + case []any: + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + } + return "" +} + func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { id, _, _, err := h.getKratosIdentity(token) return id, err diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 7dfc0129..817daf86 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -616,3 +616,70 @@ func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { assert.Contains(t, err.Error(), "inactive") mockKratos.AssertExpectations(t) } + +func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { + now := time.Date(2026, 4, 7, 4, 39, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + h := &AuthHandler{ + AuditRepo: &mockAuditRepo{}, + OathkeeperRepo: &mockOathkeeperRepo{ + logs: []domain.OathkeeperAccessLog{ + { + Timestamp: now, + RequestID: "req-1", + Method: http.MethodGet, + Path: "/api/v1/dev/sessions", + Status: http.StatusOK, + Subject: "user-123", + ClientIP: "203.0.113.7", + UserAgent: "Mozilla/5.0", + Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`, + }, + }, + }, + } + + app := fiber.New() + app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + Source string `json:"source"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "target-sid", body.Items[0].SessionID) + assert.Equal(t, "devfront", body.Items[0].ClientID) + assert.Equal(t, "devfront", body.Items[0].AppName) + assert.Equal(t, "oathkeeper", body.Items[0].Source) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 32bd1d21..c499eb39 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -115,6 +115,25 @@ func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } +type mockOathkeeperRepo struct { + logs []domain.OathkeeperAccessLog +} + +func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) { + if subject == "" { + return m.logs, nil + } + results := make([]domain.OathkeeperAccessLog, 0, len(m.logs)) + for _, log := range m.logs { + if log.Subject == subject { + results = append(results, log) + } + } + return results, nil +} + +func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil } + // --- Mock Consent Repository --- type mockConsentRepo struct { diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 1fa793c6..f4a3550f 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -9,7 +9,7 @@ import { Save, Shield, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -44,7 +44,7 @@ function ClientDetailsPage() { const queryClient = useQueryClient(); const clientId = params.id ?? ""; - const { data, isLoading, error } = useQuery({ + const { data, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), enabled: clientId.length > 0, @@ -52,12 +52,18 @@ function ClientDetailsPage() { const [redirectUris, setRedirectUris] = useState(""); const [showSecret, setShowSecret] = useState(false); + const redirectUrisHydratedRef = useRef(false); useEffect(() => { - if (data?.client?.redirectUris) { + if ( + !redirectUrisHydratedRef.current && + data?.client?.redirectUris && + redirectUris === "" + ) { setRedirectUris(data.client.redirectUris.join(", ")); + redirectUrisHydratedRef.current = true; } - }, [data]); + }, [data, redirectUris]); const mutation = useMutation({ mutationFn: () => { @@ -129,15 +135,7 @@ function ClientDetailsPage() { ); } - if (isLoading) { - return ( -
- {t("msg.dev.clients.details.loading", "Loading app...")} -
- ); - } - - if (error || !data) { + if (error && !data) { const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message; @@ -152,37 +150,45 @@ function ClientDetailsPage() { ); } + const client = data?.client; + const endpointValues = data?.endpoints ?? { + discovery: "-", + issuer: "-", + authorization: "-", + token: "-", + userinfo: "-", + }; const endpoints = [ { labelKey: "ui.dev.clients.details.endpoint.discovery", labelFallback: "Discovery Endpoint", - value: data.endpoints.discovery, + value: endpointValues.discovery, }, { labelKey: "ui.dev.clients.details.endpoint.issuer", labelFallback: "Issuer URL", - value: data.endpoints.issuer, + value: endpointValues.issuer, }, { labelKey: "ui.dev.clients.details.endpoint.authorization", labelFallback: "Authorization Endpoint", - value: data.endpoints.authorization, + value: endpointValues.authorization, }, { labelKey: "ui.dev.clients.details.endpoint.token", labelFallback: "Token Endpoint", - value: data.endpoints.token, + value: endpointValues.token, }, { labelKey: "ui.dev.clients.details.endpoint.userinfo", labelFallback: "UserInfo Endpoint", - value: data.endpoints.userinfo, + value: endpointValues.userinfo, }, ]; // Client Secret from API const secretPlaceholder = "SECRET_NOT_AVAILABLE"; - const clientSecret = data.client.clientSecret || secretPlaceholder; + const clientSecret = client?.clientSecret || secretPlaceholder; const displaySecret = clientSecret === secretPlaceholder ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") @@ -200,7 +206,7 @@ function ClientDetailsPage() { {t("ui.dev.clients.consents.breadcrumb.clients", "Apps")} / - {data.client.name || clientId} + {client?.name || clientId} / {t("ui.dev.clients.details.tab.connection", "Federation")} @@ -215,7 +221,7 @@ function ClientDetailsPage() {

- {data.client.name || data.client.id} + {client?.name || client?.id || clientId}

{t( @@ -226,12 +232,14 @@ function ClientDetailsPage() {

- {data.client.status === "active" + {client?.status === "active" ? t("ui.common.status.active", "Active") - : t("ui.common.status.inactive", "Inactive")} + : client?.status === "inactive" + ? t("ui.common.status.inactive", "Inactive") + : t("msg.common.loading", "Loading...")}
@@ -276,10 +284,10 @@ function ClientDetailsPage() {

- {data.client.id} + {client?.id || clientId}

toast( t( @@ -461,7 +469,10 @@ function ClientDetailsPage() { )} rows={5} value={redirectUris} - onChange={(e) => setRedirectUris(e.target.value)} + onChange={(e) => { + redirectUrisHydratedRef.current = true; + setRedirectUris(e.target.value); + }} className="font-mono text-sm" />
diff --git a/issue_489_completion_summary.md b/docs/trouble-shooting/issue_489_completion_summary.md similarity index 100% rename from issue_489_completion_summary.md rename to docs/trouble-shooting/issue_489_completion_summary.md diff --git a/locales/en.toml b/locales/en.toml index 9f07f641..bc5d4d6a 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -509,9 +509,11 @@ saved_success = "Saved successfully." greeting = "Hello, {{name}}." [msg.userfront.audit] +browser = "Browser: {{value}}" date = "Date: {{value}}" device = "Device: {{value}}" end = "No more items to show." +filtered_empty = "No sign-in history matches the active session filter." ip = "IP address: {{value}}" load_more_error = "Could not load more history." result = "Result: {{value}}" @@ -2055,8 +2057,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "Action" app = "App" auth_method = "Auth Method" +browser = "Browser" date = "Date" device = "Device" ip = "IP" @@ -2278,3 +2282,11 @@ verify = "Verification" [ui.userfront.signup.success] action = "Go to sign-in" + + +[ui.userfront.audit.filter] +title = "Manage My Activity" +toggle_label = "Show active sessions only" + +[msg.userfront.audit.filter] +description = "Toggle to view only active sessions." diff --git a/locales/ko.toml b/locales/ko.toml index 72bf3eed..680b34f8 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -171,9 +171,11 @@ missing_jwks_uri = "JWKS URI를 입력해야 합니다." private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다." [msg.userfront.audit] +browser = "브라우저: {{value}}" date = "접속일자: {{value}}" device = "접속환경: {{value}}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {{value}}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {{value}}" @@ -904,9 +906,11 @@ saved_success = "저장이 완료되었습니다." greeting = "안녕하세요, {{name}}님" [msg.userfront.audit] +browser = "브라우저: {{value}}" date = "접속일자: {{value}}" device = "접속환경: {{value}}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {{value}}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {{value}}" @@ -2449,8 +2453,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "관리" app = "애플리케이션" auth_method = "인증수단" +browser = "브라우저" date = "접속일자" device = "접속환경" ip = "IP" @@ -2481,7 +2487,7 @@ linked = "연동됨" [ui.userfront.dashboard.sessions] active_badge = "활성화" -current_badge = "현재 접속중" +current_badge = "접속중" current_disabled = "현재 세션" unknown_device = "알 수 없는 기기" unknown_session = "세션 정보" @@ -2671,3 +2677,11 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + + +[ui.userfront.audit.filter] +title = "내 활동 관리" +toggle_label = "활성 세션만 보기" + +[msg.userfront.audit.filter] +description = "활성화된 세션만 보려면 토글을 켜주세요." diff --git a/locales/template.toml b/locales/template.toml index ff8d60a1..2440c558 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -782,9 +782,11 @@ saved_success = "" greeting = "" [msg.userfront.audit] +browser = "" date = "" device = "" end = "" +filtered_empty = "" ip = "" load_more_error = "" result = "" @@ -2327,8 +2329,10 @@ dev_console = "" [ui.userfront.audit] [ui.userfront.audit.table] +action = "" app = "" auth_method = "" +browser = "" date = "" device = "" ip = "" @@ -2549,3 +2553,11 @@ verify = "" [ui.userfront.signup.success] action = "" + + +[ui.userfront.audit.filter] +title = "" +toggle_label = "" + +[msg.userfront.audit.filter] +description = "" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 3a27641a..f7eafeb2 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -44,9 +44,11 @@ missing = "No active session was found." greeting = "Hello, {name}." [msg.userfront.audit] +browser = "Browser: {value}" date = "Date: {value}" device = "Device: {value}" end = "No more items to show." +filtered_empty = "No sign-in history matches the active session filter." ip = "IP address: {value}" load_more_error = "Could not load more history." result = "Result: {value}" @@ -435,8 +437,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "Action" app = "App" auth_method = "Auth Method" +browser = "Browser" date = "Date" device = "Device" ip = "IP" @@ -658,3 +662,11 @@ verify = "Verification" [ui.userfront.signup.success] action = "Go to sign-in" + + +[ui.userfront.audit.filter] +title = "Manage My Activity" +toggle_label = "Show active sessions only" + +[msg.userfront.audit.filter] +description = "Toggle to view only active sessions." diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 9fc24973..244414a5 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -41,9 +41,11 @@ verify_code_failed = "인증 실패: {error}" missing = "활성 세션이 없습니다." [msg.userfront.audit] +browser = "브라우저: {value}" date = "접속일자: {value}" device = "접속환경: {value}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {value}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {value}" @@ -248,9 +250,11 @@ title = "회원가입" greeting = "안녕하세요, {name}님" [msg.userfront.audit] +browser = "브라우저: {value}" date = "접속일자: {value}" device = "접속환경: {value}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {value}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {value}" @@ -638,8 +642,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "관리" app = "애플리케이션" auth_method = "인증수단" +browser = "브라우저" date = "접속일자" device = "접속환경" ip = "IP" @@ -670,7 +676,7 @@ linked = "연동됨" [ui.userfront.dashboard.sessions] active_badge = "활성화" -current_badge = "현재 접속중" +current_badge = "접속중" current_disabled = "현재 세션" unknown_device = "알 수 없는 기기" unknown_session = "세션 정보" @@ -860,3 +866,11 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + + +[ui.userfront.audit.filter] +title = "내 활동 관리" +toggle_label = "활성 세션만 보기" + +[msg.userfront.audit.filter] +description = "활성화된 세션만 보려면 토글을 켜주세요." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index c902ac09..86653de2 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -223,9 +223,11 @@ title = "" greeting = "" [msg.userfront.audit] +browser = "" date = "" device = "" end = "" +filtered_empty = "" ip = "" load_more_error = "" result = "" @@ -613,8 +615,10 @@ dev_console = "" [ui.userfront.audit] [ui.userfront.audit.table] +action = "" app = "" auth_method = "" +browser = "" date = "" device = "" ip = "" @@ -835,3 +839,11 @@ verify = "" [ui.userfront.signup.success] action = "" + + +[ui.userfront.audit.filter] +title = "" +toggle_label = "" + +[msg.userfront.audit.filter] +description = "" diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index fc93c46f..d968c49d 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -40,6 +40,15 @@ class _DashboardScreenState extends ConsumerState { static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; + static const double _historyDateColumnWidth = 132; + static const double _historyAppColumnWidth = 132; + static const double _historyIpColumnWidth = 118; + static const double _historyDeviceColumnWidth = 128; + static const double _historyBrowserColumnWidth = 112; + static const double _historyAuthMethodColumnWidth = 108; + static const double _historyResultColumnWidth = 88; + static const double _historyStatusColumnWidth = 92; + static const double _historyActionColumnWidth = 108; final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); @@ -53,6 +62,7 @@ class _DashboardScreenState extends ConsumerState { bool _authBootstrapInProgress = false; bool _showAllActivities = false; + bool _showActiveSessionsOnly = false; final Set _revokedClientIds = {}; String _renderTranslatedText( @@ -151,9 +161,9 @@ class _DashboardScreenState extends ConsumerState { builder: (context) => AlertDialog( title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), content: Text( - tr( + _renderTranslatedText( 'msg.userfront.dashboard.sessions.revoke.confirm', - params: { + values: { 'target': session.isCurrent ? tr('ui.userfront.dashboard.sessions.current_badge') : _sessionDisplayLabel(session), @@ -496,6 +506,17 @@ class _DashboardScreenState extends ConsumerState { return SelectableText(text, style: style); } + Widget _singleLineText(String text, {TextStyle? style}) { + return Text( + text, + style: style, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ); + } + String _authMethodLabel() { if (AuthTokenStore.usesCookie()) { return tr('ui.userfront.auth_method.ory'); @@ -836,13 +857,6 @@ class _DashboardScreenState extends ConsumerState { ), const SizedBox(height: 28), ], - _buildSectionTitle( - tr('ui.userfront.sections.sessions'), - tr('msg.userfront.sections.sessions_subtitle'), - ), - const SizedBox(height: 12), - _buildSessionSection(isMobile), - const SizedBox(height: 28), _buildSectionTitle( tr('ui.userfront.sections.apps'), tr('msg.userfront.sections.apps_subtitle'), @@ -972,245 +986,6 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildSessionSection(bool isMobile) { - final sessionsState = ref.watch(userSessionsProvider); - return sessionsState.when( - data: (sessions) { - if (sessions.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr('msg.userfront.dashboard.sessions.empty'), - style: TextStyle( - fontSize: 14, - color: Colors.grey[700], - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - tr('msg.userfront.dashboard.sessions.empty_detail'), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ); - } - return _buildSessionGrid(sessions, isMobile); - }, - loading: () => const SizedBox( - height: 100, - child: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr('msg.userfront.dashboard.sessions.error'), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - TextButton( - onPressed: () => ref.read(userSessionsProvider.notifier).refresh(), - child: Text(tr('ui.common.retry')), - ), - ], - ), - ); - } - - Widget _buildSessionGrid(List sessions, bool isMobile) { - return LayoutBuilder( - builder: (context, constraints) { - final crossAxisCount = _dashboardCardColumnCount(constraints.maxWidth); - final cardWidth = _dashboardCardWidth( - constraints.maxWidth, - crossAxisCount, - ); - - return Wrap( - spacing: _dashboardCardSpacing, - runSpacing: _dashboardCardSpacing, - children: sessions.map((session) { - return SizedBox( - width: cardWidth, - child: _buildSessionCard(session, cardWidth: cardWidth), - ); - }).toList(), - ); - }, - ); - } - - Widget _buildSessionCard(UserSessionSummary session, {double? cardWidth}) { - final isCurrent = session.isCurrent; - final statusColor = session.isActive ? Colors.green : Colors.grey; - final primaryTime = - session.lastSeenAt ?? - session.authenticatedAt ?? - session.issuedAt ?? - session.expiresAt; - final primaryTimeLabel = primaryTime != null - ? _formatDateTime(primaryTime) - : tr('ui.userfront.session.unknown'); - final sessionLabel = _sessionPrimaryLabel(session); - final clientLabel = _sessionClientLabel(session); - final browserLabel = _sessionBrowserLabel(session.userAgent); - final osLabel = _sessionOsLabel(session.userAgent); - final canRevoke = !isCurrent && _revokingSessionId == null; - - return Container( - width: cardWidth ?? 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _surface, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isCurrent ? Colors.blueGrey : _border, - width: isCurrent ? 1.5 : 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 8), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - sessionLabel, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isCurrent ? Colors.blueGrey : statusColor, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - isCurrent - ? tr('ui.userfront.dashboard.sessions.current_badge') - : session.isActive - ? tr('ui.userfront.dashboard.sessions.active_badge') - : tr('ui.common.status.inactive'), - style: const TextStyle( - fontSize: 11, - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - if (clientLabel.isNotEmpty) ...[ - Text( - clientLabel, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - const SizedBox(height: 8), - ], - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildInfoChip(Icons.access_time, primaryTimeLabel), - if (session.ipAddress.isNotEmpty) - _buildInfoChip(Icons.public, session.ipAddress), - ], - ), - if (browserLabel.isNotEmpty || osLabel.isNotEmpty) ...[ - const SizedBox(height: 12), - if (browserLabel.isNotEmpty) - Text( - _renderTranslatedText( - 'msg.userfront.dashboard.sessions.browser', - values: {'value': browserLabel}, - ), - style: TextStyle(fontSize: 13, color: Colors.grey[700]), - ), - if (osLabel.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - _renderTranslatedText( - 'msg.userfront.dashboard.sessions.os', - values: {'value': osLabel}, - ), - style: TextStyle(fontSize: 13, color: Colors.grey[700]), - ), - ], - ], - if (session.clientId.trim().isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - _renderTranslatedText( - 'msg.userfront.dashboard.client_id', - fallback: 'Client ID: {{id}}', - values: {'id': session.clientId}, - ), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - const SizedBox(height: 8), - Text( - _renderTranslatedText( - 'msg.userfront.dashboard.sessions.session_id', - fallback: 'Session ID: {{id}}', - values: {'id': _compactSessionId(session.sessionId)}, - ), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: canRevoke ? () => _onRevokeSession(session) : null, - style: OutlinedButton.styleFrom( - foregroundColor: canRevoke ? Colors.redAccent : Colors.grey, - side: BorderSide( - color: canRevoke ? Colors.redAccent : Colors.grey, - width: 0.6, - ), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - child: _revokingSessionId == session.sessionId - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.redAccent, - ), - ) - : Text( - isCurrent - ? tr( - 'ui.userfront.dashboard.sessions.current_disabled', - ) - : tr('ui.userfront.dashboard.sessions.revoke.action'), - ), - ), - ), - ], - ), - ); - } - String _sessionDisplayLabel(UserSessionSummary session) { if (session.userAgent.trim().isNotEmpty) { return _sessionUserAgentLabel(session.userAgent); @@ -1709,46 +1484,167 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { + final sessionsState = ref.watch(userSessionsProvider); if (state.isLoading && state.items.isEmpty) { return _buildHistoryContainer( - child: const Center(child: CircularProgressIndicator()), + child: const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), ); } if (state.error != null && state.items.isEmpty) { return _buildHistoryContainer( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(tr('msg.userfront.dashboard.audit_load_error')), - const SizedBox(height: 8), - TextButton( - onPressed: () => - ref.read(authTimelineProvider.notifier).refresh(), - child: Text(tr('ui.common.retry')), - ), - ], + child: SizedBox( + height: 120, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr('msg.userfront.dashboard.audit_load_error')), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(authTimelineProvider.notifier).refresh(), + child: Text(tr('ui.common.retry')), + ), + ], + ), ), ), ); } - if (state.items.isEmpty) { + if (sessionsState.isLoading && !sessionsState.hasValue) { return _buildHistoryContainer( - child: Center( - child: Text( - tr('msg.userfront.dashboard.audit_empty'), - style: TextStyle(color: Colors.grey[600]), + child: const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + if (sessionsState.hasError && !sessionsState.hasValue) { + return _buildHistoryContainer( + child: SizedBox( + height: 120, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr('msg.userfront.dashboard.sessions.error')), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(userSessionsProvider.notifier).refresh(), + child: Text(tr('ui.common.retry')), + ), + ], + ), ), ), ); } + final sessions = sessionsState is AsyncData> + ? sessionsState.value + : const []; + final Map sessionById = { + for (final session in sessions) session.sessionId.trim(): session, + }; + final filteredItems = state.items.where((log) { + if (!_showActiveSessionsOnly) { + return true; + } + final status = _historySessionStatusForLog(log, sessionById); + return status != _HistorySessionStatus.inactive; + }).toList(); + + if (filteredItems.isEmpty) { + return _buildHistoryContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHistoryHeader(), + const SizedBox(height: 20), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + _showActiveSessionsOnly + ? tr('msg.userfront.audit.filtered_empty') + : tr('msg.userfront.dashboard.audit_empty'), + style: TextStyle(color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } + if (isWide) { - return _buildHistoryTable(state); + return _buildHistoryTable(state, filteredItems, sessionById); } - return _buildHistoryList(state); + return _buildHistoryList(state, filteredItems, sessionById); + } + + Widget _buildHistoryHeader() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('ui.userfront.audit.filter.title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 4), + Text( + tr('msg.userfront.audit.filter.description'), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + const SizedBox(width: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tr('ui.userfront.audit.filter.toggle_label'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(width: 2), + Transform.scale( + scale: 0.84, + alignment: Alignment.centerRight, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _showActiveSessionsOnly, + onChanged: (value) { + setState(() { + _showActiveSessionsOnly = value; + }); + }, + ), + ), + ], + ), + ], + ); } Widget _buildHistoryContainer({required Widget child}) { @@ -1764,6 +1660,116 @@ class _DashboardScreenState extends ConsumerState { ); } + _HistorySessionStatus _historySessionStatusForLog( + AuditLogEntry log, + Map sessionById, + ) { + final sessionId = log.sessionId.trim(); + if (sessionId.isEmpty) { + return _HistorySessionStatus.inactive; + } + final session = sessionById[sessionId]; + if (session == null) { + return _HistorySessionStatus.inactive; + } + if (session.isCurrent) { + return _HistorySessionStatus.current; + } + if (session.isActive) { + return _HistorySessionStatus.active; + } + return _HistorySessionStatus.inactive; + } + + String _historySessionStatusLabel(_HistorySessionStatus status) { + switch (status) { + case _HistorySessionStatus.current: + return tr('ui.userfront.dashboard.sessions.current_badge'); + case _HistorySessionStatus.active: + return tr('ui.userfront.dashboard.sessions.active_badge'); + case _HistorySessionStatus.inactive: + return tr('ui.common.status.inactive'); + } + } + + Color _historySessionStatusColor(_HistorySessionStatus status) { + switch (status) { + case _HistorySessionStatus.current: + return Colors.blueGrey; + case _HistorySessionStatus.active: + return Colors.green; + case _HistorySessionStatus.inactive: + return Colors.grey; + } + } + + Widget _buildHistoryStatusBadge(_HistorySessionStatus status) { + return SizedBox( + width: _historyStatusColumnWidth, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _historySessionStatusColor(status), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + _historySessionStatusLabel(status), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + Widget _buildHistorySessionActionCell(UserSessionSummary? session) { + if (session == null) { + return SizedBox( + width: _historyActionColumnWidth, + child: Center( + child: _selectableText(tr('ui.common.hyphen', fallback: '-')), + ), + ); + } + final isCurrent = session.isCurrent; + final canRevoke = + !isCurrent && _revokingSessionId == null && session.isActive; + return SizedBox( + width: _historyActionColumnWidth, + child: OutlinedButton( + onPressed: canRevoke ? () => _onRevokeSession(session) : null, + style: OutlinedButton.styleFrom( + foregroundColor: canRevoke ? Colors.redAccent : Colors.grey, + side: BorderSide( + color: canRevoke ? Colors.redAccent : Colors.grey, + width: 0.6, + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: _revokingSessionId == session.sessionId + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.redAccent, + ), + ) + : Text( + isCurrent + ? tr('ui.userfront.dashboard.sessions.current_disabled') + : session.isActive + ? tr('ui.userfront.dashboard.sessions.revoke.action') + : tr('ui.common.hyphen', fallback: '-'), + ), + ), + ); + } + int _dashboardCardColumnCount(double maxWidth) { if (maxWidth > 1200) { return 4; @@ -1779,10 +1785,30 @@ class _DashboardScreenState extends ConsumerState { crossAxisCount; } - Widget _buildHistoryTable(AuthTimelineState state) { + Widget _buildCenteredHistoryHeader(String label, {double? width}) { + return SizedBox( + width: width, + child: Center(child: Text(label, textAlign: TextAlign.center)), + ); + } + + Widget _buildCenteredHistoryCell(Widget child, {double? width}) { + return SizedBox( + width: width, + child: Center(child: child), + ); + } + + Widget _buildHistoryTable( + AuthTimelineState state, + List items, + Map sessionById, + ) { return _buildHistoryContainer( child: Column( children: [ + _buildHistoryHeader(), + const SizedBox(height: 16), LayoutBuilder( builder: (context, constraints) { final sessionColumnWidth = _historySessionColumnWidth( @@ -1797,41 +1823,75 @@ class _DashboardScreenState extends ConsumerState { horizontalMargin: 12, columns: [ DataColumn( - label: SizedBox( - width: sessionColumnWidth, - child: Text( - tr( - 'ui.userfront.audit.table.session_id', - fallback: 'Session ID', - ), + label: _buildCenteredHistoryHeader( + tr( + 'ui.userfront.audit.table.session_id', + fallback: 'Session ID', ), + width: sessionColumnWidth, ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.date')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.date'), + width: _historyDateColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.app')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.app'), + width: _historyAppColumnWidth, + ), ), DataColumn( - label: Text( + label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.ip', fallback: 'IP'), + width: _historyIpColumnWidth, ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.device')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.device'), + width: _historyDeviceColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.auth_method')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.browser'), + width: _historyBrowserColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.result')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.auth_method'), + width: _historyAuthMethodColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.status')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.result'), + width: _historyResultColumnWidth, + ), + ), + DataColumn( + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.status'), + width: _historyStatusColumnWidth, + ), + ), + DataColumn( + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.action'), + width: _historyActionColumnWidth, + ), ), ], - rows: state.items.map((log) { + rows: items.map((log) { + final matchedSession = sessionById[log.sessionId.trim()]; + final sessionStatus = _historySessionStatusForLog( + log, + sessionById, + ); final statusLabel = log.status == 'success' ? tr('ui.common.status.success') : tr('ui.common.status.failure'); @@ -1844,45 +1904,86 @@ class _DashboardScreenState extends ConsumerState { final deviceLabel = _deviceLabelFromUserAgent( log.userAgent, ); + final browserLabel = _sessionBrowserLabel(log.userAgent); return DataRow( cells: [ DataCell( - SizedBox( - width: sessionColumnWidth, - child: _buildHistorySessionIdCell( + _buildCenteredHistoryCell( + _buildHistorySessionIdCell( log.sessionId.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.sessionId, sessionColumnWidth, ), + width: sessionColumnWidth, ), ), DataCell( - _selectableText(_formatDateTime(log.timestamp)), - ), - DataCell(_buildAppCell(log)), - DataCell( - _selectableText( - log.ipAddress.isEmpty - ? tr('ui.common.hyphen', fallback: '-') - : log.ipAddress, + _buildCenteredHistoryCell( + _selectableText(_formatDateTime(log.timestamp)), + width: _historyDateColumnWidth, ), ), - DataCell(_selectableText(deviceLabel)), - DataCell(_buildAuthMethodCell(log, authMethod)), DataCell( - _selectableText( - statusLabel, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.w600, + _buildCenteredHistoryCell( + _buildAppCell(log), + width: _historyAppColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + log.ipAddress.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.ipAddress, ), + width: _historyIpColumnWidth, ), ), DataCell( - _selectableText( - tr('ui.userfront.audit.table.pending'), - style: const TextStyle(color: Colors.grey), + _buildCenteredHistoryCell( + _singleLineText(deviceLabel), + width: _historyDeviceColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + browserLabel.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : browserLabel, + ), + width: _historyBrowserColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildAuthMethodCell(log, authMethod), + width: _historyAuthMethodColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + statusLabel, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + width: _historyResultColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildHistoryStatusBadge(sessionStatus), + width: _historyStatusColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildHistorySessionActionCell(matchedSession), + width: _historyActionColumnWidth, ), ), ], @@ -1910,6 +2011,10 @@ class _DashboardScreenState extends ConsumerState { } String _compactSessionId(String sessionId) { + final parts = sessionId.split('-'); + if (parts.length >= 4) { + return '${parts.take(3).join('-')}-...'; + } if (sessionId.length <= _historySessionMinVisibleChars) { return sessionId; } @@ -1917,26 +2022,32 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) { - final compactMode = columnWidth <= _historySessionMinWidth + 0.5; - final displayText = compactMode ? _compactSessionId(sessionId) : sessionId; + final displayText = _compactSessionId(sessionId); final textWidget = Text( displayText, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ); - if (displayText == sessionId) { + if (displayText == sessionId || sessionId.isEmpty) { return textWidget; } return Tooltip(message: sessionId, child: textWidget); } - Widget _buildHistoryList(AuthTimelineState state) { + Widget _buildHistoryList( + AuthTimelineState state, + List items, + Map sessionById, + ) { return _buildHistoryContainer( child: Column( children: [ - for (final log in state.items) + _buildHistoryHeader(), + const SizedBox(height: 16), + for (final log in items) Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), @@ -1948,6 +2059,15 @@ class _DashboardScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + _buildHistoryStatusBadge( + _historySessionStatusForLog(log, sessionById), + ), + const Spacer(), + ], + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -2008,6 +2128,16 @@ class _DashboardScreenState extends ConsumerState { }, ), ), + _selectableText( + tr( + 'msg.userfront.audit.browser', + params: { + 'value': _sessionBrowserLabel(log.userAgent).isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : _sessionBrowserLabel(log.userAgent), + }, + ), + ), _buildAuthMethodLine( log, log.authMethod.isNotEmpty @@ -2025,8 +2155,18 @@ class _DashboardScreenState extends ConsumerState { ), ), _selectableText( - tr('msg.userfront.audit.status'), - style: TextStyle(color: Colors.grey[600]), + tr( + 'msg.userfront.audit.status', + params: { + 'value': _historySessionStatusLabel( + _historySessionStatusForLog(log, sessionById), + ), + }, + ), + ), + const SizedBox(height: 12), + _buildHistorySessionActionCell( + sessionById[log.sessionId.trim()], ), ], ), @@ -2143,6 +2283,8 @@ class _DashboardScreenState extends ConsumerState { } } +enum _HistorySessionStatus { current, active, inactive } + class _ActivityItem { final String clientId; final String appName; diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 08155de5..7974493c 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -413,9 +413,11 @@ const Map koStrings = { "msg.dev.sidebar.notice": "개발자 전용 콘솔입니다.", "msg.dev.sidebar.notice_detail": "연동 앱 등록 및 관리를 수행할 수 있습니다.", "msg.info.saved_success": "저장이 완료되었습니다.", + "msg.userfront.audit.browser": "브라우저: {{value}}", "msg.userfront.audit.date": "접속일자: {{value}}", "msg.userfront.audit.device": "접속환경: {{value}}", "msg.userfront.audit.end": "더 이상 항목이 없습니다.", + "msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.", "msg.userfront.audit.ip": "접속 IP: {{value}}", "msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.", "msg.userfront.audit.result": "인증결과: {{value}}", @@ -1691,8 +1693,12 @@ const Map koStrings = { "ui.userfront.app_label.baron": "Baron 로그인", "ui.userfront.app_label.dev_console": "Dev Console", "ui.userfront.app_title": "Baron SW 포탈", + "ui.userfront.audit.filter.title": "내 활동 관리", + "ui.userfront.audit.filter.toggle_label": "활성 세션만 보기", + "ui.userfront.audit.table.action": "관리", "ui.userfront.audit.table.app": "애플리케이션", "ui.userfront.audit.table.auth_method": "인증수단", + "ui.userfront.audit.table.browser": "브라우저", "ui.userfront.audit.table.date": "접속일자", "ui.userfront.audit.table.device": "접속환경", "ui.userfront.audit.table.ip": "IP", @@ -1715,7 +1721,7 @@ const Map koStrings = { "ui.userfront.dashboard.revoke.title": "연동 해지", "ui.userfront.dashboard.scopes.title": "권한 (Scopes)", "ui.userfront.dashboard.sessions.active_badge": "활성화", - "ui.userfront.dashboard.sessions.current_badge": "현재 접속중", + "ui.userfront.dashboard.sessions.current_badge": "접속중", "ui.userfront.dashboard.sessions.current_disabled": "현재 세션", "ui.userfront.dashboard.sessions.revoke.action": "세션 종료", "ui.userfront.dashboard.sessions.revoke.title": "세션 종료", @@ -2312,9 +2318,12 @@ const Map enStrings = { "msg.dev.sidebar.notice": "Developer Console", "msg.dev.sidebar.notice_detail": "Register and manage client applications.", "msg.info.saved_success": "Saved successfully.", + "msg.userfront.audit.browser": "Browser: {{value}}", "msg.userfront.audit.date": "Date: {{value}}", "msg.userfront.audit.device": "Device: {{value}}", "msg.userfront.audit.end": "No more items to show.", + "msg.userfront.audit.filter.description": + "Toggle to view only active sessions.", "msg.userfront.audit.ip": "IP address: {{value}}", "msg.userfront.audit.load_more_error": "Could not load more history.", "msg.userfront.audit.result": "Result: {{value}}", @@ -3694,8 +3703,12 @@ const Map enStrings = { "ui.userfront.app_label.baron": "Baron", "ui.userfront.app_label.dev_console": "Dev Console", "ui.userfront.app_title": "Baron SW Portal", + "ui.userfront.audit.filter.title": "Manage My Activity", + "ui.userfront.audit.filter.toggle_label": "Show active sessions only", + "ui.userfront.audit.table.action": "Action", "ui.userfront.audit.table.app": "App", "ui.userfront.audit.table.auth_method": "Auth Method", + "ui.userfront.audit.table.browser": "Browser", "ui.userfront.audit.table.date": "Date", "ui.userfront.audit.table.device": "Device", "ui.userfront.audit.table.ip": "IP",