fix(auth): request moderator OAuth scopes only on opt-in

Stop asking for mod scopes at normal login so existing core-only tokens
never hit a missing-permissions dialog. Gate moderated-channels Helix,
offer an Enable flow for mods/broadcasters, and upgrade atomically.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tommy Chow 2026-08-09 15:33:06 -07:00
parent 2d65aeec29
commit cc772a7c90
15 changed files with 690 additions and 128 deletions

View file

@ -147,19 +147,24 @@ class TwitchApi extends BaseApiClient {
/// Validates [token] with Twitch. /// Validates [token] with Twitch.
/// ///
/// Returns the granted OAuth scopes when the token is valid, or `null` when /// Returns token info (scopes + optional `user_id`) when valid, or `null`
/// it is invalid/expired. App-access tokens may return an empty list. /// when invalid/expired. App-access tokens may omit `user_id` and return an
Future<List<String>?> validateToken({required String token}) async { /// empty scope list.
Future<TwitchTokenInfo?> validateToken({required String token}) async {
try { try {
final data = await get<JsonMap>( final data = await get<JsonMap>(
'$_oauthBaseUrl/validate', '$_oauthBaseUrl/validate',
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
); );
final scopes = data['scopes']; final scopes = data['scopes'];
if (scopes is List) { final scopeList = scopes is List
return scopes.map((scope) => scope.toString()).toList(); ? scopes.map((scope) => scope.toString()).toList()
} : const <String>[];
return const []; final userId = data['user_id']?.toString();
return TwitchTokenInfo(
scopes: scopeList,
userId: userId == null || userId.isEmpty ? null : userId,
);
} on UnauthorizedException { } on UnauthorizedException {
// 401 -> token is invalid/expired (propagated from interceptor for validate requests) // 401 -> token is invalid/expired (propagated from interceptor for validate requests)
return null; return null;
@ -508,8 +513,8 @@ class TwitchApi extends BaseApiClient {
return result; return result;
} on ApiException catch (e) { } on ApiException catch (e) {
// Existing users may not have re-authenticated with the new scope yet. // Backstop: callers should skip this without moderator scopes, but a
// Treat this as non-fatal and hide moderation actions. // stale/partial token must not take down chat.
debugPrint('Failed to get moderated channels: $e'); debugPrint('Failed to get moderated channels: $e');
return []; return [];
} }
@ -590,3 +595,13 @@ class TwitchApi extends BaseApiClient {
return true; return true;
} }
} }
/// Result of a successful Twitch `/oauth2/validate` call.
class TwitchTokenInfo {
final List<String> scopes;
/// Present for user access tokens; omitted for app access tokens.
final String? userId;
const TwitchTokenInfo({required this.scopes, this.userId});
}

View file

@ -24,8 +24,57 @@ class UnauthorizedInterceptor extends Interceptor {
return; return;
} }
// For other requests, show login dialog and reject so the Future completes final path = err.requestOptions.uri.path;
_showLoginDialog();
// Background moderated-channels fetches must never nag — especially for
// core-only tokens after an app upgrade.
if (isTwitchModeratedChannelsPath(path)) {
handler.reject(err);
return;
}
// Moderator actions without scopes → opt-in Enable flow, not a generic
// "missing permissions" re-login for core scopes.
if (isTwitchModeratorActionPath(path) &&
!_authStore.hasModeratorScopes) {
_showAuthDialog(
title: 'Enable moderator tools',
message: enableModeratorToolsDialogMessage(),
primaryLabel: 'Enable',
onPrimary: () => openEnableModeratorTools(_authStore),
);
handler.reject(err);
return;
}
// Core-only messaging: if core scopes are present, prefer "Session expired"
// over "Missing permissions" (401 ≠ always missing scope).
final isLoggedIn = _authStore.isLoggedIn;
final missingCore = missingTwitchScopes(
granted: _authStore.grantedScopes,
);
_showAuthDialog(
title: isLoggedIn && missingCore.isNotEmpty
? 'Missing permissions'
: 'Session expired',
message: unauthorizedDialogMessage(
isLoggedIn: isLoggedIn,
grantedScopes: _authStore.grantedScopes,
),
primaryLabel: 'Log in',
onPrimary: () {
final context = navigatorKey.currentContext;
if (context == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
},
);
handler.reject(err); handler.reject(err);
return; return;
} }
@ -34,26 +83,20 @@ class UnauthorizedInterceptor extends Interceptor {
handler.next(err); handler.next(err);
} }
void _showLoginDialog() { void _showAuthDialog({
// Use the global navigator key to show dialog without needing BuildContext required String title,
required String message,
required String primaryLabel,
required VoidCallback onPrimary,
}) {
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
if (context == null) return; if (context == null) return;
// Use a flag to prevent multiple dialogs
if (_isDialogShowing) return; if (_isDialogShowing) return;
_isDialogShowing = true; _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( showDialog(
context: context, context: context,
barrierDismissible: false, // User must choose an action barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return FrostyDialog( return FrostyDialog(
title: title, title: title,
@ -62,31 +105,22 @@ class UnauthorizedInterceptor extends Interceptor {
TextButton( TextButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog Navigator.of(dialogContext).pop();
}, },
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
_isDialogShowing = false; _isDialogShowing = false;
Navigator.of(dialogContext).pop(); // Close dialog Navigator.of(dialogContext).pop();
// Navigate to login WebView onPrimary();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: _authStore,
child: const LoginWebView(),
),
),
);
}, },
child: Text('Log in'), child: Text(primaryLabel),
), ),
], ],
); );
}, },
).then((_) { ).then((_) {
// Reset flag when dialog is dismissed
_isDialogShowing = false; _isDialogShowing = false;
}); });
} }
@ -99,3 +133,11 @@ class UnauthorizedInterceptor extends Interceptor {
url.startsWith('https://id.twitch.tv/oauth2'); url.startsWith('https://id.twitch.tv/oauth2');
} }
} }
/// Helix path for listing channels the user moderates.
bool isTwitchModeratedChannelsPath(String path) =>
path.contains('/moderation/channels');
/// Helix paths for delete / ban / timeout / unban.
bool isTwitchModeratorActionPath(String path) =>
path.contains('/moderation/chat') || path.contains('/moderation/bans');

