frosty/lib/apis/unauthorized_interceptor.dart
Tommy Chow 9888985deb fix(auth): list missing Twitch scopes in the re-login dialog
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>
2026-08-09 14:48:55 -07:00

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');
}
}