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:
parent
2d65aeec29
commit
cc772a7c90
15 changed files with 690 additions and 128 deletions
|
|
@ -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});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal file
20
test/apis/unauthorized_interceptor_paths_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
test/screens/settings/stores/user_store_test.dart
Normal file
99
test/screens/settings/stores/user_store_test.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue