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:
parent
8a2878f67f
commit
0c17409fb1
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
121
ui/src/ui/app.ts
121
ui/src/ui/app.ts
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user