1
0
forked from baron/baron-sso

사용자 활성 세션 조회·종료 API 추가

This commit is contained in:
2026-04-02 11:01:23 +09:00
parent cdf2c36915
commit a2f2b2dd71
15 changed files with 1922 additions and 1 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -241,6 +241,29 @@ class AuthProxyService {
}
}
static Future<void> 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 = <String, String>{'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<Map<String, dynamic>> verifyLoginShortCode(
String shortCode, {
bool verifyOnly = false,

View File

@@ -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<String, dynamic> 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,
);
}
}

View File

@@ -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<List<UserSessionSummary>> {
@override
Future<List<UserSessionSummary>> build() async {
return _fetchSessions();
}
String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
Future<List<UserSessionSummary>> _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 = <String, String>{'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<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
return items
.whereType<Map<String, dynamic>>()
.map(UserSessionSummary.fromJson)
.toList();
} finally {
client.close();
}
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetchSessions);
}
Future<void> revokeSession(String sessionId) async {
await AuthProxyService.revokeSession(sessionId);
await refresh();
}
}
final userSessionsProvider =
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
return UserSessionsNotifier();
});

View File

@@ -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<DashboardScreen> {
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<DashboardScreen> {
}
}
Future<void> _onRevokeSession(UserSessionSummary session) async {
final confirmed = await showDialog<bool>(
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<DashboardScreen> {
_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<DashboardScreen> {
),
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<DashboardScreen> {
);
}
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<UserSessionSummary> 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),