diff --git a/lib/app_state.dart b/lib/app_state.dart index 74b076a..5b59c24 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -21,6 +21,8 @@ import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; import 'services/deep_link_service.dart'; +import 'services/auth_service.dart' show AccountBlockedException; +import 'services/localization_service.dart'; import 'widgets/node_provider_with_cache.dart'; import 'services/profile_service.dart'; import 'widgets/reauth_messages_dialog.dart'; @@ -313,8 +315,13 @@ class AppState extends ChangeNotifier { } // ---------- Auth Methods ---------- - Future login() async { - await _authState.login(); + Future login({BuildContext? context}) async { + try { + await _authState.login(); + } catch (e) { + await _handleLoginFailure(e, context); + return; + } // Check for messages and active blocks after successful login if (isLoggedIn) { checkMessages(); @@ -333,8 +340,13 @@ class AppState extends ChangeNotifier { await _authState.refreshAuthState(); } - Future forceLogin() async { - await _authState.forceLogin(); + Future forceLogin({BuildContext? context}) async { + try { + await _authState.forceLogin(); + } catch (e) { + await _handleLoginFailure(e, context); + return; + } // Check for messages and active blocks after successful login if (isLoggedIn) { checkMessages(); @@ -342,6 +354,34 @@ class AppState extends ChangeNotifier { } } + /// Handle a login/forceLogin failure. The OAuth token exchange can succeed + /// while a subsequent step still fails (e.g. an active block, or any + /// other API error) - either way, the user must be told something went + /// wrong instead of the login silently doing nothing. + Future _handleLoginFailure(Object error, BuildContext? context) async { + debugPrint('AppState: Login failed: $error'); + if (error is AccountBlockedException) { + // We got a conclusive answer straight from the API during login, so + // record it like any other successful check. + await _accountBlockState.check( + accessToken: await _authState.getAccessToken(), + uploadMode: uploadMode, + ); + if (context != null && context.mounted) { + _showActiveBlockDialog(context); + } + return; + } + if (context != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocalizationService.instance.t('auth.loginFailed')), + backgroundColor: Colors.red, + ), + ); + } + } + // ---------- Account Block Methods ---------- /// Silently check whether the user currently has an active OSM block. @@ -373,8 +413,8 @@ class AppState extends ChangeNotifier { showDialog( context: context, builder: (context) => ActiveBlockDialog( - onViewMessages: () async { - final url = Uri.parse(getMessagesUrl()); + onViewDetails: () async { + final url = Uri.parse(getBlockDetailsUrl()); await launchUrl(url, mode: LaunchMode.externalApplication); }, onDismiss: () { @@ -410,6 +450,18 @@ class AppState extends ChangeNotifier { String getMessagesUrl() { return _messagesState.getMessagesUrl(uploadMode); } + + /// URL to view the current user's block details on OSM's website, or - + /// if the username isn't known for some reason - the messages inbox, + /// since OSM also sends a message when a block is issued. + String getBlockDetailsUrl() { + if (username.isEmpty) return getMessagesUrl(); + final host = switch (uploadMode) { + UploadMode.sandbox => 'https://master.apis.dev.openstreetmap.org', + UploadMode.production || UploadMode.simulate => 'https://www.openstreetmap.org', + }; + return '$host/user/${Uri.encodeComponent(username)}/blocks'; + } /// URL of the specific changeset with unread comments, on OSM's website, /// or null if there are no unread changeset comments. diff --git a/lib/localizations/de.json b/lib/localizations/de.json index b89c21e..9f27f6c 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -214,6 +214,7 @@ "tapToLogout": "Zum Abmelden antippen", "requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen", "loggedOut": "Abgemeldet", + "loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", "testConnection": "Verbindung Testen", "testConnectionSubtitle": "OSM-Anmeldedaten überprüfen", "connectionOK": "Verbindung OK - Anmeldedaten sind gültig", @@ -247,7 +248,8 @@ "unreadNotificationsMessage": "Sie haben ungelesene OSM-Nachrichten und/oder neue Kommentare zu Ihren Changesets.", "unreadNotificationsView": "Jetzt ansehen", "activeBlockTitle": "Ihr OSM-Konto hat eine aktive Sperre", - "activeBlockMessage": "Es scheint, dass Ihr OpenStreetMap-Konto derzeit gesperrt ist und keine Beiträge einreichen kann. Bitte lesen Sie die Nachricht, die Sie erhalten haben sollten, um zu erfahren, warum und was Sie tun können, um das Problem zu beheben." + "activeBlockMessage": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Beiträge einreichen. Sie sollten eine Nachricht mit weiteren Details erhalten haben; bitte prüfen Sie Ihren OSM-Posteingang, um mehr zu erfahren.", + "viewBlockDetails": "Sperrdetails ansehen" }, "queue": { "title": "Upload-Warteschlange", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 36b821c..ce19b38 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -251,6 +251,7 @@ "tapToLogout": "Tap to logout", "requiredToSubmit": "Required to submit camera data", "loggedOut": "Logged out", + "loginFailed": "Login failed. Please try again.", "testConnection": "Test Connection", "testConnectionSubtitle": "Verify OSM credentials are working", "connectionOK": "Connection OK - credentials are valid", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "You have unread OSM messages and/or new comments on your changesets.", "unreadNotificationsView": "View Now", "activeBlockTitle": "Your OSM Account Has an Active Block", - "activeBlockMessage": "It seems your OpenStreetMap account has an active block, and is currently restricted from making submissions. Please read the message you should have received to learn why, and what you can do to resolve it." + "activeBlockMessage": "Your OpenStreetMap account currently has an active block and is restricted from making submissions. You should have received a message with more details; please check your OSM inbox to learn more.", + "viewBlockDetails": "View Block Details" }, "queue": { "title": "Upload Queue", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index c5a92a9..01072c7 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -251,6 +251,7 @@ "tapToLogout": "Toque para cerrar sesión", "requiredToSubmit": "Requerido para enviar datos de cámaras", "loggedOut": "Sesión cerrada", + "loginFailed": "Error al iniciar sesión. Inténtelo de nuevo.", "testConnection": "Probar Conexión", "testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen", "connectionOK": "Conexión OK - las credenciales son válidas", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Tienes mensajes de OSM sin leer y/o nuevos comentarios en tus changesets.", "unreadNotificationsView": "Ver Ahora", "activeBlockTitle": "Su cuenta de OSM tiene un bloqueo activo", - "activeBlockMessage": "Parece que su cuenta de OpenStreetMap tiene un bloqueo activo y actualmente no puede realizar envíos. Lea el mensaje que debería haber recibido para saber por qué y qué puede hacer para resolverlo." + "activeBlockMessage": "Su cuenta de OpenStreetMap tiene actualmente un bloqueo activo y no puede realizar envíos. Debería haber recibido un mensaje con más detalles; revise su bandeja de entrada de OSM para obtener más información.", + "viewBlockDetails": "Ver Detalles del Bloqueo" }, "queue": { "title": "Cola de Subida", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 4465f4f..9bea050 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -251,6 +251,7 @@ "tapToLogout": "Appuyer pour se déconnecter", "requiredToSubmit": "Requis pour soumettre des données de caméras", "loggedOut": "Déconnecté", + "loginFailed": "Échec de la connexion. Veuillez réessayer.", "testConnection": "Tester Connexion", "testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent", "connectionOK": "Connexion OK - les identifiants sont valides", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Vous avez des messages OSM non lus et/ou de nouveaux commentaires sur vos changesets.", "unreadNotificationsView": "Voir Maintenant", "activeBlockTitle": "Votre compte OSM a un blocage actif", - "activeBlockMessage": "Il semble que votre compte OpenStreetMap ait un blocage actif et ne puisse actuellement pas effectuer de soumissions. Veuillez lire le message que vous devriez avoir reçu pour savoir pourquoi et ce que vous pouvez faire pour y remédier." + "activeBlockMessage": "Votre compte OpenStreetMap a actuellement un blocage actif et ne peut pas effectuer de soumissions. Vous devriez avoir reçu un message avec plus de détails ; veuillez consulter votre boîte de réception OSM pour en savoir plus.", + "viewBlockDetails": "Voir les Détails du Blocage" }, "queue": { "title": "File de Téléchargement", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index f38b114..891ff7a 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -251,6 +251,7 @@ "tapToLogout": "Tocca per disconnetterti", "requiredToSubmit": "Richiesto per inviare dati delle telecamere", "loggedOut": "Disconnesso", + "loginFailed": "Accesso non riuscito. Riprova.", "testConnection": "Testa Connessione", "testConnectionSubtitle": "Verifica che le credenziali OSM funzionino", "connectionOK": "Connessione OK - le credenziali sono valide", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Hai messaggi OSM non letti e/o nuovi commenti sui tuoi changeset.", "unreadNotificationsView": "Visualizza Ora", "activeBlockTitle": "Il tuo account OSM ha un blocco attivo", - "activeBlockMessage": "Sembra che il tuo account OpenStreetMap abbia un blocco attivo e sia attualmente impossibilitato a effettuare invii. Leggi il messaggio che dovresti aver ricevuto per sapere perché e cosa puoi fare per risolvere il problema." + "activeBlockMessage": "Il tuo account OpenStreetMap ha attualmente un blocco attivo e non può effettuare invii. Dovresti aver ricevuto un messaggio con maggiori dettagli; controlla la tua casella di posta OSM per saperne di più.", + "viewBlockDetails": "Visualizza Dettagli Blocco" }, "queue": { "title": "Coda di Upload", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 1650db3..754c78d 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -251,6 +251,7 @@ "tapToLogout": "Tik om uit te loggen", "requiredToSubmit": "Vereist om camera gegevens in te dienen", "loggedOut": "Uitgelogd", + "loginFailed": "Inloggen mislukt. Probeer het opnieuw.", "testConnection": "Test Verbinding", "testConnectionSubtitle": "Verifieer dat OSM credentials werken", "connectionOK": "Verbinding OK - credentials zijn geldig", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "U heeft ongelezen OSM-berichten en/of nieuwe opmerkingen op uw changesets.", "unreadNotificationsView": "Nu Bekijken", "activeBlockTitle": "Uw OSM-account heeft een actieve blokkade", - "activeBlockMessage": "Het lijkt erop dat uw OpenStreetMap-account een actieve blokkade heeft en momenteel geen inzendingen kan doen. Lees het bericht dat u zou moeten hebben ontvangen om te weten waarom en wat u kunt doen om dit op te lossen." + "activeBlockMessage": "Uw OpenStreetMap-account heeft momenteel een actieve blokkade en kan geen inzendingen doen. U zou een bericht met meer details moeten hebben ontvangen; controleer uw OSM-inbox voor meer informatie.", + "viewBlockDetails": "Blokkadedetails Bekijken" }, "queue": { "title": "Upload Wachtrij", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 2c22347..bf0e7a8 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -251,6 +251,7 @@ "tapToLogout": "Stuknij aby się wylogować", "requiredToSubmit": "Wymagane do zgłaszania danych kamer", "loggedOut": "Wylogowany", + "loginFailed": "Logowanie nie powiodło się. Spróbuj ponownie.", "testConnection": "Testuj Połączenie", "testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają", "connectionOK": "Połączenie OK - dane logowania są ważne", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Masz nieprzeczytane wiadomości OSM i/lub nowe komentarze do Twoich zestawów zmian.", "unreadNotificationsView": "Zobacz Teraz", "activeBlockTitle": "Twoje konto OSM ma aktywną blokadę", - "activeBlockMessage": "Wygląda na to, że Twoje konto OpenStreetMap ma aktywną blokadę i obecnie nie może dokonywać zgłoszeń. Przeczytaj wiadomość, którą powinieneś był otrzymać, aby dowiedzieć się dlaczego i co możesz zrobić, aby to rozwiązać." + "activeBlockMessage": "Twoje konto OpenStreetMap ma obecnie aktywną blokadę i nie może dokonywać zgłoszeń. Powinieneś był otrzymać wiadomość z dalszymi szczegółami; sprawdź swoją skrzynkę odbiorczą OSM, aby dowiedzieć się więcej.", + "viewBlockDetails": "Zobacz Szczegóły Blokady" }, "queue": { "title": "Kolejka Przesyłania", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index f26b1d0..a934510 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -251,6 +251,7 @@ "tapToLogout": "Toque para sair", "requiredToSubmit": "Necessário para enviar dados de câmeras", "loggedOut": "Deslogado", + "loginFailed": "Falha no login. Tente novamente.", "testConnection": "Testar Conexão", "testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando", "connectionOK": "Conexão OK - credenciais são válidas", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Você tem mensagens OSM não lidas e/ou novos comentários nos seus changesets.", "unreadNotificationsView": "Ver Agora", "activeBlockTitle": "Sua conta OSM tem um bloqueio ativo", - "activeBlockMessage": "Parece que sua conta OpenStreetMap tem um bloqueio ativo e está impedida de fazer envios no momento. Leia a mensagem que você deve ter recebido para saber o motivo e o que pode fazer para resolver." + "activeBlockMessage": "Sua conta OpenStreetMap atualmente tem um bloqueio ativo e está impedida de fazer envios. Você deve ter recebido uma mensagem com mais detalhes; verifique sua caixa de entrada do OSM para saber mais.", + "viewBlockDetails": "Ver Detalhes do Bloqueio" }, "queue": { "title": "Fila de Upload", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 700c1a9..4f48680 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -251,6 +251,7 @@ "tapToLogout": "Çıkış yapmak için dokun", "requiredToSubmit": "Kamera verisi göndermek için gerekli", "loggedOut": "Çıkış yapıldı", + "loginFailed": "Giriş başarısız oldu. Lütfen tekrar deneyin.", "testConnection": "Bağlantıyı Test Et", "testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın", "connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "Okunmamış OSM mesajlarınız ve/veya değişiklik kümelerinize yapılan yeni yorumlar var.", "unreadNotificationsView": "Şimdi Görüntüle", "activeBlockTitle": "OSM Hesabınızda Aktif Bir Engelleme Var", - "activeBlockMessage": "OpenStreetMap hesabınızda aktif bir engelleme olduğu ve şu anda gönderim yapmanızın kısıtlandığı görülüyor. Nedenini ve bunu nasıl çözebileceğinizi öğrenmek için almış olmanız gereken mesajı okuyun." + "activeBlockMessage": "OpenStreetMap hesabınızda şu anda aktif bir engelleme var ve gönderim yapmanız kısıtlanmış durumda. Daha fazla ayrıntı içeren bir mesaj almış olmalısınız; daha fazla bilgi için lütfen OSM gelen kutunuzu kontrol edin.", + "viewBlockDetails": "Engelleme Ayrıntılarını Görüntüle" }, "queue": { "title": "Yükleme Kuyruğu", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 766329e..bb72834 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -251,6 +251,7 @@ "tapToLogout": "Натисніть для виходу", "requiredToSubmit": "Потрібно для подання даних камер", "loggedOut": "Вихід здійснено", + "loginFailed": "Не вдалося увійти. Спробуйте ще раз.", "testConnection": "Тестувати З'єднання", "testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють", "connectionOK": "З'єднання в порядку - облікові дані дійсні", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.", "unreadNotificationsView": "Переглянути Зараз", "activeBlockTitle": "Ваш обліковий запис OSM має активне блокування", - "activeBlockMessage": "Схоже, що ваш обліковий запис OpenStreetMap має активне блокування і наразі не може надсилати дані. Будь ласка, прочитайте повідомлення, яке ви мали отримати, щоб дізнатися чому та що можна зробити для вирішення цієї проблеми." + "activeBlockMessage": "Ваш обліковий запис OpenStreetMap наразі має активне блокування і не може надсилати дані. Ви мали отримати повідомлення з додатковими деталями; перевірте свою поштову скриньку OSM, щоб дізнатися більше.", + "viewBlockDetails": "Переглянути Деталі Блокування" }, "queue": { "title": "Черга Завантаження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 0c7de61..052ffd8 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -251,6 +251,7 @@ "tapToLogout": "点击登出", "requiredToSubmit": "提交摄像头数据所需", "loggedOut": "已登出", + "loginFailed": "登录失败,请重试。", "testConnection": "测试连接", "testConnectionSubtitle": "验证 OSM 凭据是否有效", "connectionOK": "连接正常 - 凭据有效", @@ -284,7 +285,8 @@ "unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。", "unreadNotificationsView": "立即查看", "activeBlockTitle": "您的 OSM 账户有一个有效封禁", - "activeBlockMessage": "您的 OpenStreetMap 账户似乎存在有效封禁,目前无法提交内容。请阅读您应该已收到的消息,了解原因以及如何解决。" + "activeBlockMessage": "您的 OpenStreetMap 账户目前有有效封禁,无法提交内容。您应该已收到一条包含更多详情的消息;请查看您的 OSM 收件箱以了解更多信息。", + "viewBlockDetails": "查看封禁详情" }, "queue": { "title": "上传队列", diff --git a/lib/screens/osm_account_screen.dart b/lib/screens/osm_account_screen.dart index f5b5f9e..f54f7ca 100644 --- a/lib/screens/osm_account_screen.dart +++ b/lib/screens/osm_account_screen.dart @@ -75,10 +75,12 @@ class _OSMAccountScreenState extends State { } } else { // Start login flow - the user will be redirected to browser - await appState.forceLogin(); + await appState.forceLogin(context: context); - // Don't show immediate feedback - the UI will update automatically - // when the OAuth callback completes and notifyListeners() is called + // Don't show immediate feedback on success - the UI will update + // automatically when the OAuth callback completes and + // notifyListeners() is called. Failures are surfaced directly + // by forceLogin() via a snackbar/dialog. } }, ), @@ -98,7 +100,7 @@ class _OSMAccountScreenState extends State { subtitle: Text(locService.t('auth.activeBlockMessage')), trailing: const Icon(Icons.open_in_new), onTap: () async { - final url = Uri.parse(appState.getMessagesUrl()); + final url = Uri.parse(appState.getBlockDetailsUrl()); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { diff --git a/lib/screens/settings/sections/auth_section.dart b/lib/screens/settings/sections/auth_section.dart index d82bb8b..bd3ae1b 100644 --- a/lib/screens/settings/sections/auth_section.dart +++ b/lib/screens/settings/sections/auth_section.dart @@ -40,10 +40,12 @@ class AuthSection extends StatelessWidget { } } else { // Start login flow - the user will be redirected to browser - await appState.forceLogin(); + await appState.forceLogin(context: context); - // Don't show immediate feedback - the UI will update automatically - // when the OAuth callback completes and notifyListeners() is called + // Don't show immediate feedback on success - the UI will update + // automatically when the OAuth callback completes and + // notifyListeners() is called. Failures are surfaced directly + // by forceLogin() via a snackbar/dialog. } }, ), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 4c7dcc4..06aeece 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:oauth2_client/oauth2_client.dart'; import 'package:oauth2_client/oauth2_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,6 +11,32 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../keys.dart'; import '../app_state.dart' show UploadMode; import 'http_client.dart'; +import 'osm_block_service.dart'; + +/// Thrown when the OAuth token exchange succeeds but a subsequent +/// authenticated API call (e.g. fetching the username) fails. Carries the +/// HTTP status code so callers can distinguish e.g. a 403 (often indicating +/// an active account block) from other failures. +class AuthApiException implements Exception { + final String message; + final int? statusCode; + final String? body; + + AuthApiException(this.message, {this.statusCode, this.body}); + + @override + String toString() => 'AuthApiException: $message (status: $statusCode)'; +} + +/// Thrown when the OAuth token exchange succeeds but the account turns out +/// to have an active OSM DWG block. Detected by checking the +/// `user/blocks/active` endpoint (which is accessible even while blocked) +/// immediately after obtaining the token, before attempting any other +/// authenticated call that would otherwise fail with a 403. +class AccountBlockedException implements Exception { + @override + String toString() => 'AccountBlockedException: account has an active OSM block'; +} class AuthService { // Both client IDs from keys.dart @@ -18,8 +45,10 @@ class AuthService { late OAuth2Helper _helper; String? _displayName; UploadMode _mode = UploadMode.production; + final OSMBlockService _blockService; - AuthService({UploadMode mode = UploadMode.production}) { + AuthService({UploadMode mode = UploadMode.production, OSMBlockService? blockService}) + : _blockService = blockService ?? OSMBlockService() { setUploadMode(mode); } @@ -87,8 +116,12 @@ class AuthService { try { final token = await _helper.getToken(); if (token.accessToken == null) { + debugPrint('AuthService: OAuth error: token null or missing accessToken (status: ${token.httpStatusCode}, error: ${token.error})'); log('OAuth error: token null or missing accessToken'); - return null; + throw AuthApiException( + 'OAuth token exchange did not return an access token', + statusCode: token.httpStatusCode, + ); } final tokenMap = { 'accessToken': token.accessToken, @@ -97,8 +130,33 @@ class AuthService { final tokenJson = jsonEncode(tokenMap); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, tokenJson); // Save token for current mode + + // Check for an active block *before* fetching the username or anything + // else. A blocked account's OAuth token exchange succeeds normally, + // but nearly every other authenticated endpoint (including + // user/details) returns 403. The blocks/active endpoint is explicitly + // documented as accessible even while blocked, so checking it first + // lets us detect this case reliably instead of getting a mysterious + // 403 from _fetchUsername with no way to tell why. + final isBlocked = await _blockService.checkActiveBlock( + accessToken: token.accessToken, + uploadMode: _mode, + ); + if (isBlocked == true) { + debugPrint('AuthService: Login succeeded but account has an active block'); + throw AccountBlockedException(); + } + + // Fetching the username can still fail for other reasons even though + // the OAuth token exchange succeeded. Don't swallow that - the token + // is already saved, so the caller needs to know the login isn't + // actually usable rather than silently treating it as "not logged in". _displayName = await _fetchUsername(token.accessToken!); return _displayName; + } on AccountBlockedException { + rethrow; + } on AuthApiException { + rethrow; } catch (e) { debugPrint('AuthService: OAuth login failed: $e'); log('OAuth login failed: $e'); @@ -184,24 +242,37 @@ class AuthService { final _client = UserAgentClient(); Future _fetchUsername(String accessToken) async { + final http.Response resp; try { - final resp = await _client.get( + resp = await _client.get( Uri.parse('$_apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); - - if (resp.statusCode != 200) { - log('fetchUsername response ${resp.statusCode}: ${resp.body}'); - return null; - } - final userData = jsonDecode(resp.body); - final displayName = userData['user']?['display_name']; - return displayName; } catch (e) { + // Network-level failure (no connection, timeout, etc.) - not + // indicative of a block, just a connectivity problem. debugPrint('AuthService: Error fetching username: $e'); log('Error fetching username: $e'); - return null; + rethrow; } + + if (resp.statusCode != 200) { + // A non-200 here (very commonly 403) after a successful OAuth token + // exchange is a strong signal of an active account block. Surface it + // loudly via debugPrint (visible in a normal release/profile console, + // unlike dart:developer's log()) and throw instead of silently + // returning null, so the caller can show a real error to the user. + debugPrint('AuthService: fetchUsername failed - HTTP ${resp.statusCode}: ${resp.body}'); + log('fetchUsername response ${resp.statusCode}: ${resp.body}'); + throw AuthApiException( + 'Failed to fetch user details after login', + statusCode: resp.statusCode, + body: resp.body, + ); + } + final userData = jsonDecode(resp.body); + final displayName = userData['user']?['display_name']; + return displayName; } } diff --git a/lib/state/auth_state.dart b/lib/state/auth_state.dart index dcf163b..1a735e5 100644 --- a/lib/state/auth_state.dart +++ b/lib/state/auth_state.dart @@ -28,11 +28,16 @@ class AuthState extends ChangeNotifier { Future login() async { try { _username = await _auth.login(); + notifyListeners(); } catch (e) { debugPrint("AuthState: Login error: $e"); _username = null; + notifyListeners(); + // Don't swallow this - the caller (AppState) needs to know login + // failed so it can tell the user, rather than silently staying + // logged out with no explanation. + rethrow; } - notifyListeners(); } Future logout() async { @@ -58,11 +63,16 @@ class AuthState extends ChangeNotifier { Future forceLogin() async { try { _username = await _auth.forceLogin(); + notifyListeners(); } catch (e) { debugPrint("AuthState: Forced login error: $e"); _username = null; + notifyListeners(); + // Don't swallow this - the caller (AppState) needs to know login + // failed so it can tell the user, rather than silently staying + // logged out with no explanation. + rethrow; } - notifyListeners(); } Future validateToken() async { diff --git a/lib/widgets/active_block_dialog.dart b/lib/widgets/active_block_dialog.dart index 2404e2c..5d0b3e5 100644 --- a/lib/widgets/active_block_dialog.dart +++ b/lib/widgets/active_block_dialog.dart @@ -5,12 +5,12 @@ import '../services/localization_service.dart'; /// active block. Unlike the unread-notifications dialog, this has no /// "don't show again" option and is shown every time a check finds a block. class ActiveBlockDialog extends StatelessWidget { - final VoidCallback onViewMessages; + final VoidCallback onViewDetails; final VoidCallback onDismiss; const ActiveBlockDialog({ super.key, - required this.onViewMessages, + required this.onViewDetails, required this.onDismiss, }); @@ -43,10 +43,10 @@ class ActiveBlockDialog extends StatelessWidget { FilledButton.icon( onPressed: () { Navigator.of(context).pop(); - onViewMessages(); + onViewDetails(); }, - icon: const Icon(Icons.message, size: 18), - label: Text(locService.t('auth.viewMessages')), + icon: const Icon(Icons.open_in_new, size: 18), + label: Text(locService.t('auth.viewBlockDetails')), ), ], );