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