From a2f2b2dd7135a1c29af3d41aa06790417688a3d9 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 2 Apr 2026 11:01:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 + backend/internal/handler/auth_handler.go | 337 ++++++++++++++ .../handler/auth_handler_login_test.go | 21 + .../handler/auth_handler_sessions_test.go | 157 +++++++ backend/internal/handler/user_handler_test.go | 20 + .../internal/service/kratos_admin_service.go | 97 ++++ backend/internal/service/mock_common_test.go | 20 + locales/en.toml | 27 ++ locales/ko.toml | 422 ++++++++++++++++- userfront/assets/translations/en.toml | 27 ++ userfront/assets/translations/ko.toml | 220 +++++++++ .../lib/core/services/auth_proxy_service.dart | 23 + .../lib/features/dashboard/domain/models.dart | 56 +++ .../providers/user_sessions_provider.dart | 68 +++ .../presentation/dashboard_screen.dart | 426 ++++++++++++++++++ 15 files changed, 1922 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/auth_handler_sessions_test.go create mode 100644 userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0392a1d8..b8f3502d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -581,6 +581,8 @@ func main() { user.Post("/me/password", authHandler.ChangeMyPassword) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) + user.Get("/sessions", authHandler.ListMySessions) + user.Delete("/sessions/:id", authHandler.DeleteMySession) user.Get("/rp/linked", authHandler.ListLinkedRps) user.Get("/rp/history", authHandler.ListRpHistory) user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 53d105a1..e73f112a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -6664,6 +6664,167 @@ type rpHistoryItem struct { Status string `json:"status"` } +type userSessionItem struct { + SessionID string `json:"session_id"` + AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + IssuedAt *time.Time `json:"issued_at,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ClientID string `json:"client_id,omitempty"` + AppName string `json:"app_name,omitempty"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` +} + +type userSessionListResponse struct { + Items []userSessionItem `json:"items"` +} + +func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + if strings.TrimSpace(profile.ID) == "" { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + + sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + + currentSessionID := h.resolveCurrentSessionID(c) + auditHints := h.loadSessionAuditHints(c.Context(), profile.ID) + + items := make([]userSessionItem, 0, len(sessions)) + for _, session := range sessions { + if !session.Active { + continue + } + item := userSessionItem{ + SessionID: session.ID, + IsCurrent: session.ID != "" && session.ID == currentSessionID, + IsActive: session.Active, + } + if !session.AuthenticatedAt.IsZero() { + ts := session.AuthenticatedAt + item.AuthenticatedAt = &ts + item.LastSeenAt = &ts + } + if !session.ExpiresAt.IsZero() { + ts := session.ExpiresAt + item.ExpiresAt = &ts + } + if !session.IssuedAt.IsZero() { + ts := session.IssuedAt + item.IssuedAt = &ts + if item.AuthenticatedAt == nil { + item.AuthenticatedAt = &ts + } + if item.LastSeenAt == nil { + item.LastSeenAt = &ts + } + } + if hint, ok := auditHints[session.ID]; ok { + if item.IPAddress == "" { + item.IPAddress = hint.IPAddress + } + if item.UserAgent == "" { + item.UserAgent = hint.UserAgent + } + if item.ClientID == "" { + item.ClientID = hint.ClientID + } + if item.AppName == "" { + item.AppName = hint.AppName + } + if hint.Timestamp != nil { + item.LastSeenAt = hint.Timestamp + } + } + if item.UserAgent == "" && len(session.Devices) > 0 { + deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent) + if !looksLikeInternalUserAgent(deviceUserAgent) { + item.UserAgent = deviceUserAgent + } + } + if item.IPAddress == "" && len(session.Devices) > 0 { + item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress) + } + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].IsCurrent != items[j].IsCurrent { + return items[i].IsCurrent + } + iTime := latestSessionTimestamp(items[i]) + jTime := latestSessionTimestamp(items[j]) + if iTime.Equal(jTime) { + return items[i].SessionID < items[j].SessionID + } + return iTime.After(jTime) + }) + + return c.JSON(userSessionListResponse{Items: items}) +} + +func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + targetSessionID := strings.TrimSpace(c.Params("id")) + if targetSessionID == "" { + return errorJSON(c, fiber.StatusBadRequest, "session id is required") + } + + mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + ownedSession := false + for _, candidate := range mySessions { + if strings.TrimSpace(candidate.ID) == targetSessionID { + ownedSession = true + break + } + } + if !ownedSession { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + + session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session") + } + if session == nil { + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing") + return c.JSON(fiber.Map{"status": "ok"}) + } + + result := "revoked" + if !session.Active { + result = "already_inactive" + } else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session") + } + + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result) + return c.JSON(fiber.Map{"status": "ok"}) +} + func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { @@ -6751,3 +6912,179 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { return c.JSON(fiber.Map{"items": items}) } + +type sessionAuditHint struct { + Timestamp *time.Time + IPAddress string + UserAgent string + ClientID string + AppName string +} + +func latestSessionTimestamp(item userSessionItem) time.Time { + for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} { + if candidate != nil { + return *candidate + } + } + return time.Time{} +} + +func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string { + if c == nil { + return "" + } + if token := h.getBearerToken(c); token != "" { + if sessionID := extractSessionIDFromJWT(token); sessionID != "" { + return sessionID + } + if sessionID, err := h.getKratosSessionID(token); err == nil { + return sessionID + } + } + if cookie := c.Get("Cookie"); cookie != "" { + if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil { + return sessionID + } + } + return "" +} + +func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint { + hints := make(map[string]sessionAuditHint) + if h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return hints + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "login_success", + "qr_login_success", + "link_login_success", + "code_login_success", + "password_login_success", + "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "POST /api/v1/auth/magic-link/verify", + "POST /api/v1/auth/login/code/verify", + "POST /api/v1/auth/qr/approve", + "session.revoked", + }, 200) + if err != nil { + return hints + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + existing, ok := hints[sessionID] + if ok && existing.Timestamp != nil && existing.Timestamp.After(log.Timestamp) { + continue + } + + ts := log.Timestamp + ipAddress := strings.TrimSpace(log.IPAddress) + userAgent := strings.TrimSpace(log.UserAgent) + clientID, appName := deriveSessionClientInfo(log) + if details, err := parseAuditDetails(log.Details); err == nil { + if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" { + ipAddress = strings.TrimSpace(approvedIP) + } + if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" { + userAgent = strings.TrimSpace(approvedUserAgent) + } + } + if looksLikeInternalUserAgent(userAgent) { + userAgent = "" + } + hints[sessionID] = sessionAuditHint{ + Timestamp: &ts, + IPAddress: ipAddress, + UserAgent: userAgent, + ClientID: clientID, + AppName: appName, + } + } + return hints +} + +func deriveSessionClientInfo(log domain.AuditLog) (string, string) { + details, _ := parseAuditDetails(log.Details) + clientID := "" + appName := "" + if details != nil { + if value, ok := details["client_id"].(string); ok { + clientID = strings.TrimSpace(value) + } + if value, ok := details["client_name"].(string); ok { + appName = strings.TrimSpace(value) + } + } + path := strings.ToLower(extractAuditPath(log)) + if appName == "" { + switch { + case strings.Contains(path, "/api/v1/auth/oidc/login/accept"): + appName = "OIDC 로그인" + case strings.Contains(path, "/api/v1/auth/qr/approve"): + appName = "QR 로그인" + case strings.Contains(path, "/api/v1/auth/login/code/verify"): + appName = "코드 로그인" + case strings.Contains(path, "/api/v1/auth/magic-link/verify"): + appName = "링크 로그인" + case strings.Contains(path, "/api/v1/auth/password/login"): + appName = "비밀번호 로그인" + } + } + if appName == "" && clientID != "" { + appName = clientID + } + return clientID, appName +} + +func looksLikeInternalUserAgent(userAgent string) bool { + normalized := strings.ToLower(strings.TrimSpace(userAgent)) + if normalized == "" { + return false + } + return strings.HasPrefix(normalized, "go-http-client/") || + strings.HasPrefix(normalized, "fasthttp") || + strings.HasPrefix(normalized, "fiber") +} + +func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) { + if h.AuditRepo == nil { + return + } + + details := map[string]any{ + "target_session_id": strings.TrimSpace(targetSessionID), + "revoke_result": strings.TrimSpace(result), + } + if strings.TrimSpace(actorSessionID) != "" { + details["actor_session_id"] = strings.TrimSpace(actorSessionID) + } + raw, err := json.Marshal(details) + if err != nil { + return + } + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()), + Timestamp: time.Now().UTC(), + UserID: strings.TrimSpace(actorIdentityID), + SessionID: strings.TrimSpace(actorSessionID), + EventType: "session.revoked", + Status: "success", + IPAddress: extractClientIPFromHeaders(c), + UserAgent: strings.TrimSpace(c.Get("User-Agent")), + Details: string(raw), + }) +} diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 5386bdc5..3d098bbe 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -122,6 +122,27 @@ func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID return nil } +func (m *MockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + args := m.Called(ctx, sessionID) + return args.Error(0) +} + // --- Helper --- func newAuthLoginTestApp(h *AuthHandler) *fiber.App { diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go new file mode 100644 index 00000000..ab6c2e2e --- /dev/null +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -0,0 +1,157 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestListMySessions_Success(t *testing.T) { + now := time.Date(2026, 4, 2, 1, 2, 3, 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 + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "other-sid", + Active: true, + AuthenticatedAt: now.Add(-2 * time.Hour), + ExpiresAt: now.Add(22 * time.Hour), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "login_success", + SessionID: "other-sid", + Timestamp: now.Add(-30 * time.Minute), + IPAddress: "203.0.113.10", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", 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"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "other-sid", body.Items[1].SessionID) + assert.True(t, body.Items[1].IsActive) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent) + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_Success(t *testing.T) { + 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": time.Now().UTC().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 + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + if assert.Len(t, auditRepo.logs, 1) { + assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Equal(t, "current-sid", auditRepo.logs[0].SessionID) + assert.Contains(t, auditRepo.logs[0].Details, "target-sid") + } + + mockKratos.AssertExpectations(t) +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 0927a3c6..2adcea28 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -56,6 +56,26 @@ func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } +func (m *MockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} + type MockOryProvider struct { mock.Mock } diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index 35141017..f1e062ac 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -27,6 +27,21 @@ type KratosIdentity struct { UpdatedAt time.Time `json:"updated_at,omitempty"` } +type KratosSessionDevice struct { + UserAgent string `json:"user_agent,omitempty"` + IPAddress string `json:"ip_address,omitempty"` +} + +type KratosSession struct { + ID string `json:"id"` + Active bool `json:"active"` + AuthenticatedAt time.Time `json:"authenticated_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + IssuedAt time.Time `json:"issued_at,omitempty"` + Identity *KratosIdentity `json:"identity,omitempty"` + Devices []KratosSessionDevice `json:"devices,omitempty"` +} + type KratosAdminService interface { ListIdentities(ctx context.Context) ([]KratosIdentity, error) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) @@ -34,6 +49,9 @@ type KratosAdminService interface { UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error + ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) + GetSession(ctx context.Context, sessionID string) (*KratosSession, error) + DeleteSession(ctx context.Context, sessionID string) error } type kratosAdminService struct { @@ -239,6 +257,85 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit return nil } +func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + endpoint := fmt.Sprintf("%s/admin/identities/%s/sessions", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin list identity sessions failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var sessions []KratosSession + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin get session failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var session KratosSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + return &session, nil +} + +func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("kratos admin delete session failed status=%d body=%s", resp.StatusCode, string(body)) + } + return nil +} + func hashPasswordForKratosAdmin(password string) (string, error) { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index fdf0e6d0..e5330ba3 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -110,3 +110,23 @@ func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Contex func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error { return m.Called(ctx, identityID).Error(0) } + +func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]KratosSession), args.Error(1) +} + +func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*KratosSession), args.Error(1) +} + +func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} diff --git a/locales/en.toml b/locales/en.toml index 28da2b1f..9f07f641 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -559,6 +559,20 @@ empty = "No linked apps yet." empty_detail = "Linked apps and their latest activity will appear here." error = "Could not load linked apps." +[msg.userfront.dashboard.sessions] +browser = "Browser: {{value}}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {{value}}" +recent_app = "Recent app: {{app}}" +session_id = "Session ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {{target}}?\nThat device will need to sign in again." +error = "Could not end the session: {{error}}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy." @@ -735,6 +749,7 @@ uppercase = "At least one uppercase letter" [msg.userfront.sections] apps_subtitle = "Your linked apps and their latest sign-in status." audit_subtitle = "Recent access history for Baron sign-in." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -2070,6 +2085,17 @@ status_history = "Activity history" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -2204,6 +2230,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" diff --git a/locales/ko.toml b/locales/ko.toml index 3c995a3d..72bf3eed 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -73,7 +73,402 @@ scope_admin = "Scoped to /admin" session_ttl = "Session TTL: 15m admin" tenant_headers = "Tenant-aware headers" -[msg.admin.api_keys] +[msg.admin.common] +forbidden = "이 작업을 수행할 권한이 없습니다." + +[msg.admin.audit] +empty = "아직 수집된 감사 로그가 없습니다." +end = "감사 로그의 마지막입니다." +load_error = "감사 로그를 불러오지 못했습니다: {{error}}" +loading = "감사 로그를 불러오는 중..." +subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다." + +[msg.admin.header] +subtitle = "Tenant isolation & least privilege by default" + +[msg.admin.notice] +idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다." +scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다." + +[msg.admin.org] +hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다." +import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다." +import_error = "조직도 임포트 중 오류가 발생했습니다." +import_success = "조직도가 성공적으로 임포트되었습니다." + +[msg.admin.overview] +description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다." +idp_fallback = "Fallback: Descope" +idp_primary = "IDP: Ory primary" + +[msg.admin.tenants] +approve_confirm = "이 테넌트를 승인하시겠습니까?" +approve_success = "테넌트가 승인되었습니다." +delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?" +delete_success = "테넌트가 삭제되었습니다." +empty = "아직 등록된 테넌트가 없습니다." +fetch_error = "테넌트 목록 조회에 실패했습니다." +missing_id = "테넌트 ID가 없습니다." +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?" +subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." + +[msg.dev.auth] +access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요." +access_denied_title = "접근 권한이 없습니다." + +[msg.dev.forbidden] +default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요." +rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다." +tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." +user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +title = "{{resource}} 접근 권한 없음" + +[msg.dev.audit] +empty = "조회된 감사 로그가 없습니다." +forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요." +load_error = "감사 로그 조회 실패: {{error}}" +loaded_count = "로드된 로그 {{count}}건" +loading = "감사 로그를 불러오는 중..." +subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다." + +[msg.dev.clients] +deleted = "앱이 삭제되었습니다." +delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." +delete_error = "삭제 실패: {{error}}" +load_error = "앱 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 정보를 불러오는 중..." +showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." + +[msg.dev.sidebar] +notice = "개발자 전용 콘솔입니다." +notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다." + +[msg.dev.clients.general.public_key] +auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." +auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다." +auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다." +guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json" +guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다." +guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다." +guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다." +guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다." +headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다." +jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다." +jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json" +request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다." +source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다." +subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다." + +[msg.dev.clients.general.public_key.validation] +headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다." +headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다." +headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다." +invalid_jwks_inline = "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다." +invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다." +missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다." +missing_jwks_uri = "JWKS URI를 입력해야 합니다." +private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다." + +[msg.userfront.audit] +date = "접속일자: {{value}}" +device = "접속환경: {{value}}" +end = "더 이상 항목이 없습니다." +ip = "접속 IP: {{value}}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {{value}}" +session_id = "Session ID: {{value}}" +status = "현황: (준비중)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {{device}}" +approved_ip = "승인 IP: {{ip}}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {{method}}" +client_id = "Client ID: {{id}}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {{status}}" +last_auth = "최근 인증: {{value}}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {{error}}" +session_id_copied = "세션 ID가 복사되었습니다." + +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {{id}}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {{code}}" +type = "오류 종류: {{type}}" + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {{error}}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {{error}}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {{error}}" +link_send_failed = "전송 실패: {{error}}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {{error}}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {{error}}" + +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {{error}}" +client_id = "클라이언트 ID: {{id}}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {{error}}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {{count}}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {{name}}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {{error}}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {{error}}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {{error}}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.admin.audit] +export_csv = "Export CSV" +load_more = "Load more" +target = "Target · {{target}}" +title = "감사 로그" + +[ui.admin.groups] +import_csv = "CSV 임포트" + +[ui.admin.header] +plane = "Admin Plane" +subtitle = "관리 및 정책 운영" + +[ui.admin.nav] +api_keys = "API 키" +audit_logs = "감사 로그" +auth_guard = "인증 가드" +logout = "로그아웃" +overview = "개요" +relying_parties = "애플리케이션(RP)" +tenant_dashboard = "테넌트 대시보드" +user_groups = "유저 그룹" +tenants = "테넌트" +users = "사용자" + +[ui.admin.org] +download_template = "템플릿 다운로드" +import_btn = "임포트" +import_title = "조직도 대량 등록" +start_import = "임포트 시작" + +[ui.admin.overview] +kicker = "Global Overview" +title = "통합 대시보드" + +[ui.admin.profile] +manageable_tenants = "관리 가능한 테넌트" + +[ui.admin.role] +rp_admin = "RP ADMIN" +super_admin = "SUPER ADMIN" +tenant_admin = "TENANT ADMIN" +user = "TENANT MEMBER" + +[ui.admin.tenants] +add = "테넌트 추가" +title = "테넌트 목록" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.dev.nav] +clients = "연동 앱" +logout = "로그아웃" + +[ui.dev.tenant] +single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다." +switch_success = "테넌트 전환 완료" +workspace = "작업 테넌트 (컨텍스트)" +workspace_desc = "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다." + +[ui.dev.audit] +load_more = "더 보기" +title = "감사 로그" + +[ui.dev.profile] +menu_aria = "계정 메뉴 열기" +menu_title = "계정" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" +title = "내 정보" +subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." +loading = "프로필 정보를 불러오는 중..." +error = "프로필 정보를 불러오지 못했습니다." + +[ui.dev.clients] +new = "연동 앱 추가" +search_placeholder = "연동 앱 이름/ID로 검색..." +tenant_scoped = "Tenant-scoped" +untitled = "Untitled" + +[ui.dev.dashboard] +ready_badge = "devfront ready" + +[ui.dev.header] +plane = "Dev Plane" +subtitle = "Manage your applications" + +[ui.dev.session] +auto_extend = "세션 만료 관리" +active = "세션 활성" +disabled = "자동 연장 비활성화" +unknown = "알 수 없음" +expired = "세션 만료" +expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" +remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" +refresh = "세션 만료 시간 갱신" +refreshing = "세션 만료 시간 갱신 중..." + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +status_history = "상태 이력" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" [msg.admin.api_keys.create] error = "API 키 생성에 실패했습니다." @@ -559,6 +954,20 @@ empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." +[msg.userfront.dashboard.sessions] +browser = "브라우저: {{value}}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {{value}}" +recent_app = "최근 접속 앱: {{app}}" +session_id = "세션 ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{{target}} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {{error}}" +success = "세션이 종료되었습니다." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다." copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다." @@ -2070,6 +2479,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "현재 접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 86f56c94..3a27641a 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -94,6 +94,20 @@ empty = "No linked apps yet." empty_detail = "Linked apps and their latest activity will appear here." error = "Could not load linked apps." +[msg.userfront.dashboard.sessions] +browser = "Browser: {value}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {value}" +recent_app = "Recent app: {app}" +session_id = "Session ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {target}?\nThat device will need to sign in again." +error = "Could not end the session: {error}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy." @@ -270,6 +284,7 @@ uppercase = "At least one uppercase letter" [msg.userfront.sections] apps_subtitle = "Your linked apps and their latest sign-in status." audit_subtitle = "Recent access history for Baron sign-in." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -450,6 +465,17 @@ status_history = "Activity history" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -584,6 +610,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 482b2186..3c565d55 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -171,6 +171,215 @@ qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 token_missing = "로그인 토큰을 확인할 수 없습니다." verification_failed = "승인 처리에 실패했습니다: {error}" +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {error}" +client_id = "클라이언트 ID: {id}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {error}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {count}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {name}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {error}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {error}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {error}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +status_history = "상태 이력" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" + +[msg.userfront.dashboard.activities] +empty = "연동된 앱이 없습니다." +empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." +error = "연동 정보를 불러오지 못했습니다." + +[msg.userfront.dashboard.sessions] +browser = "브라우저: {value}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {value}" +recent_app = "최근 접속 앱: {app}" +session_id = "세션 ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {error}" +success = "세션이 종료되었습니다." + +[msg.userfront.dashboard.approved_session] +copy_click = "{label}: {id}\n클릭하면 복사됩니다." +copy_tap = "{label}: {id}\n탭하면 복사됩니다." +none = "{label} 없음" + +[msg.userfront.dashboard.revoke] +confirm = "{app} 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다." +error = "해지 실패: {error}" +success = "{app} 연동이 해지되었습니다." + +[msg.userfront.dashboard.scopes] +empty = "요청된 권한이 없습니다." + +[msg.userfront.dashboard.timeline] +load_error = "접속이력을 불러오지 못했습니다." + +[msg.userfront.error.whitelist] +"$normalizedCode" = "{error}" +bad_request = "입력값을 확인해 주세요." +invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요." +not_found = "요청한 페이지를 찾을 수 없습니다." +password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." +rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요." +recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요." +recovery_invalid = "재설정 링크가 유효하지 않습니다." +settings_disabled = "현재 계정 설정 화면은 준비 중입니다." +verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요." + +[msg.userfront.error.ory] +"$normalizedCode" = "{error}" +access_denied = "사용자가 동의를 거부했습니다." +consent_required = "앱 접근 동의가 필요합니다." +interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요." +invalid_client = "클라이언트 인증 정보가 유효하지 않습니다." +invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다." +invalid_request = "잘못된 요청입니다." +invalid_scope = "요청한 권한 범위가 유효하지 않습니다." +login_required = "로그인이 필요합니다." +request_forbidden = "요청이 거부되었습니다." +server_error = "인증 서버 오류가 발생했습니다." +temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다." +unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다." +unsupported_response_type = "지원하지 않는 응답 타입입니다." + [msg.userfront.login.link] approved = "msg.userfront.login.link.approved" helper = "입력하신 정보로 로그인 링크를 전송합니다." @@ -450,6 +659,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "현재 접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 5b21ea20..57eec236 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -241,6 +241,29 @@ class AuthProxyService { } } + static Future revokeSession(String sessionId) async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.delete(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.revoke', + '세션 종료에 실패했습니다: {{error}}', + detail: response.body, + ); + } + } finally { + client.close(); + } + } + static Future> verifyLoginShortCode( String shortCode, { bool verifyOnly = false, diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart index f45c858f..3f633490 100644 --- a/userfront/lib/features/dashboard/domain/models.dart +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -170,3 +170,59 @@ class RpHistoryItem { ); } } + +class UserSessionSummary { + final String sessionId; + final DateTime? authenticatedAt; + final DateTime? expiresAt; + final DateTime? issuedAt; + final DateTime? lastSeenAt; + final String ipAddress; + final String userAgent; + final String clientId; + final String appName; + final bool isCurrent; + final bool isActive; + + UserSessionSummary({ + required this.sessionId, + this.authenticatedAt, + this.expiresAt, + this.issuedAt, + this.lastSeenAt, + required this.ipAddress, + required this.userAgent, + required this.clientId, + required this.appName, + required this.isCurrent, + required this.isActive, + }); + + factory UserSessionSummary.fromJson(Map json) { + DateTime? parseDate(dynamic raw) { + final value = raw?.toString(); + if (value == null || value.isEmpty) { + return null; + } + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + return null; + } + } + + return UserSessionSummary( + sessionId: json['session_id']?.toString() ?? '', + authenticatedAt: parseDate(json['authenticated_at']), + expiresAt: parseDate(json['expires_at']), + issuedAt: parseDate(json['issued_at']), + lastSeenAt: parseDate(json['last_seen_at']), + ipAddress: json['ip_address']?.toString() ?? '', + userAgent: json['user_agent']?.toString() ?? '', + clientId: json['client_id']?.toString() ?? '', + appName: json['app_name']?.toString() ?? '', + isCurrent: json['is_current'] == true, + isActive: json['is_active'] != false, + ); + } +} diff --git a/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart new file mode 100644 index 00000000..881a88f0 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; +import '../models.dart'; + +class UserSessionsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchSessions(); + } + + String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchSessions() async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final url = Uri.parse('$baseUrl/api/v1/user/sessions'); + + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + try { + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw Exception('Failed to load sessions: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + return items + .whereType>() + .map(UserSessionSummary.fromJson) + .toList(); + } finally { + client.close(); + } + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(_fetchSessions); + } + + Future revokeSession(String sessionId) async { + await AuthProxyService.revokeSession(sessionId); + await refresh(); + } +} + +final userSessionsProvider = + AsyncNotifierProvider>(() { + return UserSessionsNotifier(); + }); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 11f80c7a..bcbbe6ba 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -9,6 +9,7 @@ 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 '../domain/providers/user_sessions_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; @@ -45,6 +46,7 @@ class _DashboardScreenState extends ConsumerState { bool _auditLoading = false; bool _auditLoadingMore = false; bool _isRevoking = false; + String? _revokingSessionId; bool _redirectingToSignin = false; bool _authBootstrapInProgress = false; @@ -130,6 +132,67 @@ class _DashboardScreenState extends ConsumerState { } } + Future _onRevokeSession(UserSessionSummary session) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), + content: Text( + tr( + 'msg.userfront.dashboard.sessions.revoke.confirm', + params: { + 'target': session.isCurrent + ? tr('ui.userfront.dashboard.sessions.current_badge') + : _sessionDisplayLabel(session), + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(tr('ui.common.cancel')), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(tr('ui.userfront.dashboard.sessions.revoke.action')), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + setState(() => _revokingSessionId = session.sessionId); + try { + await ref.read(userSessionsProvider.notifier).revokeSession( + session.sessionId, + ); + if (!mounted) { + return; + } + ToastService.success( + tr('msg.userfront.dashboard.sessions.revoke.success'), + ); + } catch (e) { + if (!mounted) { + return; + } + ToastService.error( + tr( + 'msg.userfront.dashboard.sessions.revoke.error', + params: {'error': '$e'}, + ), + ); + } finally { + if (mounted) { + setState(() => _revokingSessionId = null); + } + } + } + void _onScanQR() { context.push('/scan'); } @@ -310,9 +373,11 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); + ref.invalidate(userSessionsProvider); await Future.wait([ ref.read(linkedRpsProvider.future), + ref.read(userSessionsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); @@ -758,6 +823,15 @@ 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'), @@ -883,6 +957,358 @@ 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) { + int crossAxisCount; + if (constraints.maxWidth > 1200) { + crossAxisCount = 3; + } else if (constraints.maxWidth > 800) { + crossAxisCount = 2; + } else { + crossAxisCount = 1; + } + const spacing = 12.0; + final cardWidth = + (constraints.maxWidth - (spacing * (crossAxisCount - 1))) / + crossAxisCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + 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( + tr( + 'msg.userfront.dashboard.sessions.browser', + params: {'value': browserLabel}, + ), + style: TextStyle(fontSize: 13, color: Colors.grey[700]), + ), + if (osLabel.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + tr( + 'msg.userfront.dashboard.sessions.os', + params: {'value': osLabel}, + ), + style: TextStyle(fontSize: 13, color: Colors.grey[700]), + ), + ], + ], + if (session.clientId.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + tr( + 'msg.userfront.dashboard.client_id', + params: {'id': session.clientId}, + ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.dashboard.sessions.session_id', + params: {'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); + } + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + + String _sessionPrimaryLabel(UserSessionSummary session) { + if (session.isCurrent) { + return tr('ui.userfront.dashboard.sessions.current_badge'); + } + final appName = session.appName.trim(); + if (appName.isNotEmpty) { + return appName; + } + return tr('ui.userfront.dashboard.sessions.unknown_session'); + } + + String _sessionClientLabel(UserSessionSummary session) { + final appName = session.appName.trim(); + if (appName.isEmpty || session.isCurrent) { + return ''; + } + return tr( + 'msg.userfront.dashboard.sessions.recent_app', + params: {'app': appName}, + ); + } + + String _sessionUserAgentLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty) { + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + if (_looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return tr('ui.userfront.device.ios'); + } + if (lower.contains('android')) { + return tr('ui.userfront.device.android'); + } + if (lower.contains('windows')) { + return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); + } + if (lower.contains('linux')) { + return tr('ui.userfront.device.linux'); + } + return userAgent; + } + + String _sessionBrowserLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('edg/')) { + return 'Edge'; + } + if (lower.contains('chrome/') && !lower.contains('edg/')) { + return 'Chrome'; + } + if (lower.contains('firefox/')) { + return 'Firefox'; + } + if (lower.contains('safari/') && !lower.contains('chrome/')) { + return 'Safari'; + } + if (lower.contains('samsungbrowser/')) { + return 'Samsung Internet'; + } + if (lower.contains('flutter')) { + return 'Flutter'; + } + return ''; + } + + String _sessionOsLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return 'iOS'; + } + if (lower.contains('android')) { + return 'Android'; + } + if (lower.contains('windows')) { + return 'Windows'; + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return 'macOS'; + } + if (lower.contains('linux')) { + return 'Linux'; + } + return ''; + } + + bool _looksLikeInternalUserAgent(String userAgent) { + return userAgent.startsWith('go-http-client/') || + userAgent.startsWith('fasthttp') || + userAgent.startsWith('fiber'); + } + Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),