Existing tokens lack the newer mod scopes, so Helix 401s looked like a vague permissions failure. Cache scopes from /oauth2/validate and show which ones need re-authorization. Co-authored-by: Cursor <cursoragent@cursor.com>
101 lines
3.3 KiB
Dart
101 lines
3.3 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:frosty/main.dart';
|
|
import 'package:frosty/screens/onboarding/login_webview.dart';
|
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
|
import 'package:frosty/utils/twitch_oauth_scopes.dart';
|
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog
|
|
class UnauthorizedInterceptor extends Interceptor {
|
|
final AuthStore _authStore;
|
|
|
|
UnauthorizedInterceptor(this._authStore);
|
|
|
|
@override
|
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
|
if (err.response?.statusCode == 401 &&
|
|
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
|
// For token validation requests, let the error propagate so validateToken can handle it
|
|
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
|
handler.next(err);
|
|
return;
|
|
}
|
|
|
|
// For other requests, show login dialog and reject so the Future completes
|
|
_showLoginDialog();
|
|
handler.reject(err);
|
|
return;
|
|
}
|
|
|
|
// For non-401 errors, continue with normal error handling
|
|
handler.next(err);
|
|
}
|
|
|
|
void _showLoginDialog() {
|
|
// Use the global navigator key to show dialog without needing BuildContext
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) return;
|
|
|
|
// Use a flag to prevent multiple dialogs
|
|
if (_isDialogShowing) return;
|
|
_isDialogShowing = true;
|
|
|
|
// Determine if user is logged in but missing scopes vs completely logged out
|
|
final isLoggedIn = _authStore.isLoggedIn;
|
|
final title = isLoggedIn ? 'Missing permissions' : 'Session expired';
|
|
final message = unauthorizedDialogMessage(
|
|
isLoggedIn: isLoggedIn,
|
|
grantedScopes: _authStore.grantedScopes,
|
|
);
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false, // User must choose an action
|
|
builder: (BuildContext dialogContext) {
|
|
return FrostyDialog(
|
|
title: title,
|
|
message: message,
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
_isDialogShowing = false;
|
|
Navigator.of(dialogContext).pop(); // Close dialog
|
|
},
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
_isDialogShowing = false;
|
|
Navigator.of(dialogContext).pop(); // Close dialog
|
|
// Navigate to login WebView
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => Provider<AuthStore>.value(
|
|
value: _authStore,
|
|
child: const LoginWebView(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: Text('Log in'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
).then((_) {
|
|
// Reset flag when dialog is dismissed
|
|
_isDialogShowing = false;
|
|
});
|
|
}
|
|
|
|
static bool _isDialogShowing = false;
|
|
|
|
bool _isTwitchHelixOrOAuth(Uri uri) {
|
|
final url = uri.toString();
|
|
return url.startsWith('https://api.twitch.tv/helix') ||
|
|
url.startsWith('https://id.twitch.tv/oauth2');
|
|
}
|
|
}
|