diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 3a71817c..3ca09ca8 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -594,6 +594,44 @@ class AuthProxyService { } } + static Future> fetchLinkedRps() async { + final url = Uri.parse('$_baseUrl/api/v1/user/rp/linked'); + final client = createHttpClient(withCredentials: true); + try { + final response = await client.get( + url, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['items'] ?? []; + } else { + throw Exception('연동된 앱 목록을 불러오지 못했습니다.'); + } + } finally { + client.close(); + } + } + + static Future revokeLinkedRp(String clientId) async { + final url = Uri.parse('$_baseUrl/api/v1/user/rp/linked/$clientId'); + final client = createHttpClient(withCredentials: true); + try { + final response = await client.delete( + url, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode != 200) { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? '연동 해지에 실패했습니다.'); + } + } finally { + client.close(); + } + } + static Future sendLog(String level, String message, {Map? data}) async { if (!_canSendClientLog()) { return; diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 54a07942..4a14e9e6 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; +import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; @@ -159,6 +160,7 @@ class _DashboardScreenState extends ConsumerState { bool _auditLoading = false; bool _auditLoadingMore = false; String? _auditError; + bool _isRevoking = false; Future>? _linkedRpsFuture; bool _showAllActivities = false; @@ -182,6 +184,50 @@ class _DashboardScreenState extends ConsumerState { AuthNotifier.instance.notify(); } + Future _onRevokeLink(String clientId, String appName) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('연동 해지'), + content: Text('$appName 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('해지하기'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isRevoking = true); + try { + await AuthProxyService.revokeLinkedRp(clientId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$appName 연동이 해지되었습니다.')), + ); + _refreshAll(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('해지 실패: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isRevoking = false); + } + } + } + void _onScanQR() { context.push('/scan'); } @@ -828,10 +874,12 @@ class _DashboardScreenState extends ConsumerState { final name = rp.name.isNotEmpty ? rp.name : rp.id; items.add( _ActivityItem( + clientId: rp.id, appName: name, lastAuthAt: lastAuthLabel, status: statusLabel, canLogout: false, + onRevoke: () => _onRevokeLink(rp.id, name), ), ); } @@ -929,16 +977,39 @@ class _DashboardScreenState extends ConsumerState { style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink), ), const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: item.canLogout ? item.onLogout : null, - style: OutlinedButton.styleFrom( - foregroundColor: _ink, - side: const BorderSide(color: _border), + Row( + children: [ + if (item.canLogout) + Expanded( + child: OutlinedButton( + onPressed: item.onLogout, + style: OutlinedButton.styleFrom( + foregroundColor: _ink, + side: const BorderSide(color: _border), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + child: const Text('로그아웃', style: TextStyle(fontSize: 13)), + ), + ), + if (item.canLogout) const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: _isRevoking ? null : item.onRevoke, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent, width: 0.5), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + child: _isRevoking + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent), + ) + : const Text('연동 해지', style: TextStyle(fontSize: 13)), + ), ), - child: const Text('로그아웃'), - ), + ], ), ], ), @@ -1136,17 +1207,21 @@ class _DashboardScreenState extends ConsumerState { } class _ActivityItem { + final String clientId; final String appName; final String lastAuthAt; final String status; final bool canLogout; final VoidCallback? onLogout; + final VoidCallback? onRevoke; _ActivityItem({ + required this.clientId, required this.appName, required this.lastAuthAt, required this.status, required this.canLogout, this.onLogout, + this.onRevoke, }); }