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

@@ -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),