From a6e37b97c6d1180f556106a830084f11700665ab Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 3 Feb 2026 12:32:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B0=8F=20UI=20=EB=9E=9C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=84=EB=A9=B4=20=EC=9E=AC=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/consent_screen.dart | 709 ++++++------------ 1 file changed, 242 insertions(+), 467 deletions(-) diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 904c7aae..679478da 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -22,6 +22,21 @@ class _ConsentScreenState extends State { bool _isLoading = true; bool _isSubmitting = false; String? _error; + + // 사용자가 선택한 스코프 목록 + final Set _selectedScopes = {}; + + // 권한별 설명 매핑 (동적으로 업데이트됨) + Map _scopeDescriptions = { + 'openid': 'OpenID 인증 정보 (로그인 상태 확인)', + 'profile': '기본 프로필 정보 (이름, 사용자 식별자)', + 'email': '이메일 주소 (계정 식별 및 알림 용도)', + 'offline_access': '오프라인 접근 (로그인 유지)', + 'phone': '휴대폰 번호 (본인 인증 및 알림)', + }; + + // 필수 권한 목록 (동적으로 업데이트됨) + Set _mandatoryScopes = {'openid'}; @override void initState() { @@ -32,13 +47,44 @@ class _ConsentScreenState extends State { Future _fetchConsentInfo() async { try { final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); + + // 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용 + if (info['scope_details'] != null) { + final details = info['scope_details'] as Map; + + details.forEach((scope, detail) { + if (detail is Map) { + // 설명 업데이트 + if (detail['description'] != null && detail['description'].toString().isNotEmpty) { + _scopeDescriptions[scope] = detail['description'].toString(); + } + // 필수 여부 업데이트 + if (detail['mandatory'] == true) { + _mandatoryScopes.add(scope); + } else { + // openid는 기본적으로 필수지만 설정에서 굳이 껐다면? + // 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만, + // 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음. + // 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음) + if (scope != 'openid') { + _mandatoryScopes.remove(scope); + } + } + } + }); + } + + // 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택 + final requestedScopes = (info['requested_scope'] as List?)?.cast() ?? []; + _selectedScopes.addAll(requestedScopes); + setState(() { _consentInfo = info; _isLoading = false; }); } catch (e) { setState(() { - _error = '권한 정보를 불러오지 못했습니다: $e'; + _error = '동의 정보를 불러오는데 실패했습니다: $e'; _isLoading = false; }); } @@ -50,491 +96,220 @@ class _ConsentScreenState extends State { _error = null; }); try { - final result = - await AuthProxyService.acceptConsent(widget.consentChallenge); - final redirectTo = result['redirectTo']?.toString() ?? ''; - if (redirectTo.isNotEmpty) { - if (webWindow.hasOpener() && webWindow.redirectOpenerTo(redirectTo)) { - // 팝업에서 호출된 경우, 부모 창으로 리다이렉트 후 현재 창을 닫습니다. - webWindow.close(); - return; - } - webWindow.redirectTo(redirectTo); - return; + // 선택된 스코프만 리스트로 변환하여 전송 + final result = await AuthProxyService.acceptConsent( + widget.consentChallenge, + grantScope: _selectedScopes.toList(), + ); + + if (result['redirectTo'] != null) { + html.window.location.href = result['redirectTo']; + } else { + setState(() { + _error = '동의가 처리되었으나, 리다이렉트 URL을 받지 못했습니다.'; + _isLoading = false; + }); } - setState(() { - _error = '동의는 완료됐지만 이동할 주소를 받지 못했습니다.'; - }); } catch (e) { setState(() { - _error = '동의 처리 중 오류가 발생했습니다: $e'; + _error = '동의 처리에 실패했습니다: $e'; + _isLoading = false; }); - } finally { - if (mounted) { - setState(() => _isSubmitting = false); - } } } - void _rejectConsent() { - webWindow.alert('동의를 취소했습니다. 창을 닫아 주세요.'); - } - - Map? _client() { - final info = _consentInfo; - if (info == null) return null; - final client = info['client']; - if (client is Map) { - return client; - } - return null; - } - - String _resolveClientName(Map? client) { - final name = client?['client_name']?.toString().trim(); - if (name != null && name.isNotEmpty) { - return name; - } - final id = client?['client_id']?.toString().trim(); - if (id != null && id.isNotEmpty) { - return id; - } - return '알 수 없는 앱'; - } - - String? _resolveClientId(Map? client) { - final id = client?['client_id']?.toString().trim(); - if (id != null && id.isNotEmpty) { - return id; - } - return null; - } - - String? _resolveClientLogo(Map? client) { - final logo = client?['logo_uri']?.toString().trim(); - if (logo != null && logo.isNotEmpty) { - return logo; - } - final metadata = client?['metadata']; - if (metadata is Map) { - final metaLogo = metadata['logo_url']?.toString().trim(); - if (metaLogo != null && metaLogo.isNotEmpty) { - return metaLogo; - } - } - return null; - } - - List _requestedScopes() { - final scopes = _consentInfo?['requested_scope']; - if (scopes is List) { - return scopes.map((e) => e.toString()).toList(); - } - return const []; - } - - String _scopeDescription(String scope) { - switch (scope) { - case 'openid': - return '로그인 상태 확인을 위한 기본 식별자'; - case 'profile': - return '이름, 사용자 식별자 등 기본 프로필 정보'; - case 'email': - return '이메일 주소 정보'; - case 'phone': - return '휴대폰 번호 정보'; - default: - return '앱에서 요청한 추가 권한'; - } - } - - Widget _buildInfoChip(IconData icon, String label) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: _subtle, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: _border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: _ink), - const SizedBox(width: 6), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: _ink, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); + void _onCancel() { + // 취소 시 동작 (창 닫기 시도 또는 알림) + // 실제 프로덕션에서는 Hydra reject API를 호출하고 리다이렉트 하는 것이 좋습니다. + // 현재는 요구사항에 따라 간단히 처리합니다. + html.window.alert('동의를 취소했습니다. 창을 닫아주세요.'); + // html.window.close(); // 브라우저 정책상 스크립트로 연 창이 아니면 닫히지 않을 수 있음 } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final client = _client(); - final clientName = _resolveClientName(client); - final clientId = _resolveClientId(client); - final logoUrl = _resolveClientLogo(client); - final scopes = _requestedScopes(); - + // 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용 return Scaffold( - backgroundColor: _subtle, - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Container( - decoration: BoxDecoration( - color: _surface, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: _border), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], + backgroundColor: Colors.grey[100], + body: Center( + child: _isLoading + ? const CircularProgressIndicator() + : _error != null + ? _buildErrorCard() + : _buildConsentCard(context), + ), + ); + } + + Widget _buildErrorCard() { + return Card( + margin: const EdgeInsets.all(24), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text(_error!, textAlign: TextAlign.center), + ], + ), + ), + ); + } + + Widget _buildConsentCard(BuildContext context) { + final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱'; + final clientLogo = _consentInfo?['client']?['logo_uri']; + final requestedScopes = (_consentInfo?['requested_scope'] as List?)?.cast() ?? []; + + return SingleChildScrollView( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + margin: const EdgeInsets.all(16), + child: Card( + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. 헤더 영역 + const Text( + '접근 권한 요청', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, ), - child: Padding( - padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), - child: _isLoading - ? Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 12), + Text( + '아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요.', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + // 2. 서비스 정보 영역 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + if (clientLogo != null && clientLogo.toString().isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 16), + child: CircleAvatar( + radius: 24, + backgroundImage: NetworkImage(clientLogo), + backgroundColor: Colors.transparent, + ), + ) + else + const Padding( + padding: EdgeInsets.only(right: 16), + child: CircleAvatar( + radius: 24, + child: Icon(Icons.apps), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), Text( - '권한 정보를 불러오는 중입니다...', - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.grey[600], + clientName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, ), ), + // 부가 설명이 있다면 여기에 표시 (Hydra 메타데이터 활용 가능) ], - ) - : _consentInfo == null - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '요청 정보를 확인할 수 없습니다.', - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.grey[700], - ), - ), - if (_error != null) ...[ - const SizedBox(height: 12), - Text( - _error!, - style: theme.textTheme.bodySmall?.copyWith( - color: const Color(0xFFB91C1C), - ), - ), - ], - const SizedBox(height: 16), - OutlinedButton( - onPressed: _fetchConsentInfo, - style: OutlinedButton.styleFrom( - foregroundColor: _ink, - side: const BorderSide(color: _border), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: const Text('다시 시도'), - ), - ], - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: _subtle, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: _border), - ), - child: logoUrl == null - ? const Icon( - Icons.lock_outline, - color: _ink, - ) - : ClipRRect( - borderRadius: - BorderRadius.circular(14), - child: Image.network( - logoUrl, - fit: BoxFit.cover, - errorBuilder: ( - context, - error, - stackTrace, - ) { - return const Icon( - Icons.lock_outline, - color: _ink, - ); - }, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - '앱 권한 요청', - style: theme.textTheme.titleLarge - ?.copyWith( - fontWeight: FontWeight.w700, - color: _ink, - ), - ), - const SizedBox(height: 6), - Text( - clientName, - style: theme.textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (clientId != null) - _buildInfoChip( - Icons.vpn_key_outlined, - 'Client ID: $clientId', - ), - _buildInfoChip( - Icons.security_outlined, - '요청 권한 ${scopes.length}개', - ), - ], - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Text( - '이 앱이 아래 정보에 접근하려고 합니다. 계속 진행하려면 동의 여부를 선택해 주세요.', - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.grey[700], - height: 1.5, - ), - ), - if (_error != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFFEE2E2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFFFCA5A5), - ), - ), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Icon( - Icons.error_outline, - color: Color(0xFFB91C1C), - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _error!, - style: theme.textTheme.bodySmall - ?.copyWith( - color: const Color(0xFFB91C1C), - height: 1.4, - ), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 20), - Text( - '요청된 권한', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - const SizedBox(height: 12), - if (scopes.isEmpty) - Text( - '요청된 권한 정보가 없습니다.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ) - else - Column( - children: scopes - .map( - (scope) => Container( - margin: const EdgeInsets.only( - bottom: 10, - ), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _subtle, - borderRadius: - BorderRadius.circular(12), - border: - Border.all(color: _border), - ), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Icon( - Icons.check_circle_outline, - color: _accent, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - Text( - scope, - style: theme.textTheme - .bodyMedium - ?.copyWith( - fontWeight: - FontWeight.w600, - color: _ink, - ), - ), - const SizedBox(height: 4), - Text( - _scopeDescription( - scope), - style: theme.textTheme - .bodySmall - ?.copyWith( - color: - Colors.grey[600], - ), - ), - ], - ), - ), - ], - ), - ), - ) - .toList(), - ), - const SizedBox(height: 12), - Text( - '동의 후 자동으로 서비스로 이동합니다.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), - const SizedBox(height: 20), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - OutlinedButton( - onPressed: _isSubmitting - ? null - : _rejectConsent, - style: OutlinedButton.styleFrom( - foregroundColor: _ink, - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - side: const BorderSide( - color: _border, - ), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - ), - child: const Text('취소'), - ), - FilledButton( - onPressed: _isSubmitting - ? null - : _acceptConsent, - style: FilledButton.styleFrom( - backgroundColor: _ink, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - ), - child: _isSubmitting - ? Row( - mainAxisSize: MainAxisSize.min, - children: const [ - SizedBox( - width: 16, - height: 16, - child: - CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 8), - Text('처리 중...'), - ], - ) - : const Text('동의하고 계속하기'), - ), - ], - ), - ], - ), + ), + ), + ], + ), ), - ), + const SizedBox(height: 32), + + // 3. 권한 선택 영역 + const Text( + '요청된 권한', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Divider(), + ...requestedScopes.map((scope) { + final isMandatory = _mandatoryScopes.contains(scope); + final description = _scopeDescriptions[scope] ?? scope; + final isSelected = _selectedScopes.contains(scope); + + return CheckboxListTile( + title: Text( + scope, // 스코프 키 (예: openid) + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text(description), + value: isSelected, + onChanged: isMandatory + ? null // 필수 항목은 변경 불가 (비활성화 상태로 체크됨) + : (bool? value) { + setState(() { + if (value == true) { + _selectedScopes.add(scope); + } else { + _selectedScopes.remove(scope); + } + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: Theme.of(context).primaryColor, + ); + }).toList(), + const Divider(), + const SizedBox(height: 32), + + // 4. 버튼 영역 + ElevatedButton( + onPressed: _acceptConsent, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: const Color(0xFF1A1F2C), // 브랜드 컬러 + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + child: const Text( + '동의하고 계속하기', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _onCancel, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('취소'), + ), + const SizedBox(height: 16), + Text( + '동의 후 자동으로 서비스로 이동합니다.', + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + textAlign: TextAlign.center, + ), + ], ), ), ),