View file

@ -1304,8 +1304,9 @@ class USERSTATE {
raw: ircMessage.raw, raw: ircMessage.raw,
color: ircMessage.tags['color'] ?? color, color: ircMessage.tags['color'] ?? color,
displayName: ircMessage.tags['display-name'] ?? displayName, displayName: ircMessage.tags['display-name'] ?? displayName,
mod: ircMessage.tags['mod'] == '0' ? false : true, // Missing tags must be false — the old `!= '0'` default treated absence as mod.
subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, mod: ircMessage.tags['mod'] == '1',
subscriber: ircMessage.tags['subscriber'] == '1',
); );
} }

View file

@ -1,14 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
/// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId], /// Moderator actions (delete / timeout / ban, plus unban) for [targetUserId],
/// reused by the message long-press menu and the user panel. /// reused by the message long-press menu and the user panel.
/// ///
/// Renders nothing unless the viewer can moderate the channel. [messageId] /// Renders nothing unless the viewer can moderate the channel, or should see
/// enables the per-message Delete action; omit it (e.g. the user panel, which /// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
/// isn't tied to one message) to hide Delete. The unban action only appears /// [messageId] enables the per-message Delete action; omit it (e.g. the user
/// when the user is currently banned/timed-out (seen via CLEARCHAT). /// panel, which isn't tied to one message) to hide Delete. The unban action
/// only appears when the user is currently banned/timed-out (seen via CLEARCHAT).
class ModerationActions extends StatelessWidget { class ModerationActions extends StatelessWidget {
final ChatStore chatStore; final ChatStore chatStore;
final String targetUserId; final String targetUserId;
@ -29,10 +33,54 @@ class ModerationActions extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!chatStore.auth.user.canModerate(chatStore.channelId)) { return Observer(
return const SizedBox.shrink(); builder: (context) {
} final auth = chatStore.auth;
final hasModScopes = auth.hasModeratorScopes;
if (hasModScopes) {
if (auth.user.canPerformModeratorActions(chatStore.channelId)) {
return _actionsColumn(context);
}
return const SizedBox.shrink();
}
// IRC/broadcaster is a CTA hint only — not authority to call Helix.
// Read userState.mod only on the no-scopes path so USERSTATE acks do
// not rebuild this sheet while real actions are already available.
final isBroadcaster = auth.user.details?.id == chatStore.channelId;
final ircModHint = chatStore.userState.mod;
if (isBroadcaster || ircModHint) {
return _enableModeratorToolsRow(context, auth);
}
return const SizedBox.shrink();
},
);
}
Widget _enableModeratorToolsRow(BuildContext context, AuthStore auth) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
ListTile(
onTap: () {
Navigator.pop(context);
// Use the root navigator after closing the sheet.
openEnableModeratorTools(auth);
},
leading: const Icon(Icons.shield_outlined),
title: const Text('Enable moderator tools…'),
subtitle: const Text(
'Authorize Twitch permissions to delete, timeout, and ban.',
),
),
],
);
}
Widget _actionsColumn(BuildContext context) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -133,7 +181,9 @@ class ModerationActions extends StatelessWidget {
duration: 600, // 10 minutes duration: 600, // 10 minutes
); );
chatStore.updateNotification( chatStore.updateNotification(
success ? '$targetName timed out for 10 minutes.' : 'Failed to timeout user', success
? '$targetName timed out for 10 minutes.'
: 'Failed to timeout user',
); );
} }

View file

