feat: add session authorization mode configuration (#446)

Add approvals.mode configuration to allow users to enable/disable
session operation authorization. Mode can be 'off' (no auth) or 'manual'
(require auth). Changes trigger automatic gateway restart for config
to take effect.

- Add ApprovalConfig type with mode: 'off' | 'manual'
- Add approvals section to settings store
- Add session authorization toggle in SessionSettings UI
- Add approvals to PLATFORM_SECTIONS for auto-restart
- Add i18n support for all 8 languages

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-04 21:29:39 +08:00
committed by GitHub
parent 99f9dcb2fe
commit d47abf1533
12 changed files with 94 additions and 29 deletions
+6
View File
@@ -38,12 +38,18 @@ export interface PrivacyConfig {
redact_pii?: boolean redact_pii?: boolean
} }
export interface ApprovalConfig {
mode?: 'off' | 'manual'
timeout?: number
}
export interface AppConfig { export interface AppConfig {
display?: DisplayConfig display?: DisplayConfig
agent?: AgentConfig agent?: AgentConfig
memory?: MemoryConfig memory?: MemoryConfig
session_reset?: SessionResetConfig session_reset?: SessionResetConfig
privacy?: PrivacyConfig privacy?: PrivacyConfig
approvals?: ApprovalConfig
telegram?: Record<string, any> telegram?: Record<string, any>
discord?: Record<string, any> discord?: Record<string, any>
slack?: Record<string, any> slack?: Record<string, any>
@@ -1,28 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import { NInputNumber, NSelect, NSwitch, useMessage } from 'naive-ui' import { NInputNumber, NSelect, NSwitch, useMessage } from "naive-ui";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { useSettingsStore } from '@/stores/hermes/settings' import { useSettingsStore } from "@/stores/hermes/settings";
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs' import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import SettingRow from './SettingRow.vue' import SettingRow from "./SettingRow.vue";
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore();
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore() const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
const message = useMessage() const message = useMessage();
const { t } = useI18n() const { t } = useI18n();
async function save(values: Record<string, any>) { async function save(values: Record<string, any>) {
try { try {
await settingsStore.saveSection('session_reset', values) await settingsStore.saveSection("session_reset", values);
message.success(t('settings.saved')) message.success(t("settings.saved"));
} catch (err: any) { } catch (err: any) {
message.error(t('settings.saveFailed')) message.error(t("settings.saveFailed"));
}
}
async function toggleRequireAuth(value: boolean) {
try {
await settingsStore.saveSection("approvals", { mode: value ? "manual" : "off" });
message.success(t("settings.saved"));
} catch (err: any) {
message.error(t("settings.saveFailed"));
} }
} }
</script> </script>
<template> <template>
<section class="settings-section"> <section class="settings-section">
<SettingRow :label="t('settings.session.mode')" :hint="t('settings.session.modeHint')"> <SettingRow
:label="t('settings.session.requireAuth')"
:hint="t('settings.session.requireAuthHint')"
>
<NSwitch :value="settingsStore.approvals.mode === 'manual'" @update:value="toggleRequireAuth" />
</SettingRow>
<SettingRow
:label="t('settings.session.mode')"
:hint="t('settings.session.modeHint')"
>
<NSelect <NSelect
:value="settingsStore.sessionReset.mode || 'both'" :value="settingsStore.sessionReset.mode || 'both'"
:options="[ :options="[
@@ -30,37 +48,53 @@ async function save(values: Record<string, any>) {
{ label: t('settings.session.modeIdle'), value: 'idle' }, { label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeHourly'), value: 'hourly' }, { label: t('settings.session.modeHourly'), value: 'hourly' },
]" ]"
size="small" class="input-md" size="small"
@update:value="v => save({ mode: v })" class="input-md"
@update:value="(v) => save({ mode: v })"
/> />
</SettingRow> </SettingRow>
<SettingRow :label="t('settings.session.idleMinutes')" :hint="t('settings.session.idleMinutesHint')"> <SettingRow
:label="t('settings.session.idleMinutes')"
:hint="t('settings.session.idleMinutesHint')"
>
<NInputNumber <NInputNumber
:value="settingsStore.sessionReset.idle_minutes" :value="settingsStore.sessionReset.idle_minutes"
:min="10" :max="10080" :step="30" :min="10"
size="small" class="input-sm" :max="10080"
@update:value="v => v != null && save({ idle_minutes: v })" :step="30"
size="small"
class="input-sm"
@update:value="(v) => v != null && save({ idle_minutes: v })"
/> />
</SettingRow> </SettingRow>
<SettingRow :label="t('settings.session.atHour')" :hint="t('settings.session.atHourHint')"> <SettingRow
:label="t('settings.session.atHour')"
:hint="t('settings.session.atHourHint')"
>
<NInputNumber <NInputNumber
:value="settingsStore.sessionReset.at_hour" :value="settingsStore.sessionReset.at_hour"
:min="0" :max="23" :step="1" :min="0"
size="small" class="input-sm" :max="23"
@update:value="v => v != null && save({ at_hour: v })" :step="1"
size="small"
class="input-sm"
@update:value="(v) => v != null && save({ at_hour: v })"
/> />
</SettingRow> </SettingRow>
<SettingRow :label="t('settings.session.liveMonitorHumanOnly')" :hint="t('settings.session.liveMonitorHumanOnlyHint')"> <SettingRow
:label="t('settings.session.liveMonitorHumanOnly')"
:hint="t('settings.session.liveMonitorHumanOnlyHint')"
>
<NSwitch <NSwitch
:value="sessionBrowserPrefsStore.humanOnly" :value="sessionBrowserPrefsStore.humanOnly"
@update:value="value => sessionBrowserPrefsStore.setHumanOnly(value)" @update:value="(value) => sessionBrowserPrefsStore.setHumanOnly(value)"
/> />
</SettingRow> </SettingRow>
</section> </section>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@use '@/styles/variables' as *; @use "@/styles/variables" as *;
.settings-section { .settings-section {
margin-top: 16px; margin-top: 16px;
+2
View File
@@ -475,6 +475,8 @@ jobTriggered: 'Job ausgelost',
liveMonitorHumanOnly: 'Live-Monitor: nur menschliche Sitzungen anzeigen', liveMonitorHumanOnly: 'Live-Monitor: nur menschliche Sitzungen anzeigen',
liveMonitorHumanOnlyHint: 'Im Live-Monitor Unteragenten- und Sitzungsmonitor-Rauschen standardmäßig ausblenden', liveMonitorHumanOnlyHint: 'Im Live-Monitor Unteragenten- und Sitzungsmonitor-Rauschen standardmäßig ausblenden',
atHourHint: 'Sitzung taglich zu dieser Stunde zurucksetzen', atHourHint: 'Sitzung taglich zu dieser Stunde zurucksetzen',
requireAuth: 'Sitzungsautorisierung',
requireAuthHint: 'Erfordert Autorisierung für Sitzungsvorgänge',
}, },
privacy: { privacy: {
redactPii: 'Personliche Daten maskieren', redactPii: 'Personliche Daten maskieren',
+2
View File
@@ -526,6 +526,8 @@ export default {
liveMonitorHumanOnly: 'Live monitor: show human sessions only', liveMonitorHumanOnly: 'Live monitor: show human sessions only',
liveMonitorHumanOnlyHint: 'Hide sub-agent/session monitor noise in the Live monitor by default', liveMonitorHumanOnlyHint: 'Hide sub-agent/session monitor noise in the Live monitor by default',
atHourHint: 'Reset session at this hour daily', atHourHint: 'Reset session at this hour daily',
requireAuth: 'Session Authorization',
requireAuthHint: 'Require authorization for session operations',
}, },
privacy: { privacy: {
redactPii: 'Redact PII', redactPii: 'Redact PII',
+2
View File
@@ -475,6 +475,8 @@ jobTriggered: 'Job ejecutado',
liveMonitorHumanOnly: 'Monitor en vivo: mostrar solo sesiones humanas', liveMonitorHumanOnly: 'Monitor en vivo: mostrar solo sesiones humanas',
liveMonitorHumanOnlyHint: 'Oculta por defecto el ruido de subagentes y del monitor de sesiones en el monitor en vivo', liveMonitorHumanOnlyHint: 'Oculta por defecto el ruido de subagentes y del monitor de sesiones en el monitor en vivo',
atHourHint: 'Reiniciar sesion a esta hora todos los dias', atHourHint: 'Reiniciar sesion a esta hora todos los dias',
requireAuth: 'Autorización de sesión',
requireAuthHint: 'Requiere autorización para operaciones de sesión',
}, },
privacy: { privacy: {
redactPii: 'Ocultar informacion personal', redactPii: 'Ocultar informacion personal',
+2
View File
@@ -475,6 +475,8 @@ jobTriggered: 'Job declenche',
liveMonitorHumanOnly: 'Moniteur live : nafficher que les sessions humaines', liveMonitorHumanOnly: 'Moniteur live : nafficher que les sessions humaines',
liveMonitorHumanOnlyHint: 'Masquer par défaut le bruit des sous-agents et du moniteur de session dans le moniteur live', liveMonitorHumanOnlyHint: 'Masquer par défaut le bruit des sous-agents et du moniteur de session dans le moniteur live',
atHourHint: 'Reinitialiser la session a cette heure chaque jour', atHourHint: 'Reinitialiser la session a cette heure chaque jour',
requireAuth: 'Autorisation de session',
requireAuthHint: 'Requiere l\'autorisation pour les operations de session',
}, },
privacy: { privacy: {
redactPii: 'Masquer les DPI', redactPii: 'Masquer les DPI',
+2
View File
@@ -475,6 +475,8 @@ export default {
liveMonitorHumanOnly: 'ライブモニター: 人間のセッションのみ表示', liveMonitorHumanOnly: 'ライブモニター: 人間のセッションのみ表示',
liveMonitorHumanOnlyHint: 'ライブモニターでサブエージェントやセッション監視ノイズを既定で隠します', liveMonitorHumanOnlyHint: 'ライブモニターでサブエージェントやセッション監視ノイズを既定で隠します',
atHourHint: '毎日指定時刻にセッションをリセット', atHourHint: '毎日指定時刻にセッションをリセット',
requireAuth: 'セッション認証',
requireAuthHint: 'セッション操作に認証を必要とする',
}, },
privacy: { privacy: {
redactPii: '個人情報のマスキング', redactPii: '個人情報のマスキング',
+2
View File
@@ -475,6 +475,8 @@ export default {
liveMonitorHumanOnly: '라이브 모니터: 사람 세션만 표시', liveMonitorHumanOnly: '라이브 모니터: 사람 세션만 표시',
liveMonitorHumanOnlyHint: '라이브 모니터에서 하위 에이전트 및 세션 모니터 노이즈를 기본으로 숨깁니다', liveMonitorHumanOnlyHint: '라이브 모니터에서 하위 에이전트 및 세션 모니터 노이즈를 기본으로 숨깁니다',
atHourHint: '매일 지정한 시간에 세션 초기화', atHourHint: '매일 지정한 시간에 세션 초기화',
requireAuth: '세션 인증',
requireAuthHint: '세션 작업에 인증 필요',
}, },
privacy: { privacy: {
redactPii: '개인정보 마스킹', redactPii: '개인정보 마스킹',
+2
View File
@@ -475,6 +475,8 @@ jobTriggered: 'Job acionado',
liveMonitorHumanOnly: 'Monitor ao vivo: mostrar apenas sessões humanas', liveMonitorHumanOnly: 'Monitor ao vivo: mostrar apenas sessões humanas',
liveMonitorHumanOnlyHint: 'Oculta por padrão o ruído de subagentes e do monitor de sessões no monitor ao vivo', liveMonitorHumanOnlyHint: 'Oculta por padrão o ruído de subagentes e do monitor de sessões no monitor ao vivo',
atHourHint: 'Reiniciar sessao neste horario diariamente', atHourHint: 'Reiniciar sessao neste horario diariamente',
requireAuth: 'Autorização de sessão',
requireAuthHint: 'Requer autorização para operações de sessão',
}, },
privacy: { privacy: {
redactPii: 'Ocultar dados pessoais', redactPii: 'Ocultar dados pessoais',
+2
View File
@@ -518,6 +518,8 @@ export default {
liveMonitorHumanOnly: '实时监看:仅显示人类会话', liveMonitorHumanOnly: '实时监看:仅显示人类会话',
liveMonitorHumanOnlyHint: '在实时监看中默认隐藏子代理和会话监看噪音', liveMonitorHumanOnlyHint: '在实时监看中默认隐藏子代理和会话监看噪音',
atHourHint: '每天在指定小时重置会话', atHourHint: '每天在指定小时重置会话',
requireAuth: '会话授权',
requireAuthHint: '修改会话操作是否授权',
}, },
privacy: { privacy: {
redactPii: '脱敏 PII', redactPii: '脱敏 PII',
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import * as configApi from '@/api/hermes/config' import * as configApi from '@/api/hermes/config'
import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig } from '@/api/hermes/config' import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig, ApprovalConfig } from '@/api/hermes/config'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false) const loading = ref(false)
@@ -12,6 +12,7 @@ export const useSettingsStore = defineStore('settings', () => {
const memory = ref<MemoryConfig>({}) const memory = ref<MemoryConfig>({})
const sessionReset = ref<SessionResetConfig>({}) const sessionReset = ref<SessionResetConfig>({})
const privacy = ref<PrivacyConfig>({}) const privacy = ref<PrivacyConfig>({})
const approvals = ref<ApprovalConfig>({})
const telegram = ref<Record<string, any>>({}) const telegram = ref<Record<string, any>>({})
const discord = ref<Record<string, any>>({}) const discord = ref<Record<string, any>>({})
const slack = ref<Record<string, any>>({}) const slack = ref<Record<string, any>>({})
@@ -32,6 +33,7 @@ export const useSettingsStore = defineStore('settings', () => {
memory.value = data.memory || {} memory.value = data.memory || {}
sessionReset.value = data.session_reset || {} sessionReset.value = data.session_reset || {}
privacy.value = data.privacy || {} privacy.value = data.privacy || {}
approvals.value = data.approvals || {}
telegram.value = data.telegram || {} telegram.value = data.telegram || {}
discord.value = data.discord || {} discord.value = data.discord || {}
slack.value = data.slack || {} slack.value = data.slack || {}
@@ -59,6 +61,7 @@ export const useSettingsStore = defineStore('settings', () => {
case 'memory': memory.value = { ...memory.value, ...values }; break case 'memory': memory.value = { ...memory.value, ...values }; break
case 'session_reset': sessionReset.value = { ...sessionReset.value, ...values }; break case 'session_reset': sessionReset.value = { ...sessionReset.value, ...values }; break
case 'privacy': privacy.value = { ...privacy.value, ...values }; break case 'privacy': privacy.value = { ...privacy.value, ...values }; break
case 'approvals': approvals.value = { ...approvals.value, ...values }; break
case 'telegram': telegram.value = { ...telegram.value, ...values }; break case 'telegram': telegram.value = { ...telegram.value, ...values }; break
case 'discord': discord.value = { ...discord.value, ...values }; break case 'discord': discord.value = { ...discord.value, ...values }; break
case 'slack': slack.value = { ...slack.value, ...values }; break case 'slack': slack.value = { ...slack.value, ...values }; break
@@ -86,7 +89,7 @@ export const useSettingsStore = defineStore('settings', () => {
return { return {
loading, saving, loading, saving,
display, agent, memory, sessionReset, privacy, display, agent, memory, sessionReset, privacy, approvals,
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms, telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms,
fetchSettings, saveSection, fetchSettings, saveSection,
} }
@@ -7,6 +7,7 @@ import { saveEnvValue } from '../../services/config-helpers'
const PLATFORM_SECTIONS = new Set([ const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix', 'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
'weixin', 'wecom', 'feishu', 'dingtalk', 'weixin', 'wecom', 'feishu', 'dingtalk',
'approvals',
]) ])
const configPath = () => getActiveConfigPath() const configPath = () => getActiveConfigPath()
@@ -95,7 +96,12 @@ async function readConfig(): Promise<Record<string, any>> {
async function writeConfig(data: Record<string, any>): Promise<void> { async function writeConfig(data: Record<string, any>): Promise<void> {
const cp = configPath() const cp = configPath()
await copyFile(cp, cp + '.bak') await copyFile(cp, cp + '.bak')
const yamlStr = YAML.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false }) const yamlStr = YAML.dump(data, {
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: true, // Force quotes on all string values
})
await writeFile(cp, yamlStr, 'utf-8') await writeFile(cp, yamlStr, 'utf-8')
} }