feat(login): add OAuth browser login button and fix ToS persistence race condition

This commit is contained in:
vicrodh committed 2026-02-23 14:45:34 -06:00
1 parent e5d34ccee7
commit 35ac015bb7
5 files changed
+152 -12

No files matched your search

+132 -4
View File
@@ -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<string | null>(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;
}
}
</script>
<div class="login-wrapper">
@@ -343,12 +412,28 @@
<div class="error-message">{error}</div>
{/if}
<button type="submit" class="login-btn" disabled={isLoading || !$qobuzTosAccepted}>
<button type="submit" class="login-btn" disabled={isLoading || isOAuthLoading || !$qobuzTosAccepted}>
{#if isLoading}
<div class="spinner small"></div>
<span>Signing in...</span>
<span>{$t('auth.loggingIn')}</span>
{:else}
<span>Sign in with Qobuz™</span>
<span>{$t('auth.loginButton')}</span>
{/if}
</button>
<div class="divider"><span>or</span></div>
<button
type="button"
class="oauth-btn"
disabled={isLoading || isOAuthLoading || !$qobuzTosAccepted}
onclick={handleOAuthLogin}
>
{#if isOAuthLoading}
<div class="spinner small"></div>
<span>{$t('auth.oauthLoading')}</span>
{:else}
<span>{$t('auth.oauthButton')}</span>
{/if}
</button>
@@ -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);
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",