OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs the client secret, so the browser never holds a Spotify token — it asks this instance what is playing and this instance calls Spotify. The callback arrives as a plain browser redirect with no Authorization header, so the OAuth state carries the user id signed with the instance secret and is compared in constant time; without that, anyone could bind their Spotify account to another user. Activities are now tracked per producer. pushActivities replaced the whole list, so the desktop game detector and Spotify would erase each other — losing exactly the case this is for, a game and Spotify at once. Polling backs off when the tab is hidden and keeps the last known track on a network error rather than reporting 'stopped listening'. A rejected refresh token (access revoked on Spotify's side) drops the row so the UI stops claiming a live connection. Scope is read-only: user-read-currently-playing and user-read-playback-state. Per the fork's language rule, the new UI ships in en and pt-BR, and this round also translates the privacy panel.
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { api } from '../../../api/client';
|
|
import { useT, type TranslationKey } from '../../../i18n';
|
|
|
|
/** Errors the OAuth callback can hand back in the URL. */
|
|
const CALLBACK_ERRORS = ['denied', 'invalid_state', 'exchange_failed'] as const;
|
|
type CallbackError = (typeof CALLBACK_ERRORS)[number];
|
|
|
|
function readCallbackResult(): CallbackError | 'connected' | null {
|
|
if (typeof window === 'undefined') return null;
|
|
const value = new URLSearchParams(window.location.search).get('spotify');
|
|
if (value === 'connected') return 'connected';
|
|
return CALLBACK_ERRORS.includes(value as CallbackError) ? (value as CallbackError) : null;
|
|
}
|
|
|
|
export function ConnectionsPanel() {
|
|
const t = useT();
|
|
const [configured, setConfigured] = useState(true);
|
|
const [connected, setConnected] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [callbackError, setCallbackError] = useState<CallbackError | null>(null);
|
|
|
|
useEffect(() => {
|
|
const result = readCallbackResult();
|
|
if (result && result !== 'connected') setCallbackError(result);
|
|
// Drop the parameter so a refresh does not replay the old outcome.
|
|
if (result && typeof window !== 'undefined') {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('spotify');
|
|
window.history.replaceState({}, '', url.toString());
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.spotify.status()
|
|
.then((s) => { if (!cancelled) { setConfigured(s.configured); setConnected(s.connected); } })
|
|
.catch(() => { /* leave the panel in its default state */ });
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const handleConnect = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const { url } = await api.spotify.authorizeUrl();
|
|
window.location.href = url;
|
|
} catch {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await api.spotify.disconnect();
|
|
setConnected(false);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('connections.title')}</h2>
|
|
|
|
<div className="rounded-lg bg-surface-elevated/40 p-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-accent-mint flex-shrink-0" aria-hidden="true">
|
|
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.586 14.424a.623.623 0 0 1-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 1 1-.277-1.215c3.809-.871 7.077-.496 9.712 1.115a.623.623 0 0 1 .207.857Zm1.223-2.722a.78.78 0 0 1-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 1 1-.452-1.492c3.632-1.102 8.147-.568 11.232 1.329a.78.78 0 0 1 .257 1.072Zm.105-2.835c-3.223-1.914-8.54-2.09-11.617-1.156a.935.935 0 1 1-.542-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 0 1-.956 1.608Z" />
|
|
</svg>
|
|
<span className="text-[15px] font-semibold text-txt-primary">Spotify</span>
|
|
{connected && (
|
|
<span className="text-[11px] px-1.5 py-0.5 rounded bg-status-online/15 text-status-online font-medium">
|
|
{t('connections.spotify.connected')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[13px] text-txt-secondary mt-1">{t('connections.spotify.description')}</p>
|
|
<p className="text-[12px] text-txt-tertiary mt-1">{t('connections.spotify.hint')}</p>
|
|
</div>
|
|
|
|
{configured && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void (connected ? handleDisconnect() : handleConnect())}
|
|
disabled={busy}
|
|
className={`px-3 py-1.5 rounded-md text-[13px] font-medium flex-shrink-0 transition-colors disabled:opacity-50 ${
|
|
connected
|
|
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
|
|
: 'bg-accent-primary text-white hover:brightness-110'
|
|
}`}
|
|
>
|
|
{connected ? t('connections.spotify.disconnect') : t('connections.spotify.connect')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!configured && (
|
|
<p className="text-[12px] text-txt-tertiary mt-3">{t('connections.spotify.notConfigured')}</p>
|
|
)}
|
|
{callbackError && (
|
|
<p className="text-[12px] text-txt-danger mt-3">
|
|
{t(`connections.spotify.error.${callbackError}` as TranslationKey)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|