feat(security): add logout button to Control UI and /logout command to TUI

- Control UI: Added logout button in topbar that clears stored credentials (token/password) from localStorage
- TUI: Added /logout command that disconnects from gateway and exits securely
- Added clearCredentials() function in storage.ts for credential cleanup
- Security improvement: Users can now explicitly revoke stored authentication

[AI-assisted]
This commit is contained in:
Nattapong Tapachoom 2026-01-28 09:34:57 +07:00
parent 8a2878f67f
commit 0c17409fb1
7 changed files with 594 additions and 489 deletions

View File

@ -115,6 +115,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
{ name: "new", description: "Reset the session" }, { name: "new", description: "Reset the session" },
{ name: "reset", description: "Reset the session" }, { name: "reset", description: "Reset the session" },
{ name: "settings", description: "Open settings" }, { name: "settings", description: "Open settings" },
{ name: "logout", description: "Disconnect and exit securely" },
{ name: "exit", description: "Exit the TUI" }, { name: "exit", description: "Exit the TUI" },
{ name: "quit", description: "Exit the TUI" }, { name: "quit", description: "Exit the TUI" },
]; ];
@ -154,6 +155,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/new or /reset", "/new or /reset",
"/abort", "/abort",
"/settings", "/settings",
"/logout",
"/exit", "/exit",
].join("\n"); ].join("\n");
} }

View File

@ -427,6 +427,14 @@ export function createCommandHandlers(context: CommandHandlerContext) {
case "settings": case "settings":
openSettings(); openSettings();
break; break;
case "logout":
chatLog.addSystem("Logging out... Disconnecting from gateway.");
tui.requestRender();
client.stop();
tui.stop();
console.log("\nLogged out successfully.");
process.exit(0);
break;
case "exit": case "exit":
case "quit": case "quit":
client.stop(); client.stop();

View File

@ -32,7 +32,9 @@ export function renderTab(state: AppViewState, tab: Tab) {
}} }}
title=${titleForTab(tab)} title=${titleForTab(tab)}
> >
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span> <span class="nav-item__icon" aria-hidden="true"
>${icons[iconForTab(tab)]}</span
>
<span class="nav-item__text">${titleForTab(tab)}</span> <span class="nav-item__text">${titleForTab(tab)}</span>
</a> </a>
`; `;
@ -45,8 +47,39 @@ export function renderChatControls(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode; const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon // Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`; const refreshIcon = html`
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`; <svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
return html` return html`
<div class="chat-controls"> <div class="chat-controls">
<label class="field chat-controls__session"> <label class="field chat-controls__session">
@ -105,9 +138,11 @@ export function renderChatControls(state: AppViewState) {
}); });
}} }}
aria-pressed=${showThinking} aria-pressed=${showThinking}
title=${disableThinkingToggle title=${
? "Disabled during onboarding" disableThinkingToggle
: "Toggle assistant thinking/working output"} ? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
}
> >
${icons.brain} ${icons.brain}
</button> </button>
@ -122,9 +157,11 @@ export function renderChatControls(state: AppViewState) {
}); });
}} }}
aria-pressed=${focusActive} aria-pressed=${focusActive}
title=${disableFocusToggle title=${
? "Disabled during onboarding" disableFocusToggle
: "Toggle focus mode (hide sidebar + page header)"} ? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
}
> >
${focusIcon} ${focusIcon}
</button> </button>
@ -240,3 +277,41 @@ function renderMonitorIcon() {
</svg> </svg>
`; `;
} }
/**
* Renders the logout button for security logout.
* Clears stored credentials and disconnects from gateway.
*/
export function renderLogoutButton(state: AppViewState) {
const logoutIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
`;
return html`
<button
class="btn btn--sm btn--icon btn--logout"
@click=${() => {
if (confirm("Are you sure you want to logout? This will clear your stored credentials.")) {
state.handleLogout();
}
}}
title="Logout (clear stored credentials)"
aria-label="Logout"
>
${logoutIcon}
</button>
`;
}

View File

