From 35ac015bb7caabef0edf20de0028d56c4e250420 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:45:34 -0600 Subject: [PATCH] feat(login): add OAuth browser login button and fix ToS persistence race condition --- src/lib/components/views/LoginView.svelte | 136 +++++++++++++++++++++- src/lib/i18n/locales/de.json | 7 +- src/lib/i18n/locales/en.json | 7 +- src/lib/i18n/locales/es.json | 7 +- src/lib/i18n/locales/fr.json | 7 +- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/src/lib/components/views/LoginView.svelte b/src/lib/components/views/LoginView.svelte index ce971ff6b..3178fce32 100644 --- a/src/lib/components/views/LoginView.svelte +++ b/src/lib/components/views/LoginView.svelte @@ -3,7 +3,7 @@ import TitleBar from '../TitleBar.svelte'; import { t } from '$lib/i18n'; import { setManualOffline } from '$lib/stores/offlineStore'; - import { qobuzTosAccepted, loadTosAcceptance } from '$lib/stores/qobuzLegalStore'; + import { qobuzTosAccepted, loadTosAcceptance, setTosAcceptance } from '$lib/stores/qobuzLegalStore'; import { get } from 'svelte/store'; interface UserInfo { @@ -24,6 +24,7 @@ let password = $state(''); let rememberMe = $state(true); let isLoading = $state(false); + let isOAuthLoading = $state(false); let isInitializing = $state(true); let initStatus = $state('Connecting to Qobuz™...'); let error = $state(null); @@ -185,6 +186,15 @@ isLoading = true; error = null; + try { + // Persist ToS acceptance synchronously before backend login so the + // backend gate (require_tos_accepted) always sees the correct value. + // The subscribe-handler write is fire-and-forget and can lose the race. + await setTosAcceptance(true); + } catch { + // If persistence fails the backend will reject the login below + } + try { // V2 manual login with blocking CoreBridge auth const response = await invoke<{ @@ -260,6 +270,65 @@ error = formatErrorMessage(err); } } + + async function handleOAuthLogin() { + if (!get(qobuzTosAccepted)) { + error = $t('legal.tosRequiredToLogin'); + return; + } + + isOAuthLoading = true; + error = null; + + try { + await setTosAcceptance(true); + } catch { + // Continue even if persistence fails + } + + try { + const response = await invoke<{ + success: boolean; + user_name?: string; + user_id?: number; + subscription?: string; + subscription_valid_until?: string | null; + error?: string; + error_code?: string; + }>('v2_start_oauth_login'); + + console.log('[LoginView] v2_start_oauth_login response:', response); + + if (response.success) { + if (!response.user_id || response.user_id === 0) { + console.error('[LoginView] OAuth login returned success but invalid user_id'); + error = $t('auth.v2AuthFailed'); + return; + } + onLoginSuccess({ + userName: response.user_name || 'User', + userId: response.user_id, + subscription: response.subscription || 'Active', + subscriptionValidUntil: response.subscription_valid_until ?? null, + }); + } else { + if (response.error_code === 'v2_auth_failed') { + error = $t('auth.v2AuthFailed'); + } else if (response.error_code === 'v2_not_initialized') { + error = $t('auth.v2NotInitialized'); + } else if (response.error_code === 'oauth_cancelled') { + error = null; // User closed the window, no error to show + } else { + error = response.error || 'Browser login failed'; + } + } + } catch (err) { + console.error('OAuth login error:', err); + error = formatErrorMessage(err); + } finally { + isOAuthLoading = false; + } + }
@@ -343,12 +412,28 @@
{error}
{/if} - + +
or
+ + @@ -635,6 +720,49 @@ cursor: not-allowed; } + .divider { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-muted); + font-size: 12px; + margin: -4px 0; + } + + .divider::before, + .divider::after { + content: ''; + flex: 1; + height: 1px; + background-color: var(--border-color, var(--bg-tertiary)); + } + + .oauth-btn { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background-color: transparent; + color: var(--text-primary); + border: 1px solid var(--accent-primary); + border-radius: 8px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + } + + .oauth-btn:hover:not(:disabled) { + background-color: var(--accent-primary); + color: white; + } + + .oauth-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + } + .footer-copy { font-size: 12px; color: var(--text-muted); diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 281aa6d4f..a55ebc554 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -1029,7 +1029,10 @@ "rememberMe": "Angemeldet bleiben", "ineligibleSubscription": "Keine aktive Abonnement. QBZ wird nur im Offline-Modus ausgeführt.", "v2AuthFailed": "Authentifizierung mit Qobuz fehlgeschlagen. Bitte versuchen Sie es erneut.", - "v2NotInitialized": "Verbindung zu Qobuz konnte nicht hergestellt werden. Bitte starten Sie die App neu." + "v2NotInitialized": "Verbindung zu Qobuz konnte nicht hergestellt werden. Bitte starten Sie die App neu.", + "connectionFailed": "Verbindung zu Qobuz™ fehlgeschlagen. Netzwerk prüfen und erneut versuchen.", + "oauthButton": "Im Browser anmelden", + "oauthLoading": "Browser wird geöffnet..." }, "legal": { "brandingLineSuffix": "", @@ -1619,7 +1622,7 @@ "title": "DailyQ", "subtitle": "Basierend auf deinem letzten Hoerverlauf.", "cardDesc": "Bereichere deinen Tag mit einer personalisierten Musikauswahl.", - "algorithmInfo": "Basierend auf dem Qobuz\u2122-Vorschlagsalgorithmus, kuratiert aus deinem lokalen Hoerverlauf." + "algorithmInfo": "Basierend auf dem Qobuz™-Vorschlagsalgorithmus, kuratiert aus deinem lokalen Hoerverlauf." }, "weeklyMixes": { "title": "WeeklyQ", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 7f46f5905..3134940cc 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -1094,7 +1094,10 @@ "rememberMe": "Remember me", "ineligibleSubscription": "No active subscription. QBZ will run in offline mode only.", "v2AuthFailed": "Authentication with Qobuz failed. Please try again.", - "v2NotInitialized": "Could not connect to Qobuz. Please restart the app." + "v2NotInitialized": "Could not connect to Qobuz. Please restart the app.", + "connectionFailed": "Failed to connect to Qobuz™. Check your network and try again.", + "oauthButton": "Sign in via Browser", + "oauthLoading": "Opening browser..." }, "legal": { "brandingLineSuffix": "", @@ -1769,7 +1772,7 @@ "probeRaw": "Probe Raw Response", "rawResponse": "Raw response" }, - "algorithmInfo": "Powered by the Qobuz\u2122 suggestion algorithm, curated from your local listening history.", + "algorithmInfo": "Powered by the Qobuz™ suggestion algorithm, curated from your local listening history.", "result": { "algorithm": "Algorithm", "count": "{count} tracks", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index c59c761a8..e897e61cd 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -1094,7 +1094,10 @@ "rememberMe": "Recordarme", "ineligibleSubscription": "No hay una suscripción activa. QBZ solo funcionará en modo offline.", "v2AuthFailed": "La autenticación con Qobuz falló. Por favor, inténtalo de nuevo.", - "v2NotInitialized": "No se pudo conectar con Qobuz. Por favor, reinicia la aplicación." + "v2NotInitialized": "No se pudo conectar con Qobuz. Por favor, reinicia la aplicación.", + "connectionFailed": "No se pudo conectar a Qobuz™. Verifica tu red e intenta de nuevo.", + "oauthButton": "Iniciar sesión en el navegador", + "oauthLoading": "Abriendo navegador..." }, "legal": { "brandingLineSuffix": "Esta aplicación usa la API de Qobuz™ pero no está certificada por Qobuz™.", @@ -1769,7 +1772,7 @@ "probeRaw": "Probar respuesta raw", "rawResponse": "Respuesta raw" }, - "algorithmInfo": "Impulsado por el algoritmo de sugerencias de Qobuz\u2122, curado a partir de tu historial de reproducciones local.", + "algorithmInfo": "Impulsado por el algoritmo de sugerencias de Qobuz™, curado a partir de tu historial de reproducciones local.", "result": { "algorithm": "Algoritmo", "count": "{count} pistas", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 493112174..2b4242658 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -1029,7 +1029,10 @@ "rememberMe": "Se souvenir de moi", "ineligibleSubscription": "Aucun abonnement actif. QBZ fonctionnera uniquement en mode hors ligne.", "v2AuthFailed": "L'authentification avec Qobuz a échoué. Veuillez réessayer.", - "v2NotInitialized": "Impossible de se connecter à Qobuz. Veuillez redémarrer l'application." + "v2NotInitialized": "Impossible de se connecter à Qobuz. Veuillez redémarrer l'application.", + "connectionFailed": "Connexion à Qobuz™ échouée. Vérifiez votre réseau et réessayez.", + "oauthButton": "Se connecter via le navigateur", + "oauthLoading": "Ouverture du navigateur..." }, "legal": { "brandingLineSuffix": "", @@ -1619,7 +1622,7 @@ "title": "DailyQ", "subtitle": "Base sur vos ecoutes recentes.", "cardDesc": "Sublimez votre journee avec une selection musicale personnalisee.", - "algorithmInfo": "Propulse par l'algorithme de suggestion Qobuz\u2122, compose a partir de votre historique d'ecoute local." + "algorithmInfo": "Propulse par l'algorithme de suggestion Qobuz™, compose a partir de votre historique d'ecoute local." }, "weeklyMixes": { "title": "WeeklyQ",