Re-authenticate in place when the Perforce ticket expires
Perforce ends the ticket on its own schedule, so a long day outlives a login. Every command then failed and the only way back was Exit + restart, which threw away the scope, selection and open panels. - p4.ts routes all ~110 commands through one invoke wrapper that recognises an auth failure (isAuthError) and raises onAuthLost, so no call site can miss it. p4_login / p4_restore / p4_clients are exempt: they report bad credentials inline and would otherwise loop the prompt. - The server poll no longer treats an expired ticket as an outage. The server answered, it just refused us — the offline banner it used to raise never cleared, since polling stayed refused forever. - ReauthModal asks for the password over the running app, keeping all state, and offers Sign out as the way back to the Connect screen. The failed command is NOT retried automatically — re-auth then refresh only. Replaying a half-finished submit or revert unattended is worse than asking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -947,6 +947,11 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.dropinner svg{width:44px;height:44px}
|
||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||
|
||||
/* re-auth: who we're signing back in as */
|
||||
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
|
||||
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
|
||||
|
||||
/* toast */
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||
|
||||
73
src/App.tsx
73
src/App.tsx
@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import { useUpdater } from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, isAuthError, onAuthLost, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
||||
@ -408,6 +408,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [offline, setOffline] = useState(false); // lost connection to the server
|
||||
const [reauth, setReauth] = useState(false); // p4 ticket expired — prompt for the password
|
||||
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
|
||||
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
|
||||
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
||||
@ -674,6 +675,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
|
||||
})().catch(() => {});
|
||||
}, [info?.clientName]); // eslint-disable-line
|
||||
// any p4 command that comes back "not logged in" raises the re-auth prompt
|
||||
useEffect(() => { onAuthLost(() => setReauth(true)); return () => onAuthLost(null); }, []);
|
||||
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
|
||||
// live Perforce command log — the Rust backend emits one event per p4 call
|
||||
useEffect(() => {
|
||||
@ -796,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
checkBehind(); // a new submit landed → am I now behind?
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// an expired ticket is not an outage: the server answered, it just
|
||||
// refused us. The re-auth prompt handles it — don't also claim offline.
|
||||
if (isAuthError(e)) return;
|
||||
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
|
||||
}
|
||||
};
|
||||
@ -1804,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
|
||||
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||
{/* last in the tree so it sits over every other overlay — nothing else
|
||||
can succeed until the session is back */}
|
||||
{reauth && <ReauthModal session={session} onSignOut={onDisconnect}
|
||||
onDone={(i) => { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />}
|
||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
||||
@ -2406,6 +2416,65 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- re-authenticate (expired ticket) ----------------
|
||||
Perforce expires the login ticket on its own schedule, so a long day
|
||||
outlives it and every command starts failing. The server is still there and
|
||||
the workspace is untouched — only the ticket died — so we ask for the
|
||||
password over the running app instead of dropping back to the Connect
|
||||
screen, which would throw away the scope, selection and open panels.
|
||||
Deliberately not dismissable by clicking away: nothing works until it's
|
||||
resolved one way or the other. */
|
||||
function ReauthModal({ session, onDone, onSignOut }: {
|
||||
session: Session | null; onDone: (i: P4Info) => void; onSignOut: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => { ref.current?.focus(); }, []);
|
||||
|
||||
async function reconnect() {
|
||||
if (!session || !password || busy) return;
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const i = await p4.login(session.server, session.user, password, session.client);
|
||||
setPassword("");
|
||||
onDone(i);
|
||||
} catch (e) { setErr(String(e)); setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-back">
|
||||
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("Session expired")}</h3>
|
||||
<div className="prompt-label">
|
||||
{t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")}
|
||||
</div>
|
||||
<div className="reauth-who">
|
||||
<span>{session?.user}</span>
|
||||
<span>{session?.client}</span>
|
||||
<span>{session?.server}</span>
|
||||
</div>
|
||||
<div className="fld"><label>{t("Password")}</label>
|
||||
<div className="inp">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6" /></svg>
|
||||
<input ref={ref} type="password" value={password} disabled={busy}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && reconnect()} />
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||||
<div className="modal-actions">
|
||||
<button className="mbtn ghost" onClick={onSignOut} disabled={busy}>{t("Sign out")}</button>
|
||||
<button className="mbtn primary" onClick={reconnect} disabled={busy || !password}>
|
||||
{busy ? t("Connecting…") : t("Reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- confirm modal ---------------- */
|
||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@ -447,6 +447,11 @@ const D: Record<string, Tr> = {
|
||||
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
|
||||
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
|
||||
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
|
||||
// session expired → re-auth prompt
|
||||
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
|
||||
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
|
||||
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
|
||||
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
|
||||
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
|
||||
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
|
||||
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
|
||||
|
||||
28
src/p4.ts
28
src/p4.ts
@ -1,5 +1,31 @@
|
||||
// Typed wrappers around the Rust p4 commands.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as rawInvoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
|
||||
* as opposed to the server being unreachable. Perforce expires tickets on its
|
||||
* own schedule (often 12h), so a workday can outlive a login. */
|
||||
export function isAuthError(e: unknown): boolean {
|
||||
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
|
||||
.test(String(e));
|
||||
}
|
||||
|
||||
// Set by the app: called when any command comes back with an auth failure, so
|
||||
// the UI can ask for the password again instead of dead-ending on every action.
|
||||
let authLost: (() => void) | null = null;
|
||||
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
|
||||
|
||||
// Commands that legitimately report bad credentials — the login screen shows
|
||||
// those inline, and firing the re-auth prompt from them would loop.
|
||||
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
|
||||
|
||||
/** Every p4 command funnels through here so a lost ticket is caught in one
|
||||
* place rather than at each of the ~110 call sites. */
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return rawInvoke<T>(cmd, args).catch((e) => {
|
||||
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
export interface P4Info {
|
||||
serverAddress?: string;
|
||||
|
||||
Reference in New Issue
Block a user