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
|
|
@ -1,14 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/base_api_client.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],
|
||||
/// reused by the message long-press menu and the user panel.
|
||||
///
|
||||
/// Renders nothing unless the viewer can moderate the channel. [messageId]
|
||||
/// enables the per-message Delete action; omit it (e.g. the user 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).
|
||||
/// Renders nothing unless the viewer can moderate the channel, or should see
|
||||
/// an opt-in row when IRC/broadcaster hints mod capability without OAuth scopes.
|
||||
/// [messageId] enables the per-message Delete action; omit it (e.g. the user
|
||||
/// 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 {
|
||||
final ChatStore chatStore;
|
||||
final String targetUserId;
|
||||
|
|
@ -29,10 +33,54 @@ class ModerationActions extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!chatStore.auth.user.canModerate(chatStore.channelId)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Observer(
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -133,7 +181,9 @@ class ModerationActions extends StatelessWidget {
|
|||
duration: 600, // 10 minutes
|
||||
);
|
||||
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:frosty/main.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/frosty_app_bar.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:provider/provider.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
|
||||
class LoginWebView extends StatelessWidget {
|
||||
/// Optional widget to navigate to after successful login
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -18,7 +41,11 @@ class LoginWebView extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: FrostyAppBar(
|
||||
title: const Text('Connect with Twitch'),
|
||||
title: Text(
|
||||
upgradeModeratorScopes
|
||||
? 'Enable moderator tools'
|
||||
: 'Connect with Twitch',
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_rounded),
|
||||
|
|
@ -44,6 +71,7 @@ class LoginWebView extends StatelessWidget {
|
|||
body: WebViewWidget(
|
||||
controller: authStore.createAuthWebViewController(
|
||||
routeAfter: routeAfter,
|
||||
upgradeModeratorScopes: upgradeModeratorScopes,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import 'package:frosty/constants.dart';
|
|||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.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:mobx/mobx.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.
|
||||
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.
|
||||
final TwitchApi twitchApi;
|
||||
|
||||
|
|
@ -45,7 +51,7 @@ abstract class AuthBase with Store {
|
|||
static const _maxReconnectAttempts = 5;
|
||||
|
||||
/// 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.
|
||||
@readonly
|
||||
|
|
@ -60,11 +66,19 @@ abstract class AuthBase with Store {
|
|||
var _isLoggedIn = false;
|
||||
|
||||
/// OAuth scopes granted to the current user token.
|
||||
///
|
||||
/// Used to explain missing-permission 401s without another network round-trip.
|
||||
var _grantedScopes = const <String>[];
|
||||
@readonly
|
||||
var _grantedScopes = ObservableList<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.
|
||||
@computed
|
||||
|
|
@ -81,6 +95,7 @@ abstract class AuthBase with Store {
|
|||
FutureOr<NavigationDecision> handleNavigation({
|
||||
required NavigationRequest request,
|
||||
Widget? routeAfter,
|
||||
bool upgradeModeratorScopes = false,
|
||||
}) {
|
||||
// Check if the URL is the redirect URI.
|
||||
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 token = uri.queryParameters['access_token'];
|
||||
|
||||
// Login with the provided token.
|
||||
if (token != null) login(token: token);
|
||||
if (token != null) {
|
||||
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".
|
||||
|
|
@ -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.
|
||||
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
||||
_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) {
|
||||
navigatorKey.currentState?.pop();
|
||||
navigatorKey.currentState?.push(
|
||||
|
|
@ -112,7 +139,10 @@ abstract class AuthBase with Store {
|
|||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
WebViewController createAuthWebViewController({Widget? routeAfter}) {
|
||||
WebViewController createAuthWebViewController({
|
||||
Widget? routeAfter,
|
||||
bool upgradeModeratorScopes = false,
|
||||
}) {
|
||||
final webViewController = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
// 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',
|
||||
);
|
||||
|
||||
final scopeQuery = upgradeModeratorScopes || _shouldIncludeModeratorScopes
|
||||
? twitch_scopes.twitchAllUserScopeQuery
|
||||
: twitch_scopes.twitchUserScopeQuery;
|
||||
|
||||
return webViewController
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: (request) =>
|
||||
handleNavigation(request: request, routeAfter: routeAfter),
|
||||
onNavigationRequest: (request) => handleNavigation(
|
||||
request: request,
|
||||
routeAfter: routeAfter,
|
||||
upgradeModeratorScopes: upgradeModeratorScopes,
|
||||
),
|
||||
onWebResourceError: (error) {
|
||||
debugPrint('Auth WebView error: ${error.description}');
|
||||
},
|
||||
|
|
@ -175,7 +212,7 @@ abstract class AuthBase with Store {
|
|||
'client_id': clientId,
|
||||
'redirect_uri': 'https://twitch.tv/login',
|
||||
'response_type': 'token',
|
||||
'scope': twitchUserScopeQuery,
|
||||
'scope': scopeQuery,
|
||||
'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 {
|
||||
try {
|
||||
|
|
@ -247,13 +303,40 @@ abstract class AuthBase with Store {
|
|||
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.
|
||||
@action
|
||||
Future<void> init() async {
|
||||
try {
|
||||
// Read and set the currently stored user token, if any.
|
||||
_token = await _storage.read(key: _userTokenKey);
|
||||
_gqlToken = await _storage.read(key: _gqlTokenKey);
|
||||
final stored = await Future.wait([
|
||||
_storage.read(key: _moderatorOptInUserIdKey),
|
||||
_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.
|
||||
// Otherwise, log in.
|
||||
|
|
@ -266,28 +349,30 @@ abstract class AuthBase with Store {
|
|||
_token = await twitchApi.getDefaultToken();
|
||||
await _storage.write(key: _defaultTokenKey, value: _token);
|
||||
}
|
||||
_setGrantedScopes(const []);
|
||||
} else {
|
||||
// Validate the existing token. If it fails, start reconnect loop.
|
||||
final List<String> scopes;
|
||||
final TwitchTokenInfo info;
|
||||
try {
|
||||
final validated = await twitchApi.validateToken(token: _token!);
|
||||
if (validated == null) return await logout();
|
||||
scopes = validated;
|
||||
info = validated;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('Token validation failed: $e');
|
||||
_isLoggedIn = false;
|
||||
_grantedScopes = const [];
|
||||
_setGrantedScopes(const []);
|
||||
_startReconnectLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
_grantedScopes = scopes;
|
||||
_setGrantedScopes(info.scopes);
|
||||
|
||||
// Initialize the user store.
|
||||
await user.init();
|
||||
|
||||
if (user.details != null) {
|
||||
_isLoggedIn = true;
|
||||
await _persistLastUserId(user.details!.id);
|
||||
_stopReconnectLoop();
|
||||
|
||||
// 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 {
|
||||
try {
|
||||
// Validate the custom token.
|
||||
final scopes = await twitchApi.validateToken(token: token);
|
||||
if (scopes == null) return;
|
||||
_grantedScopes = scopes;
|
||||
final info = await twitchApi.validateToken(token: token);
|
||||
if (info == null) return;
|
||||
_setGrantedScopes(info.scopes);
|
||||
|
||||
// Replace the current default token with the new custom token.
|
||||
_token = token;
|
||||
|
|
@ -325,6 +410,12 @@ abstract class AuthBase with Store {
|
|||
// Set the login status to logged in.
|
||||
if (user.details != null) {
|
||||
_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.setUserIdentifier(user.details!.id);
|
||||
_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.
|
||||
@action
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
_stopReconnectLoop();
|
||||
final previousUserId = user.details?.id ?? _lastUserId;
|
||||
await _persistLastUserId(previousUserId);
|
||||
|
||||
// Delete the existing user token and GQL token.
|
||||
await _storage.delete(key: _userTokenKey);
|
||||
await _storage.delete(key: _gqlTokenKey);
|
||||
_token = null;
|
||||
_gqlToken = null;
|
||||
_grantedScopes = const [];
|
||||
_setGrantedScopes(const []);
|
||||
|
||||
// Clear the user info.
|
||||
user.dispose();
|
||||
|
|
@ -390,18 +560,19 @@ abstract class AuthBase with Store {
|
|||
return;
|
||||
}
|
||||
|
||||
final scopes = await twitchApi.validateToken(token: stored);
|
||||
if (scopes == null) {
|
||||
final info = await twitchApi.validateToken(token: stored);
|
||||
if (info == null) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Token valid again — restore session.
|
||||
_token = stored;
|
||||
_grantedScopes = scopes;
|
||||
_setGrantedScopes(info.scopes);
|
||||
await user.init();
|
||||
if (user.details != null) {
|
||||
_isLoggedIn = true;
|
||||
await _persistLastUserId(user.details!.id);
|
||||
_error = null;
|
||||
_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
|
||||
|
||||
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;
|
||||
|
||||
@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);
|
||||
|
||||
String? get error {
|
||||
|
|
@ -121,6 +149,18 @@ mixin _$AuthStore on AuthBase, Store {
|
|||
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(
|
||||
'AuthBase.logout',
|
||||
context: context,
|
||||
|
|
@ -134,6 +174,7 @@ mixin _$AuthStore on AuthBase, Store {
|
|||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
hasModeratorScopes: ${hasModeratorScopes},
|
||||
headersTwitch: ${headersTwitch}
|
||||
''';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ class UserStore = UserStoreBase with _$UserStore;
|
|||
abstract class UserStoreBase with Store {
|
||||
final TwitchApi twitchApi;
|
||||
|
||||
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
|
||||
final bool Function() hasModeratorScopes;
|
||||
|
||||
/// The current user's info.
|
||||
@readonly
|
||||
UserTwitch? _details;
|
||||
|
|
@ -23,7 +26,10 @@ abstract class UserStoreBase with Store {
|
|||
|
||||
ReactionDisposer? _disposeReaction;
|
||||
|
||||
UserStoreBase({required this.twitchApi});
|
||||
UserStoreBase({
|
||||
required this.twitchApi,
|
||||
required this.hasModeratorScopes,
|
||||
});
|
||||
|
||||
@action
|
||||
Future<void> init() async {
|
||||
|
|
@ -36,11 +42,20 @@ abstract class UserStoreBase with Store {
|
|||
twitchApi
|
||||
.getUserBlockedList(id: _details!.id)
|
||||
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
|
||||
twitchApi
|
||||
.getModeratedChannels(id: _details!.id)
|
||||
.then((channels) => _moderatedChannels = channels.asObservable());
|
||||
// Never hit moderated-channels Helix without moderator scopes — that 401
|
||||
// is what nags non-mod users after an app upgrade.
|
||||
if (hasModeratorScopes()) {
|
||||
twitchApi
|
||||
.getModeratedChannels(id: _details!.id)
|
||||
.then(
|
||||
(channels) => _moderatedChannels = channels.asObservable(),
|
||||
);
|
||||
} else {
|
||||
_moderatedChannels = ObservableList<String>();
|
||||
}
|
||||
}
|
||||
|
||||
_disposeReaction?.call();
|
||||
_disposeReaction = autorun(
|
||||
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
|
||||
);
|
||||
|
|
@ -73,7 +88,7 @@ abstract class UserStoreBase with Store {
|
|||
/// status granted or revoked since then.
|
||||
@action
|
||||
Future<void> refreshModeratedChannels() async {
|
||||
if (_details?.id == null) return;
|
||||
if (_details?.id == null || !hasModeratorScopes()) return;
|
||||
_moderatedChannels =
|
||||
(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
|
||||
/// of that channel or its broadcaster (the broadcaster isn't in their own
|
||||
/// 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) =>
|
||||
_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
|
||||
Future<bool> deleteMessage({
|
||||
required String broadcasterId,
|
||||
required String messageId,
|
||||
}) async {
|
||||
// Need the moderator ID and confirmed mod status for this channel.
|
||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
||||
return false;
|
||||
}
|
||||
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||
return twitchApi.deleteChatMessage(
|
||||
broadcasterId: broadcasterId,
|
||||
moderatorId: _details!.id,
|
||||
|
|
@ -111,10 +132,7 @@ abstract class UserStoreBase with Store {
|
|||
int? duration,
|
||||
String? reason,
|
||||
}) async {
|
||||
// Need the moderator ID and confirmed mod status for this channel.
|
||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
||||
return false;
|
||||
}
|
||||
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||
return twitchApi.banUser(
|
||||
broadcasterId: broadcasterId,
|
||||
moderatorId: _details!.id,
|
||||
|
|
@ -130,9 +148,7 @@ abstract class UserStoreBase with Store {
|
|||
required String broadcasterId,
|
||||
required String userIdToUnban,
|
||||
}) async {
|
||||
if (_details?.id == null || !canModerate(broadcasterId)) {
|
||||
return false;
|
||||
}
|
||||
if (!canPerformModeratorActions(broadcasterId)) return false;
|
||||
return twitchApi.unbanUser(
|
||||
broadcasterId: broadcasterId,
|
||||
moderatorId: _details!.id,
|
||||
|
|
@ -145,6 +161,7 @@ abstract class UserStoreBase with Store {
|
|||
_details = null;
|
||||
_blockedUsers.clear();
|
||||
_moderatedChannels.clear();
|
||||
if (_disposeReaction != null) _disposeReaction!();
|
||||
_disposeReaction?.call();
|
||||
_disposeReaction = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue