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