@ -1,16 +1,39 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/frosty_app_bar.dart'; import 'package:frosty/widgets/frosty_app_bar.dart';
import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
/// Pushes the atomic moderator-scopes upgrade WebView.
void openEnableModeratorTools(AuthStore authStore, {BuildContext? context}) {
final navContext = context ?? navigatorKey.currentContext;
if (navContext == null) return;
Navigator.of(navContext).push(
MaterialPageRoute(
builder: (context) => Provider<AuthStore>.value(
value: authStore,
child: const LoginWebView(upgradeModeratorScopes: true),
),
),
);
}
/// Reusable WebView widget for Twitch OAuth login flow /// Reusable WebView widget for Twitch OAuth login flow
class LoginWebView extends StatelessWidget { class LoginWebView extends StatelessWidget {
/// Optional widget to navigate to after successful login /// Optional widget to navigate to after successful login
final Widget? routeAfter; final Widget? routeAfter;
const LoginWebView({super.key, this.routeAfter}); /// When true, requests core + moderator scopes and runs the atomic upgrade
/// commit instead of a full [AuthStore.login].
final bool upgradeModeratorScopes;
const LoginWebView({
super.key,
this.routeAfter,
this.upgradeModeratorScopes = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -18,7 +41,11 @@ class LoginWebView extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: FrostyAppBar( appBar: FrostyAppBar(
title: const Text('Connect with Twitch'), title: Text(
upgradeModeratorScopes
? 'Enable moderator tools'
: 'Connect with Twitch',
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.help_rounded), icon: const Icon(Icons.help_rounded),
@ -44,6 +71,7 @@ class LoginWebView extends StatelessWidget {
body: WebViewWidget( body: WebViewWidget(
controller: authStore.createAuthWebViewController( controller: authStore.createAuthWebViewController(
routeAfter: routeAfter, routeAfter: routeAfter,
upgradeModeratorScopes: upgradeModeratorScopes,
), ),
), ),
); );

View file

@ -10,7 +10,7 @@ import 'package:frosty/constants.dart';
import 'package:frosty/main.dart'; import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/services/cookie_extractor.dart'; import 'package:frosty/services/cookie_extractor.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/utils/twitch_oauth_scopes.dart' as twitch_scopes;
import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
@ -32,6 +32,12 @@ abstract class AuthBase with Store {
/// The secure storage key for the GQL web cookie token. /// The secure storage key for the GQL web cookie token.
static const _gqlTokenKey = 'gql_token'; static const _gqlTokenKey = 'gql_token';
/// Twitch user id that opted into moderator scopes on this device.
static const _moderatorOptInUserIdKey = 'moderator_scopes_opt_in_user_id';
/// Last logged-in Twitch user id (kept across logout for re-login scope choice).
static const _lastUserIdKey = 'last_user_id';
/// The Twitch API service for making requests. /// The Twitch API service for making requests.
final TwitchApi twitchApi; final TwitchApi twitchApi;
@ -45,7 +51,7 @@ abstract class AuthBase with Store {
static const _maxReconnectAttempts = 5; static const _maxReconnectAttempts = 5;
/// The MobX store containing information relevant to the current user. /// The MobX store containing information relevant to the current user.
final UserStore user; late final UserStore user;
/// The user token used to authenticate with the Twitch API. /// The user token used to authenticate with the Twitch API.
@readonly @readonly
@ -60,11 +66,19 @@ abstract class AuthBase with Store {
var _isLoggedIn = false; var _isLoggedIn = false;
/// OAuth scopes granted to the current user token. /// OAuth scopes granted to the current user token.
/// @readonly
/// Used to explain missing-permission 401s without another network round-trip. var _grantedScopes = ObservableList<String>();
var _grantedScopes = const <String>[];
List<String> get grantedScopes => _grantedScopes; /// Twitch user id that previously enabled moderator tools on this device.
String? _moderatorOptInUserId;
/// Last known Twitch user id, retained after logout for scope selection.
String? _lastUserId;
/// Whether the current token includes every moderator scope Frosty needs.
@computed
bool get hasModeratorScopes =>
twitch_scopes.hasModeratorScopes(_grantedScopes);
/// Authentication headers for Twitch API requests. /// Authentication headers for Twitch API requests.
@computed @computed
@ -81,6 +95,7 @@ abstract class AuthBase with Store {
FutureOr<NavigationDecision> handleNavigation({ FutureOr<NavigationDecision> handleNavigation({
required NavigationRequest request, required NavigationRequest request,
Widget? routeAfter, Widget? routeAfter,
bool upgradeModeratorScopes = false,
}) { }) {
// Check if the URL is the redirect URI. // Check if the URL is the redirect URI.
if (request.url.startsWith('https://twitch.tv/login')) { if (request.url.startsWith('https://twitch.tv/login')) {
@ -88,8 +103,13 @@ abstract class AuthBase with Store {
final uri = Uri.parse(request.url.replaceFirst('#', '?')); final uri = Uri.parse(request.url.replaceFirst('#', '?'));
final token = uri.queryParameters['access_token']; final token = uri.queryParameters['access_token'];
// Login with the provided token. if (token != null) {
if (token != null) login(token: token); if (upgradeModeratorScopes) {
unawaited(completeModeratorUpgrade(token: token));
} else {
unawaited(login(token: token));
}
}
} }
// Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true". // Check if the the URL has been redirected to "https://www.twitch.tv/?no-reload=true".
@ -97,6 +117,13 @@ abstract class AuthBase with Store {
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
if (request.url == 'https://www.twitch.tv/?no-reload=true') { if (request.url == 'https://www.twitch.tv/?no-reload=true') {
_extractGqlToken(); _extractGqlToken();
// Upgrade WebViews pop themselves after an atomic commit so we never
// dismiss into a half-applied session.
if (upgradeModeratorScopes) {
return NavigationDecision.navigate;
}
if (routeAfter != null) { if (routeAfter != null) {
navigatorKey.currentState?.pop(); navigatorKey.currentState?.pop();
navigatorKey.currentState?.push( navigatorKey.currentState?.push(
@ -112,7 +139,10 @@ abstract class AuthBase with Store {
return NavigationDecision.navigate; return NavigationDecision.navigate;
} }
WebViewController createAuthWebViewController({Widget? routeAfter}) { WebViewController createAuthWebViewController({
Widget? routeAfter,
bool upgradeModeratorScopes = false,
}) {
final webViewController = WebViewController() final webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
// Use platform-specific user agents to allow Google OAuth sign-in. // Use platform-specific user agents to allow Google OAuth sign-in.
@ -124,11 +154,18 @@ abstract class AuthBase with Store {
: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
); );
final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes
? twitch_scopes.twitchAllUserScopeQuery
: twitch_scopes.twitchUserScopeQuery;
return webViewController return webViewController
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( NavigationDelegate(
onNavigationRequest: (request) => onNavigationRequest: (request) => handleNavigation(
handleNavigation(request: request, routeAfter: routeAfter), request: request,
routeAfter: routeAfter,
upgradeModeratorScopes: upgradeModeratorScopes,
),
onWebResourceError: (error) { onWebResourceError: (error) {
debugPrint('Auth WebView error: ${error.description}'); debugPrint('Auth WebView error: ${error.description}');
}, },
@ -175,7 +212,7 @@ abstract class AuthBase with Store {
'client_id': clientId, 'client_id': clientId,
'redirect_uri': 'https://twitch.tv/login', 'redirect_uri': 'https://twitch.tv/login',
'response_type': 'token', 'response_type': 'token',
'scope': twitchUserScopeQuery, 'scope': scopeQuery,
'force_verify': 'true', 'force_verify': 'true',
}, },
), ),
@ -222,7 +259,26 @@ abstract class AuthBase with Store {
); );
} }
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi); AuthBase({required this.twitchApi}) {
user = UserStore(
twitchApi: twitchApi,
hasModeratorScopes: () => hasModeratorScopes,
);
}
bool get _shouldIncludeModeratorScopes {
final currentId = user.details?.id;
if (currentId != null && currentId == _moderatorOptInUserId) {
return true;
}
// After logout, still request mod scopes for the same opted-in account.
if (currentId == null &&
_lastUserId != null &&
_lastUserId == _moderatorOptInUserId) {
return true;
}
return false;
}
Future<void> _extractGqlToken() async { Future<void> _extractGqlToken() async {
try { try {
@ -247,13 +303,40 @@ abstract class AuthBase with Store {
await _storage.delete(key: _gqlTokenKey); await _storage.delete(key: _gqlTokenKey);
} }
void _setGrantedScopes(List<String> scopes) {
_grantedScopes = ObservableList.of(scopes);
}
Future<void> _persistModeratorOptIn(String userId) async {
if (_moderatorOptInUserId == userId) return;
_moderatorOptInUserId = userId;
await _storage.write(key: _moderatorOptInUserIdKey, value: userId);
}
Future<void> _persistLastUserId(String? userId) async {
if (_lastUserId == userId) return;
_lastUserId = userId;
if (userId == null) {
await _storage.delete(key: _lastUserIdKey);
} else {
await _storage.write(key: _lastUserIdKey, value: userId);
}
}
/// Initialize by retrieving a token if it does not already exist. /// Initialize by retrieving a token if it does not already exist.
@action @action
Future<void> init() async { Future<void> init() async {
try { try {
// Read and set the currently stored user token, if any. final stored = await Future.wait([
_token = await _storage.read(key: _userTokenKey); _storage.read(key: _moderatorOptInUserIdKey),
_gqlToken = await _storage.read(key: _gqlTokenKey); _storage.read(key: _lastUserIdKey),
_storage.read(key: _userTokenKey),
_storage.read(key: _gqlTokenKey),
]);
_moderatorOptInUserId = stored[0];
_lastUserId = stored[1];
_token = stored[2];
_gqlToken = stored[3];
// If the token does not exist, get the default token. // If the token does not exist, get the default token.
// Otherwise, log in. // Otherwise, log in.
@ -266,28 +349,30 @@ abstract class AuthBase with Store {
_token = await twitchApi.getDefaultToken(); _token = await twitchApi.getDefaultToken();
await _storage.write(key: _defaultTokenKey, value: _token); await _storage.write(key: _defaultTokenKey, value: _token);
} }
_setGrantedScopes(const []);
} else { } else {
// Validate the existing token. If it fails, start reconnect loop. // Validate the existing token. If it fails, start reconnect loop.
final List<String> scopes; final TwitchTokenInfo info;
try { try {
final validated = await twitchApi.validateToken(token: _token!); final validated = await twitchApi.validateToken(token: _token!);
if (validated == null) return await logout(); if (validated == null) return await logout();
scopes = validated; info = validated;
} on ApiException catch (e) { } on ApiException catch (e) {
debugPrint('Token validation failed: $e'); debugPrint('Token validation failed: $e');
_isLoggedIn = false; _isLoggedIn = false;
_grantedScopes = const []; _setGrantedScopes(const []);
_startReconnectLoop(); _startReconnectLoop();
return; return;
} }
_grantedScopes = scopes; _setGrantedScopes(info.scopes);
// Initialize the user store. // Initialize the user store.
await user.init(); await user.init();
if (user.details != null) { if (user.details != null) {
_isLoggedIn = true; _isLoggedIn = true;
await _persistLastUserId(user.details!.id);
_stopReconnectLoop(); _stopReconnectLoop();
// Recover a missed web session link — the WebView cookie store may // Recover a missed web session link — the WebView cookie store may
@ -309,9 +394,9 @@ abstract class AuthBase with Store {
Future<void> login({required String token}) async { Future<void> login({required String token}) async {
try { try {
// Validate the custom token. // Validate the custom token.
final scopes = await twitchApi.validateToken(token: token); final info = await twitchApi.validateToken(token: token);
if (scopes == null) return; if (info == null) return;
_grantedScopes = scopes; _setGrantedScopes(info.scopes);
// Replace the current default token with the new custom token. // Replace the current default token with the new custom token.
_token = token; _token = token;
@ -325,6 +410,12 @@ abstract class AuthBase with Store {
// Set the login status to logged in. // Set the login status to logged in.
if (user.details != null) { if (user.details != null) {
_isLoggedIn = true; _isLoggedIn = true;
await _persistLastUserId(user.details!.id);
// A fresh login that already includes mod scopes (re-auth after opt-in)
// should keep the opt-in flag for this account.
if (hasModeratorScopes) {
await _persistModeratorOptIn(user.details!.id);
}
FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true); FirebaseCrashlytics.instance.setCustomKey('is_logged_in', true);
FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id); FirebaseCrashlytics.instance.setUserIdentifier(user.details!.id);
_stopReconnectLoop(); _stopReconnectLoop();
@ -334,17 +425,96 @@ abstract class AuthBase with Store {
} }
} }
/// Atomically upgrades the current session with moderator OAuth scopes.
///
/// Keeps the existing token until validation proves the new token is for the
/// same user and includes every moderator scope. On any failure, the old
/// session is left untouched.
@action
Future<bool> completeModeratorUpgrade({required String token}) async {
final currentUserId = user.details?.id;
if (currentUserId == null || !_isLoggedIn) {
_showModeratorUpgradeFailed(
'Log in first, then enable moderator tools from a channel you moderate.',
);
return false;
}
try {
final info = await twitchApi.validateToken(token: token);
if (info == null) {
_showModeratorUpgradeFailed(
'Could not validate the new Twitch permissions. Your current session was kept.',
);
return false;
}
if (info.userId != currentUserId) {
_showModeratorUpgradeFailed(
'Log in with the same Twitch account to enable moderator tools. Your current session was kept.',
);
return false;
}
if (!twitch_scopes.hasModeratorScopes(info.scopes)) {
_showModeratorUpgradeFailed(
'Moderator permissions were not granted. Your current session was kept.',
);
return false;
}
// Commit only after all gates pass.
_token = token;
_setGrantedScopes(info.scopes);
await Future.wait([
_storage.write(key: _userTokenKey, value: token),
_persistModeratorOptIn(currentUserId),
]);
await user.refreshModeratedChannels();
navigatorKey.currentState?.pop();
return true;
} catch (e) {
debugPrint('Moderator upgrade failed due to $e');
_showModeratorUpgradeFailed(
'Could not enable moderator tools. Your current session was kept.',
);
return false;
}
}
void _showModeratorUpgradeFailed(String message) {
final context = navigatorKey.currentContext;
if (context == null) return;
showDialog(
context: context,
builder: (dialogContext) => FrostyDialog(
title: 'Moderator tools not enabled',
message: message,
actions: [
TextButton(
onPressed: Navigator.of(dialogContext).pop,
child: const Text('OK'),
),
],
),
);
}
/// Logs out the current user and updates fields accordingly. /// Logs out the current user and updates fields accordingly.
@action @action
Future<void> logout() async { Future<void> logout() async {
try { try {
_stopReconnectLoop(); _stopReconnectLoop();
final previousUserId = user.details?.id ?? _lastUserId;
await _persistLastUserId(previousUserId);
// Delete the existing user token and GQL token. // Delete the existing user token and GQL token.
await _storage.delete(key: _userTokenKey); await _storage.delete(key: _userTokenKey);
await _storage.delete(key: _gqlTokenKey); await _storage.delete(key: _gqlTokenKey);
_token = null; _token = null;
_gqlToken = null; _gqlToken = null;
_grantedScopes = const []; _setGrantedScopes(const []);
// Clear the user info. // Clear the user info.
user.dispose(); user.dispose();
@ -390,18 +560,19 @@ abstract class AuthBase with Store {
return; return;
} }
final scopes = await twitchApi.validateToken(token: stored); final info = await twitchApi.validateToken(token: stored);
if (scopes == null) { if (info == null) {
await logout(); await logout();
return; return;
} }
// Token valid again — restore session. // Token valid again — restore session.
_token = stored; _token = stored;
_grantedScopes = scopes; _setGrantedScopes(info.scopes);
await user.init(); await user.init();
if (user.details != null) { if (user.details != null) {
_isLoggedIn = true; _isLoggedIn = true;
await _persistLastUserId(user.details!.id);
_error = null; _error = null;
_stopReconnectLoop(); _stopReconnectLoop();
} }

View file

@ -9,6 +9,14 @@ part of 'auth_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
mixin _$AuthStore on AuthBase, Store { mixin _$AuthStore on AuthBase, Store {
Computed<bool>? _$hasModeratorScopesComputed;
@override
bool get hasModeratorScopes =>
(_$hasModeratorScopesComputed ??= Computed<bool>(
() => super.hasModeratorScopes,
name: 'AuthBase.hasModeratorScopes',
)).value;
Computed<Map<String, String>>? _$headersTwitchComputed; Computed<Map<String, String>>? _$headersTwitchComputed;
@override @override
@ -75,6 +83,26 @@ mixin _$AuthStore on AuthBase, Store {
}); });
} }
late final _$_grantedScopesAtom = Atom(
name: 'AuthBase._grantedScopes',
context: context,
);
ObservableList<String> get grantedScopes {
_$_grantedScopesAtom.reportRead();
return super._grantedScopes;
}
@override
ObservableList<String> get _grantedScopes => grantedScopes;
@override
set _grantedScopes(ObservableList<String> value) {
_$_grantedScopesAtom.reportWrite(value, super._grantedScopes, () {
super._grantedScopes = value;
});
}
late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context); late final _$_errorAtom = Atom(name: 'AuthBase._error', context: context);
String? get error { String? get error {
@ -121,6 +149,18 @@ mixin _$AuthStore on AuthBase, Store {
return _$loginAsyncAction.run(() => super.login(token: token)); return _$loginAsyncAction.run(() => super.login(token: token));
} }
late final _$completeModeratorUpgradeAsyncAction = AsyncAction(
'AuthBase.completeModeratorUpgrade',
context: context,
);
@override
Future<bool> completeModeratorUpgrade({required String token}) {
return _$completeModeratorUpgradeAsyncAction.run(
() => super.completeModeratorUpgrade(token: token),
);
}
late final _$logoutAsyncAction = AsyncAction( late final _$logoutAsyncAction = AsyncAction(
'AuthBase.logout', 'AuthBase.logout',
context: context, context: context,
@ -134,6 +174,7 @@ mixin _$AuthStore on AuthBase, Store {
@override @override
String toString() { String toString() {
return ''' return '''
hasModeratorScopes: ${hasModeratorScopes},
headersTwitch: ${headersTwitch} headersTwitch: ${headersTwitch}
'''; ''';
} }

View file

@ -9,6 +9,9 @@ class UserStore = UserStoreBase with _$UserStore;
abstract class UserStoreBase with Store { abstract class UserStoreBase with Store {
final TwitchApi twitchApi; final TwitchApi twitchApi;
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
final bool Function() hasModeratorScopes;
/// The current user's info. /// The current user's info.
@readonly @readonly
UserTwitch? _details; UserTwitch? _details;
@ -23,7 +26,10 @@ abstract class UserStoreBase with Store {
ReactionDisposer? _disposeReaction; ReactionDisposer? _disposeReaction;
UserStoreBase({required this.twitchApi}); UserStoreBase({
required this.twitchApi,
required this.hasModeratorScopes,
});
@action @action
Future<void> init() async { Future<void> init() async {
@ -36,11 +42,20 @@ abstract class UserStoreBase with Store {
twitchApi twitchApi
.getUserBlockedList(id: _details!.id) .getUserBlockedList(id: _details!.id)
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable()); .then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
twitchApi // Never hit moderated-channels Helix without moderator scopes — that 401
.getModeratedChannels(id: _details!.id) // is what nags non-mod users after an app upgrade.
.then((channels) => _moderatedChannels = channels.asObservable()); if (hasModeratorScopes()) {
twitchApi
.getModeratedChannels(id: _details!.id)
.then(
(channels) => _moderatedChannels = channels.asObservable(),
);
} else {
_moderatedChannels = ObservableList<String>();
}
} }
_disposeReaction?.call();
_disposeReaction = autorun( _disposeReaction = autorun(
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)), (_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
); );
@ -73,7 +88,7 @@ abstract class UserStoreBase with Store {
/// status granted or revoked since then. /// status granted or revoked since then.
@action @action
Future<void> refreshModeratedChannels() async { Future<void> refreshModeratedChannels() async {
if (_details?.id == null) return; if (_details?.id == null || !hasModeratorScopes()) return;
_moderatedChannels = _moderatedChannels =
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable(); (await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
} }
@ -85,18 +100,24 @@ abstract class UserStoreBase with Store {
/// Whether the logged-in user can moderate [channelId] — either a moderator /// Whether the logged-in user can moderate [channelId] — either a moderator
/// of that channel or its broadcaster (the broadcaster isn't in their own /// of that channel or its broadcaster (the broadcaster isn't in their own
/// moderator list but can still use the moderation endpoints). /// moderator list but can still use the moderation endpoints).
///
/// Role only — does not require moderator OAuth scopes. Use
/// [canPerformModeratorActions] before calling Helix.
bool canModerate(String channelId) => bool canModerate(String channelId) =>
_details?.id == channelId || isModerator(channelId); _details?.id == channelId || isModerator(channelId);
/// Role + moderator OAuth scopes — safe to call Helix mod endpoints.
bool canPerformModeratorActions(String channelId) =>
_details?.id != null &&
hasModeratorScopes() &&
canModerate(channelId);
@action @action
Future<bool> deleteMessage({ Future<bool> deleteMessage({
required String broadcasterId, required String broadcasterId,
required String messageId, required String messageId,
}) async { }) async {
// Need the moderator ID and confirmed mod status for this channel. if (!canPerformModeratorActions(broadcasterId)) return false;
if (_details?.id == null || !canModerate(broadcasterId)) {
return false;
}
return twitchApi.deleteChatMessage( return twitchApi.deleteChatMessage(
broadcasterId: broadcasterId, broadcasterId: broadcasterId,
moderatorId: _details!.id, moderatorId: _details!.id,
@ -111,10 +132,7 @@ abstract class UserStoreBase with Store {
int? duration, int? duration,
String? reason, String? reason,
}) async { }) async {
// Need the moderator ID and confirmed mod status for this channel. if (!canPerformModeratorActions(broadcasterId)) return false;
if (_details?.id == null || !canModerate(broadcasterId)) {
return false;
}
return twitchApi.banUser( return twitchApi.banUser(
broadcasterId: broadcasterId, broadcasterId: broadcasterId,
moderatorId: _details!.id, moderatorId: _details!.id,
@ -130,9 +148,7 @@ abstract class UserStoreBase with Store {
required String broadcasterId, required String broadcasterId,
required String userIdToUnban, required String userIdToUnban,
}) async { }) async {
if (_details?.id == null || !canModerate(broadcasterId)) { if (!canPerformModeratorActions(broadcasterId)) return false;
return false;
}
return twitchApi.unbanUser( return twitchApi.unbanUser(
broadcasterId: broadcasterId, broadcasterId: broadcasterId,
moderatorId: _details!.id, moderatorId: _details!.id,
@ -145,6 +161,7 @@ abstract class UserStoreBase with Store {
_details = null; _details = null;
_blockedUsers.clear(); _blockedUsers.clear();
_moderatedChannels.clear(); _moderatedChannels.clear();
if (_disposeReaction != null) _disposeReaction!(); _disposeReaction?.call();
_disposeReaction = null;
} }
} }

View file

@ -1,4 +1,7 @@
/// OAuth scopes Frosty requests for a logged-in Twitch user session. /// Core OAuth scopes Frosty requests on a normal Twitch login.
///
/// Matches the pre-moderator-tools set so existing tokens remain valid without
/// a re-consent nag for non-moderators.
const twitchUserScopes = <String>[ const twitchUserScopes = <String>[
'chat:read', 'chat:read',
'chat:edit', 'chat:edit',
@ -6,15 +9,28 @@ const twitchUserScopes = <String>[
'user:read:blocked_users', 'user:read:blocked_users',
'user:manage:blocked_users', 'user:manage:blocked_users',
'user:manage:chat_color', 'user:manage:chat_color',
];
/// Moderator-only scopes requested via an explicit opt-in upgrade.
const twitchModeratorScopes = <String>[
'user:read:moderated_channels', 'user:read:moderated_channels',
'moderator:manage:chat_messages', 'moderator:manage:chat_messages',
'moderator:manage:banned_users', 'moderator:manage:banned_users',
]; ];
/// Space-separated scope string for Twitch's authorize URL. /// Core + moderator scopes for an opted-in moderator session.
const twitchAllUserScopes = <String>[
...twitchUserScopes,
...twitchModeratorScopes,
];
/// Space-separated scope string for Twitch's authorize URL (core only).
String get twitchUserScopeQuery => twitchUserScopes.join(' '); String get twitchUserScopeQuery => twitchUserScopes.join(' ');
/// Human-readable labels for [twitchUserScopes]. /// Space-separated scope string for Twitch's authorize URL (core + mod).
String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' ');
/// Human-readable labels for Frosty-requested scopes.
const twitchScopeLabels = <String, String>{ const twitchScopeLabels = <String, String>{
'chat:read': 'Read chat messages', 'chat:read': 'Read chat messages',
'chat:edit': 'Send chat messages', 'chat:edit': 'Send chat messages',
@ -27,6 +43,13 @@ const twitchScopeLabels = <String, String>{
'moderator:manage:banned_users': 'Timeout and ban users as a moderator', 'moderator:manage:banned_users': 'Timeout and ban users as a moderator',
}; };
/// Whether [granted] includes every moderator scope Frosty needs.
bool hasModeratorScopes(Iterable<String> granted) =>
missingTwitchScopes(
granted: granted,
required: twitchModeratorScopes,
).isEmpty;
/// Scopes in [required] that are not present in [granted]. /// Scopes in [required] that are not present in [granted].
List<String> missingTwitchScopes({ List<String> missingTwitchScopes({
required Iterable<String> granted, required Iterable<String> granted,
@ -36,7 +59,15 @@ List<String> missingTwitchScopes({
return required.where((scope) => !grantedSet.contains(scope)).toList(); return required.where((scope) => !grantedSet.contains(scope)).toList();
} }
/// Dialog body for a Twitch Helix/OAuth 401. String _scopeBullets(Iterable<String> scopes) => scopes
.map((scope) => '• ${twitchScopeLabels[scope] ?? scope}')
.join('\n');
/// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request.
///
/// Missing-permissions copy only lists **core** scopes. A token that already
/// has core scopes is treated as expired/invalid rather than "missing mod
/// permissions" so non-mods are never nagged about moderator consent.
String unauthorizedDialogMessage({ String unauthorizedDialogMessage({
required bool isLoggedIn, required bool isLoggedIn,
required Iterable<String> grantedScopes, required Iterable<String> grantedScopes,
@ -47,14 +78,17 @@ String unauthorizedDialogMessage({
final missing = missingTwitchScopes(granted: grantedScopes); final missing = missingTwitchScopes(granted: grantedScopes);
if (missing.isEmpty) { if (missing.isEmpty) {
return 'Your session is missing permissions. Please log in again to continue.'; return 'Your session has expired. Please log in again to continue.';
} }
final bullets = missing
.map((scope) => '• ${twitchScopeLabels[scope] ?? scope}')
.join('\n');
return 'Frosty needs additional Twitch permissions:\n\n' return 'Frosty needs additional Twitch permissions:\n\n'
'$bullets\n\n' '${_scopeBullets(missing)}\n\n'
'Log in again to grant them.'; 'Log in again to grant them.';
} }
/// Copy for the explicit moderator-tools opt-in dialog.
String enableModeratorToolsDialogMessage() {
return 'Enable moderator tools to manage chat in channels you moderate:\n\n'
'${_scopeBullets(twitchModeratorScopes)}\n\n'
'You will be asked to authorize these permissions with Twitch.';
}

View file

@ -31,6 +31,8 @@ void main() async {
'client_id': clientId, 'client_id': clientId,
'redirect_uri': redirectUri, 'redirect_uri': redirectUri,
'response_type': 'token', 'response_type': 'token',
// Core scopes only — matches normal app login. Use the in-app
// "Enable moderator tools" flow when moderator scopes are needed.
'scope': twitchUserScopeQuery, 'scope': twitchUserScopeQuery,
}, },
).toString(); ).toString();

View file

@ -215,15 +215,16 @@ void main() {
}); });
group('validateToken', () { group('validateToken', () {
test('returns granted scopes for valid token', () async { test('returns scopes and user id for valid token', () async {
dioAdapter.onGet( dioAdapter.onGet(
'https://id.twitch.tv/oauth2/validate', 'https://id.twitch.tv/oauth2/validate',
(server) => server.reply(200, twitchValidateTokenResponse), (server) => server.reply(200, twitchValidateTokenResponse),
); );
final scopes = await api.validateToken(token: 'valid_token'); final info = await api.validateToken(token: 'valid_token');
expect(scopes, ['chat:read', 'chat:edit']); expect(info?.scopes, ['chat:read', 'chat:edit']);
expect(info?.userId, '12345');
}); });
test('returns null for expired token (401)', () async { test('returns null for expired token (401)', () async {
@ -232,9 +233,9 @@ void main() {
(server) => server.reply(401, {'message': 'invalid access token'}), (server) => server.reply(401, {'message': 'invalid access token'}),
); );
final scopes = await api.validateToken(token: 'expired_token'); final info = await api.validateToken(token: 'expired_token');
expect(scopes, isNull); expect(info, isNull);
}); });
test('rethrows on server error', () { test('rethrows on server error', () {

View file

@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/unauthorized_interceptor.dart';
void main() {
group('moderation path helpers', () {
test('matches helix moderation paths', () {
expect(
isTwitchModeratedChannelsPath('/helix/moderation/channels'),
isTrue,
);
expect(isTwitchModeratorActionPath('/helix/moderation/chat'), isTrue);
expect(isTwitchModeratorActionPath('/helix/moderation/bans'), isTrue);
expect(
isTwitchModeratorActionPath('/helix/moderation/channels'),
isFalse,
);
expect(isTwitchModeratedChannelsPath('/helix/users'), isFalse);
});
});
}

View file

@ -684,5 +684,18 @@ void main() {
final updated = original.fromIRCMessage(ircMsg); final updated = original.fromIRCMessage(ircMsg);
expect(updated.mod, isFalse); expect(updated.mod, isFalse);
}); });
test('missing mod and subscriber tags are false', () {
const original = USERSTATE();
final ircMsg = IRCMessage(
raw: '',
command: Command.userState,
tags: {'display-name': 'TestUser'},
);
final updated = original.fromIRCMessage(ircMsg);
expect(updated.mod, isFalse);
expect(updated.subscriber, isFalse);
});
}); });
} }

View file

@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:mocktail/mocktail.dart';
class MockTwitchApi extends Mock implements TwitchApi {}
void main() {
late MockTwitchApi api;
setUp(() {
api = MockTwitchApi();
when(() => api.getUserInfo()).thenAnswer(
(_) async => const UserTwitch(
'12345',
'testuser',
'TestUser',
'https://cdn/profile.png',
),
);
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
(_) async => <UserBlockedTwitch>[],
);
when(() => api.getModeratedChannels(id: any(named: 'id'))).thenAnswer(
(_) async => <String>['999'],
);
});
group('UserStore moderated channels gating', () {
test('init skips moderated-channels without moderator scopes', () async {
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
await store.init();
await Future<void>.delayed(Duration.zero);
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
expect(store.moderatedChannels, isEmpty);
});
test('init fetches moderated-channels when moderator scopes exist', () async {
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => true,
);
await store.init();
await Future<void>.delayed(Duration.zero);
verify(() => api.getModeratedChannels(id: '12345')).called(1);
expect(store.moderatedChannels, ['999']);
});
test('refreshModeratedChannels no-ops without moderator scopes', () async {
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
await store.init();
clearInteractions(api);
await store.refreshModeratedChannels();
verifyNever(() => api.getModeratedChannels(id: any(named: 'id')));
});
test('deleteMessage requires moderator scopes', () async {
when(
() => api.deleteChatMessage(
broadcasterId: any(named: 'broadcasterId'),
moderatorId: any(named: 'moderatorId'),
messageId: any(named: 'messageId'),
),
).thenAnswer((_) async => true);
final store = UserStore(
twitchApi: api,
hasModeratorScopes: () => false,
);
await store.init();
final result = await store.deleteMessage(
broadcasterId: '12345',
messageId: 'msg',
);
expect(result, isFalse);
verifyNever(
() => api.deleteChatMessage(
broadcasterId: any(named: 'broadcasterId'),
moderatorId: any(named: 'moderatorId'),
messageId: any(named: 'messageId'),
),
);
});
});
}

View file

@ -2,21 +2,49 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/utils/twitch_oauth_scopes.dart'; import 'package:frosty/utils/twitch_oauth_scopes.dart';
void main() { void main() {
group('scope sets', () {
test('core scopes exclude moderator scopes', () {
for (final scope in twitchModeratorScopes) {
expect(twitchUserScopes, isNot(contains(scope)));
}
expect(twitchAllUserScopes, containsAll(twitchUserScopes));
expect(twitchAllUserScopes, containsAll(twitchModeratorScopes));
});
});
group('hasModeratorScopes', () {
test('requires all three moderator scopes', () {
expect(hasModeratorScopes(twitchAllUserScopes), isTrue);
expect(
hasModeratorScopes([
...twitchUserScopes,
'user:read:moderated_channels',
'moderator:manage:chat_messages',
]),
isFalse,
);
expect(hasModeratorScopes(twitchUserScopes), isFalse);
});
});
group('missingTwitchScopes', () { group('missingTwitchScopes', () {
test('returns scopes not present on the token', () { test('defaults to core scopes only', () {
final missing = missingTwitchScopes( final missing = missingTwitchScopes(
granted: const ['chat:read', 'chat:edit'], granted: const ['chat:read', 'chat:edit'],
); );
expect(missing, contains('user:read:moderated_channels')); expect(missing, contains('user:read:follows'));
expect(missing, contains('moderator:manage:chat_messages')); expect(missing, isNot(contains('user:read:moderated_channels')));
expect(missing, contains('moderator:manage:banned_users'));
expect(missing, isNot(contains('chat:read'))); expect(missing, isNot(contains('chat:read')));
}); });
test('returns empty when all required scopes are granted', () { test('returns empty when all required scopes are granted', () {
expect(missingTwitchScopes(granted: twitchUserScopes), isEmpty);
expect( expect(
missingTwitchScopes(granted: twitchUserScopes), missingTwitchScopes(
granted: twitchAllUserScopes,
required: twitchModeratorScopes,
),
isEmpty, isEmpty,
); );
}); });
@ -33,34 +61,34 @@ void main() {
); );
}); });
test('lists human-readable missing permissions when logged in', () { test('lists missing core permissions when logged in', () {
final message = unauthorizedDialogMessage( final message = unauthorizedDialogMessage(
isLoggedIn: true, isLoggedIn: true,
grantedScopes: const [ grantedScopes: const ['chat:read', 'chat:edit'],
'chat:read',
'chat:edit',
'user:read:follows',
'user:read:blocked_users',
'user:manage:blocked_users',
'user:manage:chat_color',
],
); );
expect(message, contains('additional Twitch permissions')); expect(message, contains('additional Twitch permissions'));
expect(message, contains('View channels you moderate')); expect(message, contains('View followed channels'));
expect(message, contains('Delete chat messages as a moderator')); expect(message, isNot(contains('View channels you moderate')));
expect(message, contains('Timeout and ban users as a moderator'));
expect(message, isNot(contains('Send chat messages')));
}); });
test('falls back when logged in but no missing scopes are known', () { test('prefers session expired when core scopes are complete', () {
expect( expect(
unauthorizedDialogMessage( unauthorizedDialogMessage(
isLoggedIn: true, isLoggedIn: true,
grantedScopes: twitchUserScopes, grantedScopes: twitchUserScopes,
), ),
contains('missing permissions'), contains('expired'),
); );
}); });
}); });
group('enableModeratorToolsDialogMessage', () {
test('lists moderator permission labels', () {
final message = enableModeratorToolsDialogMessage();
expect(message, contains('View channels you moderate'));
expect(message, contains('Delete chat messages as a moderator'));
expect(message, contains('Timeout and ban users as a moderator'));
});
});
} }