1
0
forked from baron/baron-sso

세션정보 누락 해결.

This commit is contained in:
Lectom C Han
2026-01-30 16:49:39 +09:00
parent 1db7ce8f10
commit b39789dbe2
9 changed files with 92 additions and 206 deletions

View File

@@ -348,9 +348,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
separatorBuilder: (_, __) => const Divider(), separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = _users[index]; final user = _users[index];
final userObj = user['user'] ?? {}; // Descope struct structure might vary final userObj = user['user'] ?? {}; // 응답 구조가 케이스마다 다를 수 있음
// Based on Descope API, user root might have fields directly or inside 'user' // 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
// Go SDK SearchAll returns UserResponse struct.
final loginIDs = (user['loginIds'] as List?) ?? []; final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID"; final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
@@ -49,9 +48,8 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
void _redirectIfNotLoggedIn() { void _redirectIfNotLoggedIn() {
if (_redirectingToLogin || !mounted) return; if (_redirectingToLogin || !mounted) return;
final hasStoredToken = AuthTokenStore.getToken() != null; final hasStoredToken = AuthTokenStore.getToken() != null;
final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false;
final usesCookie = AuthTokenStore.usesCookie(); final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie; final isLoggedIn = hasStoredToken || usesCookie;
if (!isLoggedIn) { if (!isLoggedIn) {
_redirectingToLogin = true; _redirectingToLogin = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -65,13 +63,12 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
if (widget.pendingRef == null) return; if (widget.pendingRef == null) return;
final storedToken = AuthTokenStore.getToken(); final storedToken = AuthTokenStore.getToken();
final session = Descope.sessionManager.session;
final usesCookie = AuthTokenStore.usesCookie(); final usesCookie = AuthTokenStore.usesCookie();
var hasCookie = usesCookie; var hasCookie = usesCookie;
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) { if (storedToken == null && !hasCookie) {
hasCookie = await _bootstrapCookieSession(); hasCookie = await _bootstrapCookieSession();
} }
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) { if (storedToken == null && !hasCookie) {
if (mounted) { if (mounted) {
context.go('/signin?notice=qr_login_required'); context.go('/signin?notice=qr_login_required');
} }
@@ -84,7 +81,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
}); });
// jwt 유효성 확인 // jwt 유효성 확인
try { try {
final token = storedToken ?? session?.sessionToken.jwt ?? ''; final token = storedToken ?? '';
await AuthProxyService.approveQrLogin( await AuthProxyService.approveQrLogin(
widget.pendingRef!, widget.pendingRef!,
token: token, token: token,
@@ -109,9 +106,8 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasStoredToken = AuthTokenStore.getToken() != null; final hasStoredToken = AuthTokenStore.getToken() != null;
final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false;
final usesCookie = AuthTokenStore.usesCookie(); final usesCookie = AuthTokenStore.usesCookie();
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession; final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession;
if (!isLoggedIn && !_redirectingToLogin) { if (!isLoggedIn && !_redirectingToLogin) {
_redirectIfNotLoggedIn(); _redirectIfNotLoggedIn();

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
@@ -191,7 +190,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}); });
} }
// Helper to decode JWT and get loginId // JWT를 디코딩해 표시용 로그인 아이디 추출
String _getLoginIdFromJwt(String jwt) { String _getLoginIdFromJwt(String jwt) {
try { try {
final parts = jwt.split('.'); final parts = jwt.split('.');
@@ -199,7 +198,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = json.decode(payload); final data = json.decode(payload);
// Descope tokens usually have 'name', 'email', or 'sub' // 일반적으로 name/email/sub 필드를 사용
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User'; return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
} catch (e) { } catch (e) {
debugPrint("[JWT] Decode error: $e"); debugPrint("[JWT] Decode error: $e");
@@ -207,20 +206,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
} }
// Helper to decode JWT and get User ID (sub claim)
String _getUserIdFromJwt(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) return 'unknown';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = json.decode(payload) as Map<String, dynamic>;
return data['sub'] as String? ?? 'unknown';
} catch (e) {
debugPrint("[JWT] Could not extract User ID (sub): $e");
return 'unknown';
}
}
void _handleTabSelection() { void _handleTabSelection() {
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작 // QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
if (_tabController.index == 2 && _qrPendingRef == null) { if (_tabController.index == 2 && _qrPendingRef == null) {
@@ -350,12 +335,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}) { }) {
final isJwt = token.split('.').length == 3; final isJwt = token.split('.').length == 3;
if (isJwt) { if (isJwt) {
final displayName = _getLoginIdFromJwt(token); _getLoginIdFromJwt(token);
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(token, token, dummyUser);
Descope.sessionManager.manageSession(session);
} }
if (!mounted) return; if (!mounted) return;
@@ -813,33 +793,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_logTokenDetails(token); _logTokenDetails(token);
final userId = _getUserIdFromJwt(token);
final providerName = provider ?? AuthTokenStore.getProvider(); final providerName = provider ?? AuthTokenStore.getProvider();
final isJwt = token.split('.').length == 3;
final isOry = (providerName ?? '').toLowerCase().contains('ory') || !isJwt;
AuthTokenStore.setToken(token, provider: providerName); AuthTokenStore.setToken(token, provider: providerName);
AuthTokenStore.clearPendingProvider(); AuthTokenStore.clearPendingProvider();
// [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트 // 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트
try { try {
if (!isOry) { await ref.read(profileProvider.notifier).loadProfile();
// 임시 세션 생성 (API 호출을 위해)
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
Descope.sessionManager.manageSession(tempSession);
}
// 백엔드 GetMe 호출 (프로필 노티파이어 사용)
final profile = await ref.read(profileProvider.notifier).loadProfile();
if (profile != null && !isOry) {
// 실제 정보로 세션 유저 정보 교체
final realUser = DescopeUser(
userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [],
);
final realSession = DescopeSession.fromJwt(token, token, realUser);
Descope.sessionManager.manageSession(realSession);
}
} catch (e) { } catch (e) {
debugPrint("[Auth] Failed to pre-fetch profile: $e"); debugPrint("[Auth] Failed to pre-fetch profile: $e");
} }

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:descope/descope.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart'; import '../../../core/services/auth_token_store.dart';
@@ -94,7 +93,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
final approveRef = qrData; final approveRef = qrData;
final storedToken = AuthTokenStore.getToken(); final storedToken = AuthTokenStore.getToken();
final sessionToken = storedToken ?? Descope.sessionManager.session?.sessionToken.jwt; final sessionToken = storedToken;
var usesCookie = AuthTokenStore.usesCookie(); var usesCookie = AuthTokenStore.usesCookie();
if (sessionToken == null && !usesCookie) { if (sessionToken == null && !usesCookie) {
usesCookie = await _bootstrapCookieSession(); usesCookie = await _bootstrapCookieSession();

View File

@@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/notifiers/auth_notifier.dart';
@@ -147,7 +146,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
Future<void> _logout() async { Future<void> _logout() async {
Descope.sessionManager.clearSession();
AuthTokenStore.clear(); AuthTokenStore.clear();
AuthNotifier.instance.notify(); AuthNotifier.instance.notify();
} }
@@ -334,9 +332,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (lower.contains('ory')) { if (lower.contains('ory')) {
return 'Ory 세션'; return 'Ory 세션';
} }
if (lower.contains('descope')) {
return 'Descope';
}
return provider; return provider;
} }
@@ -450,11 +445,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider); final profileState = ref.watch(profileProvider);
final profile = profileState.value; final profile = profileState.value;
final user = Descope.sessionManager.session?.user; final userName = profile?.name ??
final userName = user?.name ??
user?.email ??
user?.phone ??
profile?.name ??
profile?.email ?? profile?.email ??
profile?.phone ?? profile?.phone ??
'User'; 'User';

View File

@@ -1,4 +1,3 @@
import 'package:descope/descope.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -43,6 +42,55 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _isCodeSent = false; bool _isCodeSent = false;
bool _isVerifying = false; bool _isVerifying = false;
@override
void initState() {
super.initState();
_nameFocus.addListener(_onNameFocusChange);
_departmentFocus.addListener(_onDepartmentFocusChange);
_phoneFocus.addListener(_onPhoneFocusChange);
_phoneCodeFocus.addListener(_onPhoneCodeFocusChange);
}
void _onNameFocusChange() {
if (!mounted) return;
if (!_nameFocus.hasFocus && _nameTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'name');
} else if (_nameFocus.hasFocus) {
_nameTouched = true;
}
}
void _onDepartmentFocusChange() {
if (!mounted) return;
if (!_departmentFocus.hasFocus && _departmentTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'department');
} else if (_departmentFocus.hasFocus) {
_departmentTouched = true;
}
}
void _onPhoneFocusChange() {
if (!mounted) return;
if (!_phoneFocus.hasFocus && _phoneTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
} else if (_phoneFocus.hasFocus) {
_phoneTouched = true;
}
}
void _onPhoneCodeFocusChange() {
if (!mounted) return;
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
} else if (_phoneCodeFocus.hasFocus) {
_phoneCodeTouched = true;
}
}
@override @override
void dispose() { void dispose() {
_nameController?.dispose(); _nameController?.dispose();
@@ -57,7 +105,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
} }
Future<void> _logout() async { Future<void> _logout() async {
Descope.sessionManager.clearSession();
AuthTokenStore.clear(); AuthTokenStore.clear();
AuthNotifier.instance.notify(); AuthNotifier.instance.notify();
} }
@@ -532,31 +579,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
child: Focus(
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
onFocusChange: (hasFocus) {
if (field == 'name') {
if (hasFocus) {
_nameTouched = true;
return;
}
if (!_nameTouched) {
return;
}
}
if (field == 'department') {
if (hasFocus) {
_departmentTouched = true;
return;
}
if (!_departmentTouched) {
return;
}
}
if (!hasFocus) {
_autoSaveIfEditing(profile, field);
}
},
child: TextField( child: TextField(
controller: controller, controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus, focusNode: field == 'name' ? _nameFocus : _departmentFocus,
@@ -568,7 +590,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
), ),
), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
OutlinedButton( OutlinedButton(
onPressed: isUpdating ? null : () => _cancelEditing(profile), onPressed: isUpdating ? null : () => _cancelEditing(profile),
@@ -605,20 +626,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
child: Focus(
focusNode: _phoneFocus,
onFocusChange: (hasFocus) {
if (hasFocus) {
_phoneTouched = true;
return;
}
if (!_phoneTouched) {
return;
}
if (!hasFocus) {
_handlePhoneFocusChange(profile);
}
},
child: TextField( child: TextField(
controller: _phoneController, controller: _phoneController,
focusNode: _phoneFocus, focusNode: _phoneFocus,
@@ -635,7 +642,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
enabled: !_isPhoneVerified, enabled: !_isPhoneVerified,
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
if (_isPhoneChanged && !_isPhoneVerified) if (_isPhoneChanged && !_isPhoneVerified)
ElevatedButton( ElevatedButton(
@@ -655,20 +661,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
child: Focus(
focusNode: _phoneCodeFocus,
onFocusChange: (hasFocus) {
if (hasFocus) {
_phoneCodeTouched = true;
return;
}
if (!_phoneCodeTouched) {
return;
}
if (!hasFocus) {
_handlePhoneFocusChange(profile);
}
},
child: TextField( child: TextField(
controller: _codeController, controller: _codeController,
focusNode: _phoneCodeFocus, focusNode: _phoneCodeFocus,
@@ -681,7 +673,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
), ),
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( ElevatedButton(
onPressed: _isVerifying ? null : () => _verifyCode(profile), onPressed: _isVerifying ? null : () => _verifyCode(profile),

View File

@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:flutter_web_plugins/url_strategy.dart';
@@ -66,21 +65,6 @@ void main() async {
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts(); await _loadBundledFonts();
// Initialize Descope (프로젝트 ID가 없으면 경고만 남기고 진행)
final projectId = dotenv.maybeGet('DESCOPE_PROJECT_ID') ?? '';
if (projectId.isEmpty || projectId == 'your-project-id') {
_log.severe("DESCOPE_PROJECT_ID is missing. Descope may not work correctly.");
}
Descope.setup(projectId);
// Load saved session if any
try {
// 저장된 세션 불러옴
await Descope.sessionManager.loadSession();
} catch (e) {
_log.warning("Failed to load session: $e");
}
runApp(const ProviderScope(child: BaronSSOApp())); runApp(const ProviderScope(child: BaronSSOApp()));
} }
@@ -189,11 +173,9 @@ final _router = GoRouter(
), ),
], ],
redirect: (context, state) { redirect: (context, state) {
final hasDescopeSession =
Descope.sessionManager.session?.refreshToken?.isExpired == false;
final hasStoredToken = AuthTokenStore.getToken() != null; final hasStoredToken = AuthTokenStore.getToken() != null;
final hasCookieSession = AuthTokenStore.usesCookie(); final hasCookieSession = AuthTokenStore.usesCookie();
final isLoggedIn = hasDescopeSession || hasStoredToken || hasCookieSession; final isLoggedIn = hasStoredToken || hasCookieSession;
final path = state.uri.path; final path = state.uri.path;
// Public paths that don't require login // Public paths that don't require login

View File

@@ -97,14 +97,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cryptography:
dependency: transitive
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -113,14 +105,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
descope:
dependency: "direct main"
description:
name: descope
sha256: cae7d22e47d7d1c35d5a1cdfad2ae5f3b5e755476f215ddfa4dbeab6937f3ac2
url: "https://pub.dev"
source: hosted
version: "0.9.12"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -129,14 +113,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.5"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -248,14 +224,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
url: "https://pub.dev"
source: hosted
version: "4.10.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,4 +1,4 @@
name: app.brsw.kr name: userfront
description: "A new Flutter project." description: "A new Flutter project."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
@@ -36,7 +36,6 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_riverpod: ^3.0.3 flutter_riverpod: ^3.0.3
go_router: ^17.0.1 go_router: ^17.0.1
descope: ^0.9.11
http: ^1.6.0 http: ^1.6.0
flutter_dotenv: ^6.0.0 flutter_dotenv: ^6.0.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2