feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 16:57:28 +08:00
committed by GitHub
parent 045ac250e0
commit 7015fb0153
35 changed files with 1477 additions and 112 deletions
@@ -14,7 +14,12 @@ import { PasswordInput } from "../ui/password-input";
import { Modal } from "../ui/modal";
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { refresh } = useAuth();
const { refresh, sessionVia } = useAuth();
// Desktop-established sessions set the password without the old one: the desktop seed
// password is random and never shown, so there is nothing to type. Keyed on the
// session's origin, not desktopMode — a browser signed into the same server holds a
// password session and must still prove the current password.
const desktopSession = sessionVia === "desktop";
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -32,7 +37,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
const submit = async () => {
const next: { old?: string; new?: string; confirm?: string } = {};
if (!oldPassword) next.old = S.common.requiredField;
if (!desktopSession && !oldPassword) next.old = S.common.requiredField;
if (!newPassword) next.new = S.common.requiredField;
if (!confirmPassword) next.confirm = S.common.requiredField;
if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch;
@@ -43,7 +48,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
setBusy(true);
setErrors({});
try {
await api.changePassword({ oldPassword, newPassword });
await api.changePassword(desktopSession ? { newPassword } : { oldPassword, newPassword });
await refresh();
onClose();
} catch (e) {
@@ -76,20 +81,22 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
}
>
<div className="space-y-3">
<PasswordInput
label={S.account.oldPassword}
required
size="sm"
value={oldPassword}
onChange={(e) => {
setOldPassword(e.target.value);
clearErrors();
}}
error={errors.old}
autoComplete="current-password"
hint={S.account.oldPasswordHint}
autoFocus
/>
{!desktopSession && (
<PasswordInput
label={S.account.oldPassword}
required
size="sm"
value={oldPassword}
onChange={(e) => {
setOldPassword(e.target.value);
clearErrors();
}}
error={errors.old}
autoComplete="current-password"
hint={S.account.oldPasswordHint}
autoFocus
/>
)}
<PasswordInput
label={S.account.newPassword}
required
@@ -102,6 +109,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
error={errors.new}
autoComplete="new-password"
hint={S.auth.passwordHint}
autoFocus={desktopSession}
/>
<PasswordInput
label={S.account.confirmPassword}
@@ -141,7 +141,7 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
}
export function AppLayout() {
const { user } = useAuth();
const { user, desktopMode } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
@@ -195,8 +195,9 @@ export function AppLayout() {
<span className="text-sm font-semibold">{S.appName}</span>
</header>
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change */}
{user?.passwordIsInitial && (
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change.
Hidden in desktop mode — the seed password there is random and never shown, so "change it" is meaningless nagging. */}
{user?.passwordIsInitial && !desktopMode && (
<div className="flex shrink-0 items-center justify-center gap-3 border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
<span>{S.account.initialPasswordBanner}</span>
<button
+62 -53
View File
@@ -192,7 +192,7 @@ export function Sidebar({
onCollapse?: () => void;
}) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { user, logout, desktopMode } = useAuth();
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
useTheme();
const { lang, locale, setLang } = useLocale();
@@ -1115,49 +1115,54 @@ export function Sidebar({
While checking, the label swaps to the busy text and the version stays put.
Nothing is fetched until the menu first opens; the version span appears once
/api/version resolves. */}
<button
type="button"
disabled={updateChecking}
onClick={() => {
if (newVersion !== null) {
setUserOpen(false);
setUpdateDialogOpen(true);
} else {
void runUpdateCheck();
}
}}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-2">
{updateChecking && (
<span
aria-hidden
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
/>
)}
{!updateChecking && newVersion !== null && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
/>
)}
<span className="min-w-0 truncate">
{updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
{/* Hidden in desktop mode: updates are the desktop app's job (electron-updater),
and the dialog's admin self-update re-runs the CLI entry, which does not
exist under the desktop shell. */}
{!desktopMode && (
<button
type="button"
disabled={updateChecking}
onClick={() => {
if (newVersion !== null) {
setUserOpen(false);
setUpdateDialogOpen(true);
} else {
void runUpdateCheck();
}
}}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-2">
{updateChecking && (
<span
aria-hidden
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
/>
)}
{!updateChecking && newVersion !== null && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
/>
)}
<span className="min-w-0 truncate">
{updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
</span>
</span>
</span>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
</span>
)}
</button>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
</span>
)}
</button>
)}
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
<button
@@ -1171,16 +1176,20 @@ export function Sidebar({
{S.admin.users}
</button>
)}
<button
type="button"
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
onClick={() => {
setUserOpen(false);
void logout().then(() => navigate("/login"));
}}
>
{S.auth.logout}
</button>
{/* Hidden in desktop mode: the window IS the session — logging out would
strand the user on a login page whose password was never shown. */}
{!desktopMode && (
<button
type="button"
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
onClick={() => {
setUserOpen(false);
void logout().then(() => navigate("/login"));
}}
>
{S.auth.logout}
</button>
)}
</div>
</Dropdown>
</div>
+23 -1
View File
@@ -20,6 +20,18 @@ interface AuthContextValue {
* /api/me because it depends on the host the browser is using.
*/
previewIsolated: boolean;
/**
* Whether the server runs in desktop mode (spawned by the desktop shell). The UI then
* hides the logout entry, the initial-password banner and the self-update entry — the
* desktop app manages sign-in and updates itself.
*/
desktopMode: boolean;
/**
* How THIS session was established. A browser signed into a desktop-mode server holds
* a "password" session; only "desktop" sessions (the shell's window) may change the
* password without the old one.
*/
sessionVia: "password" | "desktop";
login: (userId: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */
@@ -33,6 +45,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Assume isolated until told otherwise: the warning is the exceptional state, and
// flashing it during initialization would be noise.
const [previewIsolated, setPreviewIsolated] = useState(true);
const [desktopMode, setDesktopMode] = useState(false);
const [sessionVia, setSessionVia] = useState<"password" | "desktop">("password");
// Any API returning 401 (session expired / database rebuilt) clears the current user, and
// RequireAuth redirects back to the login page.
@@ -51,6 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (cancelled) return;
setUser(res.user);
setPreviewIsolated(res.previewIsolated);
setDesktopMode(res.desktopMode);
setSessionVia(res.sessionVia);
})
.catch((err: unknown) => {
if (cancelled) return;
@@ -76,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const me = await api.getMe();
setUser(me.user);
setPreviewIsolated(me.previewIsolated);
setDesktopMode(me.desktopMode);
setSessionVia(me.sessionVia);
} catch {
// Login itself succeeded; keep the optimistic default.
}
@@ -93,10 +111,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const res = await api.getMe();
setUser(res.user);
setPreviewIsolated(res.previewIsolated);
setDesktopMode(res.desktopMode);
setSessionVia(res.sessionVia);
}, []);
return (
<AuthContext.Provider value={{ user, previewIsolated, login, logout, refresh }}>
<AuthContext.Provider
value={{ user, previewIsolated, desktopMode, sessionVia, login, logout, refresh }}
>
{children}
</AuthContext.Provider>
);