forked from baron/baron-sso
사용자 활성 세션 조회·종료 API 추가
This commit is contained in:
@@ -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