@ -50,7 +50,12 @@ import {
rotateDeviceToken, rotateDeviceToken,
} from "./controllers/devices"; } from "./controllers/devices";
import { renderSkills } from "./views/skills"; import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import {
renderChatControls,
renderLogoutButton,
renderTab,
renderThemeToggle,
} from "./app-render.helpers";
import { loadChannels } from "./controllers/channels"; import { loadChannels } from "./controllers/channels";
import { loadPresence } from "./controllers/presence"; import { loadPresence } from "./controllers/presence";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
@ -78,7 +83,13 @@ import {
saveExecApprovals, saveExecApprovals,
updateExecApprovalsFormValue, updateExecApprovalsFormValue,
} from "./controllers/exec-approvals"; } from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import {
loadCronRuns,
toggleCronJob,
runCronJob,
removeCronJob,
addCronJob,
} from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
@ -88,10 +99,7 @@ const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? []; const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey); const parsed = parseAgentSessionKey(state.sessionKey);
const agentId = const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
parsed?.agentId ??
state.agentsList?.defaultId ??
"main";
const agent = list.find((entry) => entry.id === agentId); const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity; const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar; const candidate = identity?.avatarUrl ?? identity?.avatar;
@ -112,7 +120,11 @@ export function renderApp(state: AppViewState) {
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
return html` return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"> <div
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${
state.settings.navCollapsed ? "shell--nav-collapsed" : ""
} ${state.onboarding ? "shell--onboarding" : ""}"
>
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<button <button
@ -129,7 +141,10 @@ export function renderApp(state: AppViewState) {
</button> </button>
<div class="brand"> <div class="brand">
<div class="brand-logo"> <div class="brand-logo">
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="Moltbot" /> <img
src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26"
alt="Moltbot"
/>
</div> </div>
<div class="brand-text"> <div class="brand-text">
<div class="brand-title">MOLTBOT</div> <div class="brand-title">MOLTBOT</div>
@ -143,7 +158,7 @@ export function renderApp(state: AppViewState) {
<span>Health</span> <span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span> <span class="mono">${state.connected ? "OK" : "Offline"}</span>
</div> </div>
${renderThemeToggle(state)} ${renderThemeToggle(state)} ${renderLogoutButton(state)}
</div> </div>
</header> </header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
@ -151,7 +166,9 @@ export function renderApp(state: AppViewState) {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false; const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab); const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html` return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}"> <div
class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}"
>
<button <button
class="nav-label" class="nav-label"
@click=${() => { @click=${() => {
@ -165,7 +182,9 @@ export function renderApp(state: AppViewState) {
aria-expanded=${!isGroupCollapsed} aria-expanded=${!isGroupCollapsed}
> >
<span class="nav-label__text">${group.label}</span> <span class="nav-label__text">${group.label}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span> <span class="nav-label__chevron"
>${isGroupCollapsed ? "+" : ""}</span
>
</button> </button>
<div class="nav-group__items"> <div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))} ${group.tabs.map((tab) => renderTab(state, tab))}
@ -185,7 +204,9 @@ export function renderApp(state: AppViewState) {
rel="noreferrer" rel="noreferrer"
title="Docs (opens in new tab)" title="Docs (opens in new tab)"
> >
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> <span class="nav-item__icon" aria-hidden="true"
>${icons.book}</span
>
<span class="nav-item__text">Docs</span> <span class="nav-item__text">Docs</span>
</a> </a>
</div> </div>
@ -198,384 +219,402 @@ export function renderApp(state: AppViewState) {
<div class="page-sub">${subtitleForTab(state.tab)}</div> <div class="page-sub">${subtitleForTab(state.tab)}</div>
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
</section> </section>
${state.tab === "overview" ${
? renderOverview({ state.tab === "overview"
connected: state.connected, ? renderOverview({
hello: state.hello, connected: state.connected,
settings: state.settings, hello: state.hello,
password: state.password, settings: state.settings,
lastError: state.lastError, password: state.password,
presenceCount, lastError: state.lastError,
sessionsCount, presenceCount,
cronEnabled: state.cronStatus?.enabled ?? null, sessionsCount,
cronNext, cronEnabled: state.cronStatus?.enabled ?? null,
lastChannelsRefresh: state.channelsLastSuccess, cronNext,
onSettingsChange: (next) => state.applySettings(next), lastChannelsRefresh: state.channelsLastSuccess,
onPasswordChange: (next) => (state.password = next), onSettingsChange: (next) => state.applySettings(next),
onSessionKeyChange: (next) => { onPasswordChange: (next) => (state.password = next),
state.sessionKey = next; onSessionKeyChange: (next) => {
state.chatMessage = ""; state.sessionKey = next;
state.resetToolStream(); state.chatMessage = "";
state.applySettings({ state.resetToolStream();
...state.settings, state.applySettings({
sessionKey: next, ...state.settings,
lastActiveSessionKey: next, sessionKey: next,
}); lastActiveSessionKey: next,
void state.loadAssistantIdentity(); });
}, void state.loadAssistantIdentity();
onConnect: () => state.connect(), },
onRefresh: () => state.loadOverview(), onConnect: () => state.connect(),
}) onRefresh: () => state.loadOverview(),
: nothing} })
: nothing
${state.tab === "channels" }
? renderChannels({ ${
connected: state.connected, state.tab === "channels"
loading: state.channelsLoading, ? renderChannels({
snapshot: state.channelsSnapshot, connected: state.connected,
lastError: state.channelsError, loading: state.channelsLoading,
lastSuccessAt: state.channelsLastSuccess, snapshot: state.channelsSnapshot,
whatsappMessage: state.whatsappLoginMessage, lastError: state.channelsError,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, lastSuccessAt: state.channelsLastSuccess,
whatsappConnected: state.whatsappLoginConnected, whatsappMessage: state.whatsappLoginMessage,
whatsappBusy: state.whatsappBusy, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
configSchema: state.configSchema, whatsappConnected: state.whatsappLoginConnected,
configSchemaLoading: state.configSchemaLoading, whatsappBusy: state.whatsappBusy,
configForm: state.configForm, configSchema: state.configSchema,
configUiHints: state.configUiHints, configSchemaLoading: state.configSchemaLoading,
configSaving: state.configSaving, configForm: state.configForm,
configFormDirty: state.configFormDirty, configUiHints: state.configUiHints,
nostrProfileFormState: state.nostrProfileFormState, configSaving: state.configSaving,
nostrProfileAccountId: state.nostrProfileAccountId, configFormDirty: state.configFormDirty,
onRefresh: (probe) => loadChannels(state, probe), nostrProfileFormState: state.nostrProfileFormState,
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), nostrProfileAccountId: state.nostrProfileAccountId,
onWhatsAppWait: () => state.handleWhatsAppWait(), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onWhatsAppWait: () => state.handleWhatsAppWait(),
onConfigSave: () => state.handleChannelConfigSave(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigReload: () => state.handleChannelConfigReload(), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onNostrProfileEdit: (accountId, profile) => onConfigSave: () => state.handleChannelConfigSave(),
state.handleNostrProfileEdit(accountId, profile), onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileCancel: () => state.handleNostrProfileCancel(), onNostrProfileEdit: (accountId, profile) =>
onNostrProfileFieldChange: (field, value) => state.handleNostrProfileEdit(accountId, profile),
state.handleNostrProfileFieldChange(field, value), onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileSave: () => state.handleNostrProfileSave(), onNostrProfileFieldChange: (field, value) =>
onNostrProfileImport: () => state.handleNostrProfileImport(), state.handleNostrProfileFieldChange(field, value),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), onNostrProfileSave: () => state.handleNostrProfileSave(),
}) onNostrProfileImport: () => state.handleNostrProfileImport(),
: nothing} onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
${state.tab === "instances" : nothing
? renderInstances({ }
loading: state.presenceLoading, ${
entries: state.presenceEntries, state.tab === "instances"
lastError: state.presenceError, ? renderInstances({
statusMessage: state.presenceStatus, loading: state.presenceLoading,
onRefresh: () => loadPresence(state), entries: state.presenceEntries,
}) lastError: state.presenceError,
: nothing} statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
${state.tab === "sessions" })
? renderSessions({ : nothing
loading: state.sessionsLoading, }
result: state.sessionsResult, ${
error: state.sessionsError, state.tab === "sessions"
activeMinutes: state.sessionsFilterActive, ? renderSessions({
limit: state.sessionsFilterLimit, loading: state.sessionsLoading,
includeGlobal: state.sessionsIncludeGlobal, result: state.sessionsResult,
includeUnknown: state.sessionsIncludeUnknown, error: state.sessionsError,
basePath: state.basePath, activeMinutes: state.sessionsFilterActive,
onFiltersChange: (next) => { limit: state.sessionsFilterLimit,
state.sessionsFilterActive = next.activeMinutes; includeGlobal: state.sessionsIncludeGlobal,
state.sessionsFilterLimit = next.limit; includeUnknown: state.sessionsIncludeUnknown,
state.sessionsIncludeGlobal = next.includeGlobal; basePath: state.basePath,
state.sessionsIncludeUnknown = next.includeUnknown; onFiltersChange: (next) => {
}, state.sessionsFilterActive = next.activeMinutes;
onRefresh: () => loadSessions(state), state.sessionsFilterLimit = next.limit;
onPatch: (key, patch) => patchSession(state, key, patch), state.sessionsIncludeGlobal = next.includeGlobal;
onDelete: (key) => deleteSession(state, key), state.sessionsIncludeUnknown = next.includeUnknown;
}) },
: nothing} onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
${state.tab === "cron" onDelete: (key) => deleteSession(state, key),
? renderCron({ })
loading: state.cronLoading, : nothing
status: state.cronStatus, }
jobs: state.cronJobs, ${
error: state.cronError, state.tab === "cron"
busy: state.cronBusy, ? renderCron({
form: state.cronForm, loading: state.cronLoading,
channels: state.channelsSnapshot?.channelMeta?.length status: state.cronStatus,
? state.channelsSnapshot.channelMeta.map((entry) => entry.id) jobs: state.cronJobs,
: state.channelsSnapshot?.channelOrder ?? [], error: state.cronError,
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, busy: state.cronBusy,
channelMeta: state.channelsSnapshot?.channelMeta ?? [], form: state.cronForm,
runsJobId: state.cronRunsJobId, channels: state.channelsSnapshot?.channelMeta?.length
runs: state.cronRuns, ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), : (state.channelsSnapshot?.channelOrder ?? []),
onRefresh: () => state.loadCron(), channelLabels: state.channelsSnapshot?.channelLabels ?? {},
onAdd: () => addCronJob(state), channelMeta: state.channelsSnapshot?.channelMeta ?? [],
onToggle: (job, enabled) => toggleCronJob(state, job, enabled), runsJobId: state.cronRunsJobId,
onRun: (job) => runCronJob(state, job), runs: state.cronRuns,
onRemove: (job) => removeCronJob(state, job), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onLoadRuns: (jobId) => loadCronRuns(state, jobId), onRefresh: () => state.loadCron(),
}) onAdd: () => addCronJob(state),
: nothing} onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
${state.tab === "skills" onRemove: (job) => removeCronJob(state, job),
? renderSkills({ onLoadRuns: (jobId) => loadCronRuns(state, jobId),
loading: state.skillsLoading, })
report: state.skillsReport, : nothing
error: state.skillsError, }
filter: state.skillsFilter, ${
edits: state.skillEdits, state.tab === "skills"
messages: state.skillMessages, ? renderSkills({
busyKey: state.skillsBusyKey, loading: state.skillsLoading,
onFilterChange: (next) => (state.skillsFilter = next), report: state.skillsReport,
onRefresh: () => loadSkills(state, { clearMessages: true }), error: state.skillsError,
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), filter: state.skillsFilter,
onEdit: (key, value) => updateSkillEdit(state, key, value), edits: state.skillEdits,
onSaveKey: (key) => saveSkillApiKey(state, key), messages: state.skillMessages,
onInstall: (skillKey, name, installId) => busyKey: state.skillsBusyKey,
installSkill(state, skillKey, name, installId), onFilterChange: (next) => (state.skillsFilter = next),
}) onRefresh: () => loadSkills(state, { clearMessages: true }),
: nothing} onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
${state.tab === "nodes" onSaveKey: (key) => saveSkillApiKey(state, key),
? renderNodes({ onInstall: (skillKey, name, installId) =>
loading: state.nodesLoading, installSkill(state, skillKey, name, installId),
nodes: state.nodes, })
devicesLoading: state.devicesLoading, : nothing
devicesError: state.devicesError, }
devicesList: state.devicesList, ${
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null), state.tab === "nodes"
configLoading: state.configLoading, ? renderNodes({
configSaving: state.configSaving, loading: state.nodesLoading,
configDirty: state.configFormDirty, nodes: state.nodes,
configFormMode: state.configFormMode, devicesLoading: state.devicesLoading,
execApprovalsLoading: state.execApprovalsLoading, devicesError: state.devicesError,
execApprovalsSaving: state.execApprovalsSaving, devicesList: state.devicesList,
execApprovalsDirty: state.execApprovalsDirty, configForm:
execApprovalsSnapshot: state.execApprovalsSnapshot, state.configForm ??
execApprovalsForm: state.execApprovalsForm, (state.configSnapshot?.config as Record<string, unknown> | null),
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, configLoading: state.configLoading,
execApprovalsTarget: state.execApprovalsTarget, configSaving: state.configSaving,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, configDirty: state.configFormDirty,
onRefresh: () => loadNodes(state), configFormMode: state.configFormMode,
onDevicesRefresh: () => loadDevices(state), execApprovalsLoading: state.execApprovalsLoading,
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), execApprovalsSaving: state.execApprovalsSaving,
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), execApprovalsDirty: state.execApprovalsDirty,
onDeviceRotate: (deviceId, role, scopes) => execApprovalsSnapshot: state.execApprovalsSnapshot,
rotateDeviceToken(state, { deviceId, role, scopes }), execApprovalsForm: state.execApprovalsForm,
onDeviceRevoke: (deviceId, role) => execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
revokeDeviceToken(state, { deviceId, role }), execApprovalsTarget: state.execApprovalsTarget,
onLoadConfig: () => loadConfig(state), execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onLoadExecApprovals: () => { onRefresh: () => loadNodes(state),
const target = onDevicesRefresh: () => loadDevices(state),
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
: { kind: "gateway" as const }; onDeviceRotate: (deviceId, role, scopes) =>
return loadExecApprovals(state, target); rotateDeviceToken(state, { deviceId, role, scopes }),
}, onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onBindDefault: (nodeId) => { onLoadConfig: () => loadConfig(state),
if (nodeId) { onLoadExecApprovals: () => {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); const target =
} else { state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
removeConfigFormValue(state, ["tools", "exec", "node"]); ? {
} kind: "node" as const,
}, nodeId: state.execApprovalsTargetNodeId,
onBindAgent: (agentIndex, nodeId) => { }
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; : { kind: "gateway" as const };
if (nodeId) { return loadExecApprovals(state, target);
updateConfigFormValue(state, basePath, nodeId); },
} else { onBindDefault: (nodeId) => {
removeConfigFormValue(state, basePath); if (nodeId) {
} updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
}, } else {
onSaveBindings: () => saveConfig(state), removeConfigFormValue(state, ["tools", "exec", "node"]);
onExecApprovalsTargetChange: (kind, nodeId) => { }
state.execApprovalsTarget = kind; },
state.execApprovalsTargetNodeId = nodeId; onBindAgent: (agentIndex, nodeId) => {
state.execApprovalsSnapshot = null; const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
state.execApprovalsForm = null; if (nodeId) {
state.execApprovalsDirty = false; updateConfigFormValue(state, basePath, nodeId);
state.execApprovalsSelectedAgent = null; } else {
}, removeConfigFormValue(state, basePath);
onExecApprovalsSelectAgent: (agentId) => { }
state.execApprovalsSelectedAgent = agentId; },
}, onSaveBindings: () => saveConfig(state),
onExecApprovalsPatch: (path, value) => onExecApprovalsTargetChange: (kind, nodeId) => {
updateExecApprovalsFormValue(state, path, value), state.execApprovalsTarget = kind;
onExecApprovalsRemove: (path) => state.execApprovalsTargetNodeId = nodeId;
removeExecApprovalsFormValue(state, path), state.execApprovalsSnapshot = null;
onSaveExecApprovals: () => { state.execApprovalsForm = null;
const target = state.execApprovalsDirty = false;
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsSelectedAgent = null;
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } },
: { kind: "gateway" as const }; onExecApprovalsSelectAgent: (agentId) => {
return saveExecApprovals(state, target); state.execApprovalsSelectedAgent = agentId;
}, },
}) onExecApprovalsPatch: (path, value) =>
: nothing} updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
${state.tab === "chat" onSaveExecApprovals: () => {
? renderChat({ const target =
sessionKey: state.sessionKey, state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
onSessionKeyChange: (next) => { ? {
state.sessionKey = next; kind: "node" as const,
state.chatMessage = ""; nodeId: state.execApprovalsTargetNodeId,
state.chatAttachments = []; }
state.chatStream = null; : { kind: "gateway" as const };
state.chatStreamStartedAt = null; return saveExecApprovals(state, target);
state.chatRunId = null; },
state.chatQueue = []; })
state.resetToolStream(); : nothing
state.resetChatScroll(); }
state.applySettings({ ${
...state.settings, state.tab === "chat"
sessionKey: next, ? renderChat({
lastActiveSessionKey: next, sessionKey: state.sessionKey,
}); onSessionKeyChange: (next) => {
void state.loadAssistantIdentity(); state.sessionKey = next;
void loadChatHistory(state); state.chatMessage = "";
void refreshChatAvatar(state); state.chatAttachments = [];
}, state.chatStream = null;
thinkingLevel: state.chatThinkingLevel, state.chatStreamStartedAt = null;
showThinking, state.chatRunId = null;
loading: state.chatLoading, state.chatQueue = [];
sending: state.chatSending, state.resetToolStream();
compactionStatus: state.compactionStatus, state.resetChatScroll();
assistantAvatarUrl: chatAvatarUrl, state.applySettings({
messages: state.chatMessages, ...state.settings,
toolMessages: state.chatToolMessages, sessionKey: next,
stream: state.chatStream, lastActiveSessionKey: next,
streamStartedAt: state.chatStreamStartedAt, });
draft: state.chatMessage, void state.loadAssistantIdentity();
queue: state.chatQueue, void loadChatHistory(state);
connected: state.connected, void refreshChatAvatar(state);
canSend: state.connected, },
disabledReason: chatDisabledReason, thinkingLevel: state.chatThinkingLevel,
error: state.lastError, showThinking,
sessions: state.sessionsResult, loading: state.chatLoading,
focusMode: chatFocus, sending: state.chatSending,
onRefresh: () => { compactionStatus: state.compactionStatus,
state.resetToolStream(); assistantAvatarUrl: chatAvatarUrl,
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); messages: state.chatMessages,
}, toolMessages: state.chatToolMessages,
onToggleFocusMode: () => { stream: state.chatStream,
if (state.onboarding) return; streamStartedAt: state.chatStreamStartedAt,
state.applySettings({ draft: state.chatMessage,
...state.settings, queue: state.chatQueue,
chatFocusMode: !state.settings.chatFocusMode, connected: state.connected,
}); canSend: state.connected,
}, disabledReason: chatDisabledReason,
onChatScroll: (event) => state.handleChatScroll(event), error: state.lastError,
onDraftChange: (next) => (state.chatMessage = next), sessions: state.sessionsResult,
attachments: state.chatAttachments, focusMode: chatFocus,
onAttachmentsChange: (next) => (state.chatAttachments = next), onRefresh: () => {
onSend: () => state.handleSendChat(), state.resetToolStream();
canAbort: Boolean(state.chatRunId), return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
onAbort: () => void state.handleAbortChat(), },
onQueueRemove: (id) => state.removeQueuedMessage(id), onToggleFocusMode: () => {
onNewSession: () => if (state.onboarding) return;
state.handleSendChat("/new", { restoreDraft: true }), state.applySettings({
// Sidebar props for tool output viewing ...state.settings,
sidebarOpen: state.sidebarOpen, chatFocusMode: !state.settings.chatFocusMode,
sidebarContent: state.sidebarContent, });
sidebarError: state.sidebarError, },
splitRatio: state.splitRatio, onChatScroll: (event) => state.handleChatScroll(event),
onOpenSidebar: (content: string) => state.handleOpenSidebar(content), onDraftChange: (next) => (state.chatMessage = next),
onCloseSidebar: () => state.handleCloseSidebar(), attachments: state.chatAttachments,
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onAttachmentsChange: (next) => (state.chatAttachments = next),
assistantName: state.assistantName, onSend: () => state.handleSendChat(),
assistantAvatar: state.assistantAvatar, canAbort: Boolean(state.chatRunId),
}) onAbort: () => void state.handleAbortChat(),
: nothing} onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
${state.tab === "config" // Sidebar props for tool output viewing
? renderConfig({ sidebarOpen: state.sidebarOpen,
raw: state.configRaw, sidebarContent: state.sidebarContent,
originalRaw: state.configRawOriginal, sidebarError: state.sidebarError,
valid: state.configValid, splitRatio: state.splitRatio,
issues: state.configIssues, onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
loading: state.configLoading, onCloseSidebar: () => state.handleCloseSidebar(),
saving: state.configSaving, onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
applying: state.configApplying, assistantName: state.assistantName,
updating: state.updateRunning, assistantAvatar: state.assistantAvatar,
connected: state.connected, })
schema: state.configSchema, : nothing
schemaLoading: state.configSchemaLoading, }
uiHints: state.configUiHints, ${
formMode: state.configFormMode, state.tab === "config"
formValue: state.configForm, ? renderConfig({
originalValue: state.configFormOriginal, raw: state.configRaw,
searchQuery: state.configSearchQuery, originalRaw: state.configRawOriginal,
activeSection: state.configActiveSection, valid: state.configValid,
activeSubsection: state.configActiveSubsection, issues: state.configIssues,
onRawChange: (next) => { loading: state.configLoading,
state.configRaw = next; saving: state.configSaving,
}, applying: state.configApplying,
onFormModeChange: (mode) => (state.configFormMode = mode), updating: state.updateRunning,
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), connected: state.connected,
onSearchChange: (query) => (state.configSearchQuery = query), schema: state.configSchema,
onSectionChange: (section) => { schemaLoading: state.configSchemaLoading,
state.configActiveSection = section; uiHints: state.configUiHints,
state.configActiveSubsection = null; formMode: state.configFormMode,
}, formValue: state.configForm,
onSubsectionChange: (section) => (state.configActiveSubsection = section), originalValue: state.configFormOriginal,
onReload: () => loadConfig(state), searchQuery: state.configSearchQuery,
onSave: () => saveConfig(state), activeSection: state.configActiveSection,
onApply: () => applyConfig(state), activeSubsection: state.configActiveSubsection,
onUpdate: () => runUpdate(state), onRawChange: (next) => {
}) state.configRaw = next;
: nothing} },
onFormModeChange: (mode) => (state.configFormMode = mode),
${state.tab === "debug" onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
? renderDebug({ onSearchChange: (query) => (state.configSearchQuery = query),
loading: state.debugLoading, onSectionChange: (section) => {
status: state.debugStatus, state.configActiveSection = section;
health: state.debugHealth, state.configActiveSubsection = null;
models: state.debugModels, },
heartbeat: state.debugHeartbeat, onSubsectionChange: (section) => (state.configActiveSubsection = section),
eventLog: state.eventLog, onReload: () => loadConfig(state),
callMethod: state.debugCallMethod, onSave: () => saveConfig(state),
callParams: state.debugCallParams, onApply: () => applyConfig(state),
callResult: state.debugCallResult, onUpdate: () => runUpdate(state),
callError: state.debugCallError, })
onCallMethodChange: (next) => (state.debugCallMethod = next), : nothing
onCallParamsChange: (next) => (state.debugCallParams = next), }
onRefresh: () => loadDebug(state), ${
onCall: () => callDebugMethod(state), state.tab === "debug"
}) ? renderDebug({
: nothing} loading: state.debugLoading,
status: state.debugStatus,
${state.tab === "logs" health: state.debugHealth,
? renderLogs({ models: state.debugModels,
loading: state.logsLoading, heartbeat: state.debugHeartbeat,
error: state.logsError, eventLog: state.eventLog,
file: state.logsFile, callMethod: state.debugCallMethod,
entries: state.logsEntries, callParams: state.debugCallParams,
filterText: state.logsFilterText, callResult: state.debugCallResult,
levelFilters: state.logsLevelFilters, callError: state.debugCallError,
autoFollow: state.logsAutoFollow, onCallMethodChange: (next) => (state.debugCallMethod = next),
truncated: state.logsTruncated, onCallParamsChange: (next) => (state.debugCallParams = next),
onFilterTextChange: (next) => (state.logsFilterText = next), onRefresh: () => loadDebug(state),
onLevelToggle: (level, enabled) => { onCall: () => callDebugMethod(state),
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; })
}, : nothing
onToggleAutoFollow: (next) => (state.logsAutoFollow = next), }
onRefresh: () => loadLogs(state, { reset: true }), ${
onExport: (lines, label) => state.exportLogs(lines, label), state.tab === "logs"
onScroll: (event) => state.handleLogsScroll(event), ? renderLogs({
}) loading: state.logsLoading,
: nothing} error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = {
...state.logsLevelFilters,
[level]: enabled,
};
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing
}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
</div> </div>

