From 9d9a2d48cd7b2faa05038b73fcc1f4f6467eae01 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 07:24:08 +0000 Subject: [PATCH] fix(ui): recover from a dead session instead of stranding the operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token the server no longer accepts (expired, revoked, or issued for a project the show is no longer running) left the artist UI retrying forever behind a red dot with no way out — the diagnosis said "sign in again" but there was nothing to sign in with. The dead token is now dropped and the login screen comes back, saying why it appeared and prefilling the username that was last signed in here. --- packages/ui/src/app.tsx | 22 ++++++++++++++++++-- packages/ui/src/components/login-screen.tsx | 15 ++++++++++++-- packages/ui/src/lib/use-auth.ts | 23 +++++++++++++++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index b2656fe..19a0e64 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -444,7 +444,7 @@ function MasterSliders({ export default function Home() { const [configRev, setConfigRev] = useState(0); const config = useConfig(configRev); - const { user, token, checked, login } = useAuth(); + const { user, token, checked, endedSession, lastUser, login, sessionEnded } = useAuth(); const { connection, grid, orientation, playlistState, settings, send } = useSocket( config?.simulatorUrl ?? null, token, @@ -490,6 +490,14 @@ export default function Home() { }); }, []); + // A token the server no longer accepts can only reconnect into the same + // error, so stop retrying against it and hand the operator the login screen. + useEffect(() => { + if (token && connection.state === 'down' && connection.cause === 'sessionExpired') { + sessionEnded(); + } + }, [token, connection.state, connection.cause, sessionEnded]); + // Sync slider state from server on initial connect const settingsSyncedRef = useRef(false); useEffect(() => { @@ -773,7 +781,17 @@ export default function Home() { } if (!user) { - return ; + return ( + + ); } /* ---------- PHONE LAYOUT ---------- */ diff --git a/packages/ui/src/components/login-screen.tsx b/packages/ui/src/components/login-screen.tsx index 3191dc3..08d528b 100644 --- a/packages/ui/src/components/login-screen.tsx +++ b/packages/ui/src/components/login-screen.tsx @@ -2,10 +2,14 @@ import { FormEvent, useState } from 'react'; interface LoginScreenProps { onLogin: (username: string, token: string) => void; + /** Why this screen appeared, when it wasn't the operator's own sign-out. */ + notice?: string; + /** Prefilled username — whoever was signed in on this device last. */ + defaultUsername?: string; } -export function LoginScreen({ onLogin }: LoginScreenProps) { - const [username, setUsername] = useState(''); +export function LoginScreen({ onLogin, notice, defaultUsername = '' }: LoginScreenProps) { + const [username, setUsername] = useState(defaultUsername); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -59,6 +63,13 @@ export function LoginScreen({ onLogin }: LoginScreenProps) {

+ {/* Why we're back here (session expired, revoked, or project switched) */} + {notice && ( +

+ {notice} +

+ )} + {/* Username */} (null); const [token, setToken] = useState(null); const [checked, setChecked] = useState(false); + /** Set when a signed-in session stopped being valid, so the login screen can + * say why it is showing rather than looking like a random logout. */ + const [endedSession, setEndedSession] = useState(false); useEffect(() => { const stored = localStorage.getItem('wg_token'); @@ -36,17 +43,29 @@ export function useAuth() { const login = useCallback((username: string, jwt: string) => { localStorage.setItem('wg_token', jwt); + localStorage.setItem(LAST_USER_KEY, username); localStorage.removeItem('wg_user'); setToken(jwt); setUser(username); + setEndedSession(false); }, []); - const logout = useCallback(() => { + const clear = useCallback((ended: boolean) => { localStorage.removeItem('wg_token'); localStorage.removeItem('wg_user'); setToken(null); setUser(null); + setEndedSession(ended); }, []); - return { user, token, checked, login, logout }; + const logout = useCallback(() => clear(false), [clear]); + + /** Drop a token the server no longer accepts (expired, revoked, or issued for + * another project) and fall back to the login screen — a dead token can only + * ever reconnect into the same error. */ + const sessionEnded = useCallback(() => clear(true), [clear]); + + const lastUser = typeof window === 'undefined' ? '' : localStorage.getItem(LAST_USER_KEY) ?? ''; + + return { user, token, checked, endedSession, lastUser, login, logout, sessionEnded }; }