Skip to content

Commit 09e89ab

Browse files
committed
feat: auth refresh + group flow fix
1 parent b929b6b commit 09e89ab

File tree

9 files changed

+234
-55
lines changed

9 files changed

+234
-55
lines changed

leaderboard_app/lib/chatpage-components/chat_view.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ class _ChatViewState extends State<ChatView> {
9595
builder: (_) => GroupInfoPage(groupId: widget.groupId, initialName: widget.groupName),
9696
),
9797
).then((result) {
98-
if (result is Map && result['leftGroup'] == true) {
99-
if (mounted) Navigator.of(context).pop();
98+
if (result is Map && (result['leftGroup'] == true || result['deletedGroup'] == true)) {
99+
if (mounted) Navigator.of(context).pop(result);
100100
}
101101
});
102102
},

leaderboard_app/lib/models/auth_models.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@ class AuthResponse {
22
final bool success;
33
final String message;
44
final String token;
5+
final String? refreshToken;
56
final User user;
67

78
AuthResponse({
89
required this.success,
910
required this.message,
1011
required this.token,
12+
this.refreshToken,
1113
required this.user,
1214
});
1315

1416
factory AuthResponse.fromJson(Map<String, dynamic> json) {
1517
final data = (json['data'] ?? {}) as Map<String, dynamic>;
1618
final userJson = (data['user'] ?? json['user'] ?? {}) as Map<String, dynamic>;
1719
final token = (data['token'] ?? json['token'] ?? '') as String;
20+
final refresh = (data['refreshToken'] ?? data['refresh_token'] ?? json['refreshToken'] ?? json['refresh_token'] ?? '') as String;
1821
return AuthResponse(
1922
success: json['success'] == true || json['ok'] == true,
2023
message: (json['message'] ?? json['msg'] ?? '') as String,
2124
token: token,
25+
refreshToken: refresh.isEmpty ? null : refresh,
2226
user: User.fromJson(userJson),
2327
);
2428
}

leaderboard_app/lib/pages/chatlists_page.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ class _ChatlistsPageState extends State<ChatlistsPage> {
203203
ScaffoldMessenger.of(context).showSnackBar(
204204
const SnackBar(content: Text('Group created')),
205205
);
206+
// Refresh lists to reflect membership and server-derived fields
207+
// (e.g., lastMessage, counts, privacy flags, etc.)
208+
// Uses current search filter.
209+
// Fire-and-forget; UI already shows snackbar feedback.
210+
unawaited(_refresh());
206211
}
207212
},
208213
icon: provider.isCreating
@@ -433,19 +438,26 @@ class _ChatlistsPageState extends State<ChatlistsPage> {
433438
}
434439
if (!mounted) return;
435440
if (isMember) {
436-
Navigator.push(
441+
final result = await Navigator.push(
437442
context,
438443
MaterialPageRoute(
439444
builder: (context) => ChatPage(groupId: groupId, groupName: groupName),
440445
),
441446
);
447+
if (result is Map && (result['deletedGroup'] == true || result['leftGroup'] == true || result['updated'] == true)) {
448+
await _refresh();
449+
}
442450
} else {
443-
Navigator.push(
451+
final result = await Navigator.push(
444452
context,
445453
MaterialPageRoute(
446454
builder: (context) => GroupInfoPage(groupId: groupId, initialName: groupName),
447455
),
448456
);
457+
// If group was deleted or membership changed, refresh list on return
458+
if (result is Map && (result['deletedGroup'] == true || result['leftGroup'] == true || result['updated'] == true)) {
459+
await _refresh();
460+
}
449461
}
450462
},
451463
child: Container(

leaderboard_app/lib/pages/groupinfo_page.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -627,8 +627,9 @@ class _GroupInfoPageState extends State<GroupInfoPage> {
627627
final chatListProv = context.read<ChatListProvider?>();
628628
chatListProv?.removeGroup(_group!.id);
629629
}
630-
if (!mounted) return;
631-
Navigator.of(context).pop();
630+
if (!mounted) return;
631+
// Return a signal so the previous page can refresh its data.
632+
Navigator.of(context).pop({'deletedGroup': true, 'groupId': _group!.id});
632633
} catch (_) {
633634
setState(() => _error = 'Failed to delete group');
634635
} finally {

leaderboard_app/lib/services/auth/auth_service.dart

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:leaderboard_app/services/core/api_client.dart';
44
import 'package:shared_preferences/shared_preferences.dart';
55
import 'package:leaderboard_app/services/core/dio_provider.dart';
66
import 'package:leaderboard_app/services/chat/chat_service.dart';
7+
import 'package:leaderboard_app/services/core/token_manager.dart';
78

89
class AuthService {
910
final Dio _dio;
@@ -26,7 +27,7 @@ class AuthService {
2627
final login = await signIn(email: email, password: password);
2728
return login;
2829
} else {
29-
await _saveAuth(response.token);
30+
await _saveAuth(response.token, refreshToken: response.refreshToken);
3031
// Initialize a fresh socket connection with the new token.
3132
try { await ChatService.instance.connectWithToken(response.token); } catch (_) {}
3233
return response;
@@ -42,20 +43,21 @@ class AuthService {
4243
if (response.token.isEmpty) {
4344
throw DioException(requestOptions: res.requestOptions, response: res, message: 'Token missing in response');
4445
}
45-
await _saveAuth(response.token);
46+
await _saveAuth(response.token, refreshToken: response.refreshToken);
4647
// After storing token, connect socket with new identity.
4748
try { await ChatService.instance.connectWithToken(response.token); } catch (_) {}
4849
return response;
4950
}
5051

5152
Future<void> logout() async {
52-
final prefs = await SharedPreferences.getInstance();
53+
final prefs = await SharedPreferences.getInstance();
5354
// Clear all persisted user-specific data on logout to avoid leaking
5455
// authentication state or cached profile details between accounts.
5556
// If in the future some keys should persist across logins (e.g. theme),
5657
// fetch their values first and re-set them after clear().
5758
await prefs.clear();
58-
DioProvider.reset();
59+
await TokenManager.clearTokens();
60+
DioProvider.reset();
5961
// Proactively disconnect socket (in case ChatProvider not yet instantiated to reset it)
6062
try { ChatService.instance.disconnect(); } catch (_) {}
6163
// Also reset any cached singletons that embed auth headers (e.g. Dio).
@@ -79,8 +81,7 @@ class AuthService {
7981
return User.fromJson(userJson);
8082
}
8183

82-
Future<void> _saveAuth(String token) async {
83-
final prefs = await SharedPreferences.getInstance();
84-
await prefs.setString('authToken', token);
84+
Future<void> _saveAuth(String token, {String? refreshToken}) async {
85+
await TokenManager.saveTokens(accessToken: token, refreshToken: refreshToken);
8586
}
8687
}
Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'package:dio/dio.dart';
2-
import 'package:shared_preferences/shared_preferences.dart';
32
import 'package:leaderboard_app/config/api_config.dart';
3+
import 'package:leaderboard_app/services/core/dio_provider.dart';
44

55
class ApiClient {
66
static final String kBaseUrl = ApiConfig.baseUrl;
@@ -9,34 +9,7 @@ class ApiClient {
99
ApiClient._internal(this.dio);
1010

1111
static Future<ApiClient> create({String? baseUrl}) async {
12-
final prefs = await SharedPreferences.getInstance();
13-
final dio = Dio(
14-
BaseOptions(
15-
baseUrl: baseUrl ?? kBaseUrl,
16-
connectTimeout: const Duration(seconds: 10),
17-
receiveTimeout: const Duration(seconds: 20),
18-
headers: {
19-
'Content-Type': 'application/json',
20-
},
21-
),
22-
);
23-
24-
dio.interceptors.add(
25-
InterceptorsWrapper(
26-
onRequest: (options, handler) async {
27-
final token = prefs.getString('authToken');
28-
if (token != null && token.isNotEmpty) {
29-
options.headers['Authorization'] = 'Bearer $token';
30-
}
31-
handler.next(options);
32-
},
33-
onError: (e, handler) {
34-
// You can add logging or global error handling here
35-
handler.next(e);
36-
},
37-
),
38-
);
39-
12+
final dio = await DioProvider.getInstance(baseUrl: baseUrl ?? kBaseUrl);
4013
return ApiClient._internal(dio);
4114
}
4215
}

leaderboard_app/lib/services/core/dio_provider.dart

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,85 @@
11
import 'dart:async';
22
import 'package:dio/dio.dart';
33
import 'package:flutter/foundation.dart' show kIsWeb; // narrow imports
4-
import 'package:shared_preferences/shared_preferences.dart';
54
import 'package:leaderboard_app/config/api_config.dart';
5+
import 'package:leaderboard_app/services/core/token_manager.dart';
66

77
/// Provides a configured singleton Dio instance with auth header + logging.
88
class DioProvider {
99
static Dio? _dio;
10+
static Future<String?>? _refreshingFuture;
1011

1112
static Future<Dio> getInstance({String? baseUrl}) async {
1213
if (_dio != null) return _dio!;
13-
final prefs = await SharedPreferences.getInstance();
14+
// Note: Token values are retrieved on-demand via TokenManager.
1415
final dio = Dio(
1516
BaseOptions(
1617
baseUrl: baseUrl ?? ApiConfig.baseUrl,
17-
connectTimeout: const Duration(seconds: 10),
18-
receiveTimeout: const Duration(seconds: 20),
18+
connectTimeout: const Duration(seconds: 15),
19+
receiveTimeout: const Duration(seconds: 60),
1920
headers: {'Content-Type': 'application/json'},
2021
),
2122
);
2223

2324
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async {
24-
final token = prefs.getString('authToken');
25+
// Skip auth header for explicit opt-out
26+
if (options.extra['skipAuth'] == true) {
27+
handler.next(options);
28+
return;
29+
}
30+
final token = await TokenManager.getAccessToken();
2531
if (token != null && token.isNotEmpty) {
2632
options.headers['Authorization'] = 'Bearer $token';
2733
}
2834
handler.next(options);
29-
}, onError: (e, handler) {
35+
}, onError: (e, handler) async {
3036
// Simple retry for idempotent GETs on network issues
3137
if (_shouldRetry(e)) {
3238
_retry(dio, e.requestOptions).then(handler.resolve).catchError((_) => handler.next(e));
33-
} else {
34-
handler.next(e);
39+
return;
3540
}
41+
42+
// Refresh on 401 Unauthorized, excluding auth endpoints to avoid loops
43+
final status = e.response?.statusCode;
44+
final path = e.requestOptions.path;
45+
final isAuthPath = path.contains('/auth/login') || path.contains('/auth/signup') || path.contains('/auth/refresh');
46+
final alreadyRetried = e.requestOptions.extra['retried'] == true;
47+
48+
if (status == 401 && !isAuthPath && !alreadyRetried) {
49+
try {
50+
final newToken = await _refreshTokenIfNeeded(dio);
51+
if (newToken != null && newToken.isNotEmpty) {
52+
// Clone and retry original request with new token
53+
final opts = Options(
54+
method: e.requestOptions.method,
55+
headers: {
56+
...e.requestOptions.headers,
57+
'Authorization': 'Bearer $newToken',
58+
},
59+
responseType: e.requestOptions.responseType,
60+
contentType: e.requestOptions.contentType,
61+
sendTimeout: e.requestOptions.sendTimeout,
62+
receiveTimeout: e.requestOptions.receiveTimeout,
63+
extra: {
64+
...e.requestOptions.extra,
65+
'retried': true,
66+
},
67+
);
68+
final rerun = await dio.request<dynamic>(
69+
e.requestOptions.path,
70+
data: e.requestOptions.data,
71+
queryParameters: e.requestOptions.queryParameters,
72+
options: opts,
73+
);
74+
handler.resolve(rerun);
75+
return;
76+
}
77+
} catch (_) {
78+
// fall through to next
79+
}
80+
}
81+
82+
handler.next(e);
3683
}));
3784

3885
// Basic log interceptor (custom to avoid extra dependency)
@@ -43,17 +90,28 @@ class DioProvider {
4390
}
4491

4592
static bool _shouldRetry(DioException e) {
46-
return e.type == DioExceptionType.connectionError && e.requestOptions.method == 'GET';
93+
final isGet = e.requestOptions.method == 'GET';
94+
final isNet = e.type == DioExceptionType.connectionError || e.type == DioExceptionType.receiveTimeout;
95+
if (!(isGet && isNet)) return false;
96+
final attempts = (e.requestOptions.extra['retryCount'] as int?) ?? 0;
97+
return attempts < 1; // retry at most once
4798
}
4899

49100
static Future<Response<dynamic>> _retry(Dio dio, RequestOptions requestOptions) async {
101+
final attempts = (requestOptions.extra['retryCount'] as int?) ?? 0;
102+
// brief backoff before retrying
103+
await Future<void>.delayed(Duration(milliseconds: 500 * (attempts + 1)));
50104
final opts = Options(
51105
method: requestOptions.method,
52106
headers: requestOptions.headers,
53107
responseType: requestOptions.responseType,
54108
contentType: requestOptions.contentType,
55109
sendTimeout: requestOptions.sendTimeout,
56110
receiveTimeout: requestOptions.receiveTimeout,
111+
extra: {
112+
...requestOptions.extra,
113+
'retryCount': attempts + 1,
114+
},
57115
);
58116
return dio.request<dynamic>(
59117
requestOptions.path,
@@ -68,6 +126,72 @@ class DioProvider {
68126
static void reset() {
69127
_dio = null;
70128
}
129+
130+
/// Ensure only one refresh happens at a time. Returns new access token or null.
131+
static Future<String?> _refreshTokenIfNeeded(Dio baseDio) async {
132+
// If a refresh is already ongoing, await the same future
133+
final ongoing = _refreshingFuture;
134+
if (ongoing != null) {
135+
return ongoing;
136+
}
137+
138+
final completer = Completer<String?>();
139+
_refreshingFuture = completer.future;
140+
141+
try {
142+
final newToken = await _callRefreshEndpoint(baseDio);
143+
completer.complete(newToken);
144+
return newToken;
145+
} catch (err) {
146+
completer.complete(null);
147+
return null;
148+
} finally {
149+
_refreshingFuture = null;
150+
}
151+
}
152+
153+
/// Call the refresh endpoint using a bare Dio to avoid interceptor loops.
154+
static Future<String?> _callRefreshEndpoint(Dio baseDio) async {
155+
final refreshToken = await TokenManager.getRefreshToken();
156+
final refreshDio = Dio(
157+
BaseOptions(
158+
baseUrl: baseDio.options.baseUrl,
159+
connectTimeout: baseDio.options.connectTimeout,
160+
receiveTimeout: baseDio.options.receiveTimeout,
161+
headers: {'Content-Type': 'application/json'},
162+
),
163+
);
164+
165+
Response res;
166+
try {
167+
if (refreshToken != null && refreshToken.isNotEmpty) {
168+
res = await refreshDio.post('/auth/refresh', data: {'refreshToken': refreshToken});
169+
} else {
170+
// Some backends use HttpOnly cookie refresh; attempt without body
171+
res = await refreshDio.post('/auth/refresh');
172+
}
173+
} on DioException {
174+
// Refresh failed
175+
await TokenManager.clearTokens();
176+
return null;
177+
}
178+
179+
final data = res.data is Map<String, dynamic>
180+
? res.data as Map<String, dynamic>
181+
: <String, dynamic>{};
182+
final payload = (data['data'] ?? data) as Map<String, dynamic>;
183+
final newAccess = (payload['token'] ?? payload['accessToken'] ?? '') as String;
184+
final newRefresh = (payload['refreshToken'] ?? payload['refresh_token'] ?? '') as String;
185+
186+
if (newAccess.isEmpty) {
187+
await TokenManager.clearTokens();
188+
return null;
189+
}
190+
191+
// Persist the new tokens
192+
await TokenManager.saveTokens(accessToken: newAccess, refreshToken: newRefresh.isEmpty ? null : newRefresh);
193+
return newAccess;
194+
}
71195
}
72196

73197
class _LogInterceptor extends Interceptor {

0 commit comments

Comments
 (0)