diff --git a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart new file mode 100644 index 00000000..cb7cb716 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart @@ -0,0 +1,21 @@ +import 'providers/linked_rps_provider.dart'; + +String? resolveLinkedRpLaunchUrl(LinkedRp rp) { + final normalizedStatus = rp.status.trim().toLowerCase(); + final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active'; + if (!isActive) { + return null; + } + + final initUrl = rp.initUrl.trim(); + if (initUrl.isNotEmpty) { + return initUrl; + } + + final url = rp.url.trim(); + if (url.isNotEmpty) { + return url; + } + + return null; +} diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart index 3f633490..0fa73d4e 100644 --- a/userfront/lib/features/dashboard/domain/models.dart +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -96,6 +96,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -105,6 +106,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, this.lastAuthenticatedAt, @@ -126,6 +128,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart index b571351c..2c8ddbd3 100644 --- a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -10,6 +10,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -19,6 +20,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, required this.lastAuthenticatedAt, @@ -40,6 +42,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? 'unknown', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d968c49d..d06ef9c2 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_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../domain/linked_rp_launch.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/user_sessions_provider.dart'; @@ -1216,6 +1217,7 @@ class _DashboardScreenState extends ConsumerState { isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, + launchUrl: resolveLinkedRpLaunchUrl(rp), lastAuthDateTime: rp.lastAuthenticatedAt, ), ); @@ -1460,7 +1462,7 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { - final itemUrl = item.url; + final itemUrl = item.launchUrl; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); final canOpen = await canLaunchUrl(uri); @@ -2291,6 +2293,7 @@ class _ActivityItem { final String lastAuthAt; final String status; final String? url; + final String? launchUrl; final List scopes; final bool isRevoked; final VoidCallback? onRevoke; @@ -2303,6 +2306,7 @@ class _ActivityItem { required this.status, required this.scopes, this.url, + this.launchUrl, this.isRevoked = false, this.onRevoke, this.lastAuthDateTime, diff --git a/userfront/test/linked_rp_launch_test.dart b/userfront/test/linked_rp_launch_test.dart new file mode 100644 index 00000000..3e06e01c --- /dev/null +++ b/userfront/test/linked_rp_launch_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart'; +import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart'; + +LinkedRp _linkedRp({ + required String status, + String url = '', + String initUrl = '', +}) { + return LinkedRp( + id: 'client-1', + name: 'Example App', + logo: '', + url: url, + initUrl: initUrl, + status: status, + scopes: const ['openid', 'profile'], + lastAuthenticatedAt: null, + ); +} + +void main() { + test('LinkedRp.fromJson은 init_url을 읽는다', () { + final rp = LinkedRp.fromJson({ + 'id': 'client-1', + 'name': 'Example App', + 'status': 'active', + 'url': 'https://example.com', + 'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + }); + + expect( + rp.initUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'active', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect( + launchUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp(status: 'active', url: 'https://example.com'), + ); + + expect(launchUrl, 'https://example.com'); + }); + + test('비활성 앱은 진입 URL을 만들지 않는다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'inactive', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect(launchUrl, isNull); + }); +}