View File

@ -22,10 +22,7 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills"; import type { SkillMessage } from "./controllers/skills";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
@ -203,4 +200,5 @@ export type AppViewState = {
handleLogsLevelFilterToggle: (level: LogLevel) => void; handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void; handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>; handleCallDebugMethod: (method: string, params: string) => Promise<void>;
handleLogout: () => void;
}; };

View File

@ -3,7 +3,7 @@ import { customElement, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import { resolveInjectedAssistantIdentity } from "./assistant-identity"; import { resolveInjectedAssistantIdentity } from "./assistant-identity";
import { loadSettings, type UiSettings } from "./storage"; import { clearCredentials, loadSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render"; import { renderApp } from "./app-render";
import type { Tab } from "./navigation"; import type { Tab } from "./navigation";
import type { ResolvedTheme, ThemeMode } from "./theme"; import type { ResolvedTheme, ThemeMode } from "./theme";
@ -24,17 +24,10 @@ import type {
StatusSummary, StatusSummary,
NostrProfile, NostrProfile,
} from "./types"; } from "./types";
import { import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
type ChatAttachment,
type ChatQueueItem,
type CronFormState,
} from "./ui-types";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type { import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { import {
@ -97,12 +90,7 @@ function resolveOnboardingMode(): boolean {
const raw = params.get("onboarding"); const raw = params.get("onboarding");
if (!raw) return false; if (!raw) return false;
const normalized = raw.trim().toLowerCase(); const normalized = raw.trim().toLowerCase();
return ( return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
normalized === "1" ||
normalized === "true" ||
normalized === "yes" ||
normalized === "on"
);
} }
@customElement("moltbot-app") @customElement("moltbot-app")
@ -134,9 +122,7 @@ export class MoltbotApp extends LitElement {
@state() chatStream: string | null = null; @state() chatStream: string | null = null;
@state() chatStreamStartedAt: number | null = null; @state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null; @state() chatRunId: string | null = null;
@state() compactionStatus: @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
| import("./app-tool-stream").CompactionStatus
| null = null;
@state() chatAvatarUrl: string | null = null; @state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = []; @state() chatQueue: ChatQueueItem[] = [];
@ -270,12 +256,9 @@ export class MoltbotApp extends LitElement {
private toolStreamOrder: string[] = []; private toolStreamOrder: string[] = [];
basePath = ""; basePath = "";
private popStateHandler = () => private popStateHandler = () =>
onPopStateInternal( onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
private themeMedia: MediaQueryList | null = null; private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
null;
private topbarObserver: ResizeObserver | null = null; private topbarObserver: ResizeObserver | null = null;
createRenderRoot() { createRenderRoot() {
@ -288,29 +271,20 @@ export class MoltbotApp extends LitElement {
} }
protected firstUpdated() { protected firstUpdated() {
handleFirstUpdated( handleFirstUpdated(this as unknown as Parameters<typeof handleFirstUpdated>[0]);
this as unknown as Parameters<typeof handleFirstUpdated>[0],
);
} }
disconnectedCallback() { disconnectedCallback() {
handleDisconnected( handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
this as unknown as Parameters<typeof handleDisconnected>[0],
);
super.disconnectedCallback(); super.disconnectedCallback();
} }
protected updated(changed: Map<PropertyKey, unknown>) { protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated( handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
} }
connect() { connect() {
connectGatewayInternal( connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
} }
handleChatScroll(event: Event) { handleChatScroll(event: Event) {
@ -332,15 +306,11 @@ export class MoltbotApp extends LitElement {
} }
resetToolStream() { resetToolStream() {
resetToolStreamInternal( resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
} }
resetChatScroll() { resetChatScroll() {
resetChatScrollInternal( resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
} }
async loadAssistantIdentity() { async loadAssistantIdentity() {
@ -348,43 +318,27 @@ export class MoltbotApp extends LitElement {
} }
applySettings(next: UiSettings) { applySettings(next: UiSettings) {
applySettingsInternal( applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
} }
setTab(next: Tab) { setTab(next: Tab) {
setTabInternal( setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
this as unknown as Parameters<typeof setTabInternal>[0],
next,
);
} }
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal( setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
} }
async loadOverview() { async loadOverview() {
await loadOverviewInternal( await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
} }
async loadCron() { async loadCron() {
await loadCronInternal( await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
this as unknown as Parameters<typeof loadCronInternal>[0],
);
} }
async handleAbortChat() { async handleAbortChat() {
await handleAbortChatInternal( await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
} }
removeQueuedMessage(id: string) { removeQueuedMessage(id: string) {
@ -449,9 +403,7 @@ export class MoltbotApp extends LitElement {
handleNostrProfileToggleAdvancedInternal(this); handleNostrProfileToggleAdvancedInternal(this);
} }
async handleExecApprovalDecision( async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
decision: "allow-once" | "allow-always" | "deny",
) {
const active = this.execApprovalQueue[0]; const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) return; if (!active || !this.client || this.execApprovalBusy) return;
this.execApprovalBusy = true; this.execApprovalBusy = true;
@ -461,9 +413,7 @@ export class MoltbotApp extends LitElement {
id: active.id, id: active.id,
decision, decision,
}); });
this.execApprovalQueue = this.execApprovalQueue.filter( this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
(entry) => entry.id !== active.id,
);
} catch (err) { } catch (err) {
this.execApprovalError = `Exec approval failed: ${String(err)}`; this.execApprovalError = `Exec approval failed: ${String(err)}`;
} finally { } finally {
@ -496,6 +446,35 @@ export class MoltbotApp extends LitElement {
}, 200); }, 200);
} }
/**
* Security logout - clears stored credentials (token/password) from localStorage.
* Disconnects from gateway and resets connection state.
* User will need to re-authenticate to access the Control UI.
*/
handleLogout() {
// Clear credentials from localStorage
const clearedSettings = clearCredentials();
this.settings = clearedSettings;
this.password = "";
// Disconnect from gateway
if (this.client) {
this.client.stop();
this.client = null;
}
this.connected = false;
this.hello = null;
// Clear URL parameters
const url = new URL(window.location.href);
url.searchParams.delete("token");
url.searchParams.delete("password");
window.history.replaceState({}, "", url.toString());
// Show confirmation
this.lastError = "Logged out successfully. Credentials cleared.";
}
handleSplitRatioChange(ratio: number) { handleSplitRatioChange(ratio: number) {
const newRatio = Math.max(0.4, Math.min(0.7, ratio)); const newRatio = Math.max(0.4, Math.min(0.7, ratio));
this.splitRatio = newRatio; this.splitRatio = newRatio;

View File

@ -46,31 +46,22 @@ export function loadSettings(): UiSettings {
? parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim()
: defaults.gatewayUrl, : defaults.gatewayUrl,
token: typeof parsed.token === "string" ? parsed.token : defaults.token, token: typeof parsed.token === "string" ? parsed.token : defaults.token,
password: password: typeof parsed.password === "string" ? parsed.password : defaults.password,
typeof parsed.password === "string"
? parsed.password
: defaults.password,
sessionKey: sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim() ? parsed.sessionKey.trim()
: defaults.sessionKey, : defaults.sessionKey,
lastActiveSessionKey: lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" && typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim()
parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim() ? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" && : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey, defaults.lastActiveSessionKey,
theme: theme:
parsed.theme === "light" || parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
parsed.theme === "dark" ||
parsed.theme === "system"
? parsed.theme ? parsed.theme
: defaults.theme, : defaults.theme,
chatFocusMode: chatFocusMode:
typeof parsed.chatFocusMode === "boolean" typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
? parsed.chatFocusMode
: defaults.chatFocusMode,
chatShowThinking: chatShowThinking:
typeof parsed.chatShowThinking === "boolean" typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking ? parsed.chatShowThinking
@ -82,12 +73,9 @@ export function loadSettings(): UiSettings {
? parsed.splitRatio ? parsed.splitRatio
: defaults.splitRatio, : defaults.splitRatio,
navCollapsed: navCollapsed:
typeof parsed.navCollapsed === "boolean" typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
? parsed.navCollapsed
: defaults.navCollapsed,
navGroupsCollapsed: navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" && typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed ? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed, : defaults.navGroupsCollapsed,
}; };
@ -99,3 +87,19 @@ export function loadSettings(): UiSettings {
export function saveSettings(next: UiSettings) { export function saveSettings(next: UiSettings) {
localStorage.setItem(KEY, JSON.stringify(next)); localStorage.setItem(KEY, JSON.stringify(next));
} }
/**
* Clears authentication credentials (token and password) from localStorage.
* Used for security logout - allows user to explicitly revoke stored auth.
* Preserves other UI settings like theme, session key, etc.
*/
export function clearCredentials(): UiSettings {
const current = loadSettings();
const cleared: UiSettings = {
...current,
token: "",
password: "",
};
saveSettings(cleared);
return cleared;
}