diff --git a/locales/ko.toml b/locales/ko.toml index 72bf3eed..9a3aa1c8 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2481,7 +2481,7 @@ linked = "연동됨" [ui.userfront.dashboard.sessions] active_badge = "활성화" -current_badge = "현재 접속중" +current_badge = "접속중" current_disabled = "현재 세션" unknown_device = "알 수 없는 기기" unknown_session = "세션 정보" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 3a27641a..9791416c 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -47,11 +47,13 @@ greeting = "Hello, {name}." date = "Date: {value}" device = "Device: {value}" end = "No more items to show." +filtered_empty = "There are no active or current sessions to display." +filter.description = "Turn this on to show only active or current sessions." ip = "IP address: {value}" load_more_error = "Could not load more history." result = "Result: {value}" session_id = "Session ID: {value}" -status = "Status: pending" +status = "Status: {value}" [msg.userfront.consent] accept_error = "Failed to process consent: {error}" @@ -283,7 +285,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." +audit_subtitle = "Review current session status and recent sign-in history together." sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] @@ -434,7 +436,12 @@ dev_console = "Dev Console" [ui.userfront.audit] +[ui.userfront.audit.filter] +title = "Session filter" +toggle_label = "Active only" + [ui.userfront.audit.table] +action = "Action" app = "App" auth_method = "Auth Method" date = "Date" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 9fc24973..247f2bfd 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -44,11 +44,13 @@ missing = "활성 세션이 없습니다." date = "접속일자: {value}" device = "접속환경: {value}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성화 또는 접속중인 세션이 없습니다." +filter.description = "토글을 켜면 활성화 또는 접속중인 세션만 표시됩니다." ip = "접속 IP: {value}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {value}" session_id = "Session ID: {value}" -status = "현황: (준비중)" +status = "현황: {value}" [msg.userfront.dashboard] approved_device = "승인 기기: {device}" @@ -138,7 +140,7 @@ success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그 [msg.userfront.sections] apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." -audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +audit_subtitle = "현재 세션 현황과 최근 접근 기록을 함께 확인할 수 있습니다." sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." [msg.userfront.settings] @@ -487,7 +489,7 @@ uppercase = "대문자 1개 이상" [msg.userfront.sections] apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." -audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +audit_subtitle = "현재 세션 현황과 최근 접근 기록을 함께 확인할 수 있습니다." [msg.userfront.settings] disabled = "현재 계정 설정 화면은 준비 중입니다." @@ -637,7 +639,12 @@ dev_console = "Dev Console" [ui.userfront.audit] +[ui.userfront.audit.filter] +title = "세션 필터" +toggle_label = "활성 세션만" + [ui.userfront.audit.table] +action = "관리" app = "애플리케이션" auth_method = "인증수단" date = "접속일자" @@ -670,7 +677,7 @@ linked = "연동됨" [ui.userfront.dashboard.sessions] active_badge = "활성화" -current_badge = "현재 접속중" +current_badge = "접속중" current_disabled = "현재 세션" unknown_device = "알 수 없는 기기" unknown_session = "세션 정보" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index c902ac09..d51aa5c6 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -226,6 +226,8 @@ greeting = "" date = "" device = "" end = "" +filtered_empty = "" +filter.description = "" ip = "" load_more_error = "" result = "" @@ -612,7 +614,12 @@ dev_console = "" [ui.userfront.audit] +[ui.userfront.audit.filter] +title = "" +toggle_label = "" + [ui.userfront.audit.table] +action = "" app = "" auth_method = "" date = "" diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index fc93c46f..8b575324 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -53,6 +53,7 @@ class _DashboardScreenState extends ConsumerState { bool _authBootstrapInProgress = false; bool _showAllActivities = false; + bool _showActiveSessionsOnly = false; final Set _revokedClientIds = {}; String _renderTranslatedText( @@ -836,13 +837,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 +966,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 +1464,161 @@ 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: 13, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + Switch( + value: _showActiveSessionsOnly, + onChanged: (value) { + setState(() { + _showActiveSessionsOnly = value; + }); + }, + ), + ], + ), + ], + ); } Widget _buildHistoryContainer({required Widget child}) { @@ -1764,6 +1634,106 @@ 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 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 _selectableText(tr('ui.common.hyphen', fallback: '-')); + } + final isCurrent = session.isCurrent; + final canRevoke = + !isCurrent && _revokingSessionId == null && session.isActive; + return SizedBox( + width: 108, + 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 +1749,16 @@ class _DashboardScreenState extends ConsumerState { crossAxisCount; } - Widget _buildHistoryTable(AuthTimelineState state) { + 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( @@ -1830,8 +1806,16 @@ class _DashboardScreenState extends ConsumerState { DataColumn( label: Text(tr('ui.userfront.audit.table.status')), ), + DataColumn( + label: Text(tr('ui.userfront.audit.table.action')), + ), ], - 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'); @@ -1879,11 +1863,9 @@ class _DashboardScreenState extends ConsumerState { ), ), ), + DataCell(_buildHistoryStatusBadge(sessionStatus)), DataCell( - _selectableText( - tr('ui.userfront.audit.table.pending'), - style: const TextStyle(color: Colors.grey), - ), + _buildHistorySessionActionCell(matchedSession), ), ], ); @@ -1932,11 +1914,17 @@ class _DashboardScreenState extends ConsumerState { 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 +1936,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( @@ -2025,8 +2022,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 +2150,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..db6b951a 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -1715,7 +1715,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": "세션 종료",