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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user