forked from baron/baron-sso
사용자 활성 세션 조회·종료 API 추가
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user