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 }; }