Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
+2
View File
@@ -9,6 +9,7 @@ import AppSidebar from '@/components/layout/AppSidebar.vue'
import { useKeyboard } from '@/composables/useKeyboard'
import { useAppStore } from '@/stores/hermes/app'
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
const { isDark, isComic } = useTheme()
const { t } = useI18n()
@@ -55,6 +56,7 @@ useKeyboard()
<template>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NMessageProvider>
<AuthEventListener />
<NDialogProvider>
<NNotificationProvider>
<div v-if="nodeVersionLow && ready" class="node-warning-bar">
+80
View File
@@ -3,6 +3,7 @@ import { request } from './client'
export interface AuthStatus {
hasPasswordLogin: boolean
username: string | null
hasUsers?: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
@@ -27,6 +28,21 @@ export async function loginWithPassword(username: string, password: string): Pro
return data.token
}
export interface CurrentUser {
id: number
username: string
role: UserRole
status: UserStatus
created_at: number
updated_at: number
last_login_at: number | null
}
export async function fetchCurrentUser(): Promise<CurrentUser> {
const res = await request<{ user: CurrentUser }>('/api/auth/me')
return res.user
}
export async function setupPassword(username: string, password: string): Promise<void> {
return request('/api/auth/setup', {
method: 'POST',
@@ -54,6 +70,70 @@ export async function removePassword(): Promise<void> {
})
}
export type UserRole = 'super_admin' | 'admin'
export type UserStatus = 'active' | 'disabled'
export interface ManagedUser {
id: number
username: string
role: UserRole
status: UserStatus
profiles: string[]
default_profile: string | null
created_at: number
updated_at: number
last_login_at: number | null
}
export interface ManagedUsersResponse {
users: ManagedUser[]
profiles: string[]
}
export async function fetchManagedUsers(): Promise<ManagedUsersResponse> {
return request<ManagedUsersResponse>('/api/auth/users')
}
export async function createManagedUser(input: {
username: string
password: string
role: UserRole
status: UserStatus
profiles: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function updateManagedUser(id: number, input: {
username?: string
password?: string
role?: UserRole
status?: UserStatus
profiles?: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function deleteManagedUser(id: number): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'DELETE',
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export interface LockedIp {
ip: string
type: 'password' | 'token'
+61 -16
View File
@@ -26,23 +26,56 @@ export function hasApiKey(): boolean {
return !!getApiKey()
}
/**
* Get current active profile name.
* Reads from store first (authoritative source), falls back to localStorage.
*/
function getActiveProfileName(): string | null {
export type StoredUserRole = 'super_admin' | 'admin'
export function getStoredUserRole(): StoredUserRole | null {
const token = getApiKey()
const payload = token.split('.')[1]
if (!payload) return null
try {
// Dynamic import to avoid circular dependency
const { useProfilesStore } = require('@/stores/hermes/profiles')
const store = useProfilesStore()
// Store is the source of truth - it's updated from /api/hermes/profiles
return store.activeProfileName
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const data = JSON.parse(atob(padded)) as { role?: unknown }
return data.role === 'super_admin' || data.role === 'admin' ? data.role : null
} catch {
// Fallback to localStorage if store is not available (e.g., during initialization)
return localStorage.getItem('hermes_active_profile_name')
return null
}
}
export function isStoredSuperAdmin(): boolean {
return getStoredUserRole() === 'super_admin'
}
function getActiveProfileName(): string | null {
return localStorage.getItem('hermes_active_profile_name')
}
function bodyHasProfileSelector(body: BodyInit | null | undefined): boolean {
if (typeof body !== 'string') return false
try {
const parsed = JSON.parse(body) as { profile?: unknown }
return typeof parsed?.profile === 'string' && parsed.profile.trim().length > 0
} catch {
return false
}
}
function shouldAttachProfileHeader(path: string, options: RequestInit): boolean {
try {
const url = new URL(path, 'http://hermes.local')
if (url.searchParams.has('profile')) return false
if (url.pathname.startsWith('/api/hermes/profiles')) return false
} catch {
if (path.startsWith('/api/hermes/profiles')) return false
}
return !bodyHasProfileSelector(options.body)
}
function emitAuthNotice(kind: 'expired' | 'forbidden') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } }))
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const base = getBaseUrl()
const url = `${base}${path}`
@@ -56,9 +89,10 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
headers['Authorization'] = `Bearer ${apiKey}`
}
// Inject active profile header for proxied gateway requests
// Inject active profile header for request-scoped endpoints. Explicit profile
// selectors in the URL/body and profile-name routes are validated directly.
const profileName = getActiveProfileName()
if (profileName && profileName !== 'default') {
if (profileName && shouldAttachProfileHeader(path, options)) {
headers['X-Hermes-Profile'] = profileName
}
@@ -67,11 +101,11 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
// Global 401 handler — only redirect to login for local BFF endpoints
// Proxied gateway requests should not trigger logout
const isLocalBff = !path.startsWith('/api/hermes/v1/') &&
!path.startsWith('/api/hermes/jobs') &&
!path.startsWith('/api/hermes/skills')
!path.startsWith('/v1/')
if (res.status === 401 && isLocalBff) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
@@ -80,6 +114,17 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
if (!res.ok) {
const text = await res.text().catch(() => '')
if (res.status === 403 && isLocalBff) {
if (text.includes('User is disabled or does not exist')) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
} else {
emitAuthNotice('forbidden')
}
}
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
}
+10
View File
@@ -225,6 +225,14 @@ function normalizedBoard(board?: string): string {
return trimmed || 'default'
}
function activeProfileName(): string | null {
try {
return localStorage.getItem('hermes_active_profile_name')
} catch {
return null
}
}
function appendQuery(path: string, params: URLSearchParams): string {
const qs = params.toString()
return qs ? `${path}?${qs}` : path
@@ -251,6 +259,8 @@ export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string
const params = boardParams(opts?.board)
const token = getApiKey()
if (token) params.set('token', token)
const profile = activeProfileName()
if (profile) params.set('profile', profile)
const path = `/api/hermes/kanban/events?${params.toString()}`
if (base) {
@@ -155,6 +155,10 @@ export async function renameProfile(name: string, newName: string): Promise<bool
}
export async function switchProfile(name: string): Promise<boolean> {
return !!name
}
export async function switchHermesProfile(name: string): Promise<boolean> {
try {
await request('/api/hermes/profiles/active', {
method: 'PUT',
+11 -5
View File
@@ -2,7 +2,7 @@ import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
profile?: string
profile?: string | null
source: string
model: string
provider?: string
@@ -94,18 +94,24 @@ export async function fetchSession(id: string): Promise<SessionDetail | null> {
/**
* Fetch Hermes session detail only (exclude api_server source)
*/
export async function fetchHermesSession(id: string): Promise<SessionDetail | null> {
export async function fetchHermesSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
try {
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/hermes/${id}`)
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/hermes/${id}${query ? `?${query}` : ''}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string): Promise<boolean> {
export async function deleteSession(id: string, profile?: string | null): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}`, { method: 'DELETE' })
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
await request(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`, { method: 'DELETE' })
return true
} catch {
return false
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
const message = useMessage()
const { t } = useI18n()
let lastNoticeAt = 0
function onAuthNotice(event: Event) {
const detail = (event as CustomEvent<{ kind?: string }>).detail || {}
const now = Date.now()
if (now - lastNoticeAt < 1200) return
lastNoticeAt = now
if (detail.kind === 'forbidden') {
message.error(t('login.accessDenied'))
return
}
message.error(t('login.sessionExpired'))
}
onMounted(() => {
window.addEventListener('hermes-auth-notice', onAuthNotice)
})
onUnmounted(() => {
window.removeEventListener('hermes-auth-notice', onAuthNotice)
})
</script>
<template>
<span style="display: none" aria-hidden="true" />
</template>
@@ -37,9 +37,19 @@ async function toggleDetail() {
}
async function handleSwitch() {
dialog.warning({
title: t('profiles.switchTo'),
content: t('profiles.switchConfirm', { name: props.profile.name }),
positiveText: t('profiles.switchTo'),
negativeText: t('common.cancel'),
onPositiveClick: performHermesSwitch,
})
}
async function performHermesSwitch() {
switching.value = true
try {
const ok = await profilesStore.switchProfile(props.profile.name)
const ok = await profilesStore.switchHermesProfile(props.profile.name)
if (ok) {
message.success(t('profiles.switchSuccess', { name: props.profile.name }))
// Reload to refresh all profile-dependent data
@@ -2,22 +2,15 @@
import { ref, onMounted } from "vue";
import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
import { changePassword, changeUsername, fetchCurrentUser, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
import type { LockedIp } from "@/api/auth";
const { t } = useI18n();
const message = useMessage();
const hasPasswordLogin = ref(false);
const username = ref<string | null>(null);
const loading = ref(false);
// Setup form
const showSetupModal = ref(false);
const setupUsername = ref("");
const setupPasswordVal = ref("");
const setupPasswordConfirm = ref("");
// Change password form
const showChangePasswordModal = ref(false);
const currentPasswordForPwd = ref("");
@@ -31,38 +24,11 @@ const newUsernameVal = ref("");
onMounted(async () => {
try {
const status = await fetchAuthStatus();
hasPasswordLogin.value = status.hasPasswordLogin;
username.value = status.username;
const user = await fetchCurrentUser();
username.value = user.username;
} catch { /* ignore */ }
});
async function handleSetup() {
if (setupPasswordVal.value !== setupPasswordConfirm.value) {
message.error(t("login.passwordMismatch"));
return;
}
if (setupPasswordVal.value.length < 6) {
message.error(t("login.passwordTooShort"));
return;
}
loading.value = true;
try {
await setupPassword(setupUsername.value, setupPasswordVal.value);
hasPasswordLogin.value = true;
username.value = setupUsername.value;
showSetupModal.value = false;
setupUsername.value = "";
setupPasswordVal.value = "";
setupPasswordConfirm.value = "";
message.success(t("login.setupSuccess"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
async function handleChangePassword() {
if (newPasswordVal.value !== newPasswordConfirm.value) {
message.error(t("login.passwordMismatch"));
@@ -107,27 +73,6 @@ async function handleChangeUsername() {
}
}
async function handleRemove() {
loading.value = true;
try {
await removePassword();
hasPasswordLogin.value = false;
username.value = null;
message.success(t("login.passwordRemoved"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
function openSetupModal() {
setupUsername.value = "";
setupPasswordVal.value = "";
setupPasswordConfirm.value = "";
showSetupModal.value = true;
}
function openChangePasswordModal() {
currentPasswordForPwd.value = "";
newPasswordVal.value = "";
@@ -187,25 +132,12 @@ onMounted(() => { loadLockedIps(); });
<div class="account-settings">
<p class="section-desc">{{ t("login.setupDescription") }}</p>
<!-- Not configured -->
<div v-if="!hasPasswordLogin" class="action-row">
<span class="action-label">{{ t("login.passwordLoginNotConfigured") }}</span>
<NButton type="primary" @click="openSetupModal">{{ t("login.setupPassword") }}</NButton>
</div>
<!-- Configured -->
<div v-else class="configured-section">
<div class="configured-section">
<div class="action-row">
<span class="action-label">{{ t("login.passwordLoginConfigured", { username }) }}</span>
<div class="action-buttons">
<NButton @click="openChangePasswordModal">{{ t("login.changePassword") }}</NButton>
<NButton @click="openChangeUsernameModal">{{ t("login.changeUsername") }}</NButton>
<NPopconfirm @positive-click="handleRemove">
<template #trigger>
<NButton type="error" ghost :loading="loading">{{ t("login.removePasswordLogin") }}</NButton>
</template>
{{ t("login.removeConfirm") }}
</NPopconfirm>
</div>
</div>
</div>
@@ -238,25 +170,6 @@ onMounted(() => { loadLockedIps(); });
<p v-else class="empty-hint">{{ t("settings.lockedIps.empty") }}</p>
</div>
<!-- Setup modal -->
<NModal v-model:show="showSetupModal" preset="dialog" :title="t('login.setupPassword')">
<NForm label-placement="top">
<NFormItem :label="t('login.username')">
<NInput v-model:value="setupUsername" :placeholder="t('login.usernamePlaceholder')" />
</NFormItem>
<NFormItem :label="t('login.newPassword')">
<NInput v-model:value="setupPasswordVal" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('login.confirmPassword')">
<NInput v-model:value="setupPasswordConfirm" type="password" show-password-on="click" :placeholder="t('login.confirmPassword')" @keyup.enter="handleSetup" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showSetupModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSetup">{{ t("common.save") }}</NButton>
</template>
</NModal>
<!-- Change password modal -->
<NModal v-model:show="showChangePasswordModal" preset="dialog" :title="t('login.changePassword')">
<NForm label-placement="top">
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSelect, NSpace, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import {
createManagedUser,
deleteManagedUser,
fetchManagedUsers,
updateManagedUser,
type ManagedUser,
type UserRole,
type UserStatus,
} from '@/api/auth'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const users = ref<ManagedUser[]>([])
const profiles = ref<string[]>([])
const showModal = ref(false)
const editingUser = ref<ManagedUser | null>(null)
const form = reactive({
username: '',
password: '',
role: 'admin' as UserRole,
status: 'active' as UserStatus,
profiles: [] as string[],
})
const roleOptions = computed(() => [
{ label: t('users.roles.admin'), value: 'admin' },
{ label: t('users.roles.superAdmin'), value: 'super_admin' },
])
const statusOptions = computed(() => [
{ label: t('users.status.active'), value: 'active' },
{ label: t('users.status.disabled'), value: 'disabled' },
])
const profileOptions = computed(() => profiles.value.map(profile => ({ label: profile, value: profile })))
function resetForm() {
editingUser.value = null
form.username = ''
form.password = ''
form.role = 'admin'
form.status = 'active'
form.profiles = []
}
async function loadUsers() {
loading.value = true
try {
const res = await fetchManagedUsers()
users.value = res.users
profiles.value = res.profiles
} catch (err: any) {
message.error(err.message || t('users.loadFailed'))
} finally {
loading.value = false
}
}
function openCreate() {
resetForm()
showModal.value = true
}
function openEdit(user: ManagedUser) {
editingUser.value = user
form.username = user.username
form.password = ''
form.role = user.role
form.status = user.status
form.profiles = [...user.profiles]
showModal.value = true
}
async function submit() {
if (form.username.trim().length < 2) {
message.error(t('login.usernameTooShort'))
return
}
if (!editingUser.value && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
if (form.password && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
saving.value = true
try {
const payload = {
username: form.username.trim(),
password: form.password || undefined,
role: form.role,
status: form.status,
profiles: form.role === 'super_admin' ? [] : form.profiles,
defaultProfile: form.profiles[0] || null,
}
const res = editingUser.value
? await updateManagedUser(editingUser.value.id, payload)
: await createManagedUser({ ...payload, password: form.password })
users.value = res.users
profiles.value = res.profiles
showModal.value = false
resetForm()
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function setStatus(user: ManagedUser, status: UserStatus) {
saving.value = true
try {
const res = await updateManagedUser(user.id, { status })
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function removeUser(user: ManagedUser) {
saving.value = true
try {
const res = await deleteManagedUser(user.id)
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.deleteFailed'))
} finally {
saving.value = false
}
}
function formatTime(value: number | null): string {
if (!value) return '-'
return new Date(value).toLocaleString()
}
const columns = computed<DataTableColumns<ManagedUser>>(() => [
{
title: t('users.username'),
key: 'username',
minWidth: 140,
},
{
title: t('users.role'),
key: 'role',
width: 130,
render: (row) => h(NTag, { size: 'small', type: row.role === 'super_admin' ? 'warning' : 'default' }, {
default: () => row.role === 'super_admin' ? t('users.roles.superAdmin') : t('users.roles.admin'),
}),
},
{
title: t('users.statusLabel'),
key: 'status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'error' }, {
default: () => row.status === 'active' ? t('users.status.active') : t('users.status.disabled'),
}),
},
{
title: t('users.profiles'),
key: 'profiles',
minWidth: 200,
render: (row) => row.role === 'super_admin'
? h('span', { class: 'muted' }, t('users.allProfiles'))
: h(NSpace, { size: 4 }, {
default: () => row.profiles.length
? row.profiles.map(profile => h(NTag, { size: 'small', bordered: false }, { default: () => profile }))
: h('span', { class: 'muted' }, t('users.noProfiles')),
}),
},
{
title: t('users.lastLogin'),
key: 'last_login_at',
minWidth: 170,
render: (row) => formatTime(row.last_login_at),
},
{
title: t('common.edit'),
key: 'actions',
width: 280,
render: (row) => h(NSpace, { size: 8 }, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('common.edit') }),
h(NButton, {
size: 'small',
type: row.status === 'active' ? 'warning' : 'primary',
ghost: true,
loading: saving.value,
onClick: () => setStatus(row, row.status === 'active' ? 'disabled' : 'active'),
}, { default: () => row.status === 'active' ? t('users.disable') : t('users.enable') }),
h(NPopconfirm, { onPositiveClick: () => removeUser(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true, loading: saving.value }, { default: () => t('common.delete') }),
default: () => t('users.deleteConfirm'),
}),
],
}),
},
])
onMounted(loadUsers)
</script>
<template>
<div class="user-management">
<div class="toolbar">
<div>
<h3 class="section-title">{{ t('users.title') }}</h3>
<p class="section-desc">{{ t('users.description') }}</p>
</div>
<NButton type="primary" @click="openCreate">{{ t('users.create') }}</NButton>
</div>
<NDataTable
:columns="columns"
:data="users"
:loading="loading"
:bordered="false"
:single-line="false"
size="small"
/>
<NModal v-model:show="showModal" preset="dialog" :title="editingUser ? t('users.edit') : t('users.create')">
<NForm label-placement="top">
<NFormItem :label="t('users.username')">
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" />
</NFormItem>
<NFormItem :label="editingUser ? t('users.newPasswordOptional') : t('login.newPassword')">
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('users.role')">
<NSelect v-model:value="form.role" :options="roleOptions" />
</NFormItem>
<NFormItem :label="t('users.statusLabel')">
<NSelect v-model:value="form.status" :options="statusOptions" />
</NFormItem>
<NFormItem v-if="form.role !== 'super_admin'" :label="t('users.profiles')">
<NSelect
v-model:value="form.profiles"
multiple
filterable
:options="profileOptions"
:placeholder="t('users.profilesPlaceholder')"
/>
</NFormItem>
</NForm>
<template #action>
<NButton @click="showModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="submit">{{ t('common.save') }}</NButton>
</template>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.user-management {
padding: 8px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.section-desc {
margin: 0;
font-size: 13px;
color: $text-muted;
}
:deep(.muted) {
color: $text-muted;
}
</style>
@@ -10,6 +10,7 @@ import LanguageSwitch from "./LanguageSwitch.vue";
import ThemeSwitch from "./ThemeSwitch.vue";
import { useSessionSearch } from '@/composables/useSessionSearch'
import { changelog } from "@/data/changelog";
import { isStoredSuperAdmin } from "@/api/client";
const { t } = useI18n();
const message = useMessage();
@@ -18,6 +19,7 @@ const router = useRouter();
const appStore = useAppStore();
const { openSessionSearch } = useSessionSearch();
const selectedKey = computed(() => route.name as string);
const isSuperAdmin = computed(() => isStoredSuperAdmin());
const logoPath = '/logo.png';
const collapsedGroups = reactive<Record<string, boolean>>({});
@@ -226,7 +228,7 @@ function openChangelog() {
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.performance' }" @click="handleNav('hermes.performance')">
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.performance' }" @click="handleNav('hermes.performance')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
@@ -251,7 +253,7 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('system')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Geben Sie Ihren Zugangs-Token ein, um fortzufahren. Finden Sie ihn in den Server-Startprotokollen.',
description: 'Geben Sie Benutzername und Passwort ein, um fortzufahren.',
placeholder: 'Zugangs-Token',
submit: 'Anmelden',
tokenRequired: 'Bitte geben Sie Ihren Zugangs-Token ein',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche, bitte versuchen Sie es spater erneut',
sessionExpired: 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.',
accessDenied: 'Sie haben keine Berechtigung fur diese Ressource.',
passwordMismatch: 'Passworter stimmen nicht uberein',
passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
setupSuccess: 'Passwort-Login erfolgreich konfiguriert',
@@ -31,10 +33,38 @@ export default {
newUsername: 'Neuer Benutzername',
usernameChanged: 'Benutzername erfolgreich geandert',
usernameTooShort: 'Benutzername muss mindestens 2 Zeichen lang sein',
setupDescription: 'Richten Sie Benutzername und Passwort fur bequemes Login ein. Der Zugangs-Token bleibt als Backup verfugbar.',
removeConfirm: 'Mochten Sie das Passwort-Login wirklich entfernen? Sie mussen dann den Zugangs-Token verwenden.',
setupDescription: 'Verwalten Sie Benutzername und Passwort fur die Anmeldung.',
removeConfirm: 'Passwort-Login ist fur Benutzerkonten erforderlich.',
passwordLoginNotConfigured: 'Passwort-Login ist nicht konfiguriert',
passwordLoginConfigured: 'Passwort-Login aktiviert ({username})',
passwordLoginConfigured: 'Aktuelles Konto: {username}',
},
users: {
title: 'Kontoverwaltung',
description: 'Benutzer erstellen, Rollen zuweisen und steuern, auf welche Profile normale Administratoren zugreifen koennen.',
create: 'Benutzer erstellen',
edit: 'Benutzer bearbeiten',
username: 'Benutzername',
role: 'Rolle',
statusLabel: 'Status',
profiles: 'Profiles',
profilesPlaceholder: 'Zugreifbare Profile auswaehlen',
allProfiles: 'Alle Profile',
noProfiles: 'Keine Profile zugewiesen',
lastLogin: 'Letzte Anmeldung',
newPasswordOptional: 'Neues Passwort (leer lassen zum Beibehalten)',
loadFailed: 'Benutzer konnten nicht geladen werden',
deleteConfirm: 'Diesen Benutzer loeschen?',
enable: 'Aktivieren',
disable: 'Deaktivieren',
roles: {
superAdmin: 'Super Admin',
admin: 'Admin',
},
status: {
active: 'Aktiv',
disabled: 'Deaktiviert',
},
},
// Common
@@ -583,10 +613,10 @@ jobTriggered: 'Job ausgelost',
export: 'Exportieren',
rename: 'Umbenennen',
delete: 'Loschen',
switchTo: 'Wechseln zu',
switchConfirm: 'Das Wechseln zum Profil "{name}" startet das Gateway neu. Fortfahren?',
switchSuccess: 'Zum Profil "{name}" gewechselt',
switchFailed: 'Profilwechsel fehlgeschlagen. Moglicherweise muss das Gateway manuell neu gestartet werden.',
switchTo: 'Hermes Profile wechseln',
switchConfirm: 'Dies fuehrt `hermes profile use {name}` aus und aendert das aktive Hermes CLI Profile. Fortfahren?',
switchSuccess: 'Aktives Hermes Profile ist jetzt "{name}"',
switchFailed: 'Hermes Profile konnte nicht gewechselt werden. Das Gateway muss eventuell manuell neu gestartet werden.',
createSuccess: 'Profil "{name}" erstellt',
createFailed: 'Erstellen des Profils fehlgeschlagen',
renameSuccess: 'Profil umbenannt',
@@ -673,7 +703,8 @@ jobTriggered: 'Job ausgelost',
saveFailed: 'Speichern fehlgeschlagen',
tabs: {
display: 'Anzeige',
account: 'Konto',
account: 'Aktuelles Konto',
users: 'Kontoverwaltung',
agent: 'Agent',
memory: 'Gedachtnis',
compression: 'Komprimierung',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Enter your access token to continue. Find it in the server startup logs.',
description: 'Enter your username and password to continue.',
placeholder: 'Access token',
submit: 'Login',
tokenRequired: 'Please enter your access token',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'Please enter username and password',
invalidCredentials: 'Invalid username or password',
tooManyAttempts: 'Too many failed attempts, please try again later',
sessionExpired: 'Login expired. Please sign in again.',
accessDenied: 'You do not have permission to access this resource.',
passwordMismatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 6 characters',
setupSuccess: 'Password login configured successfully',
@@ -31,10 +33,38 @@ export default {
newUsername: 'New Username',
usernameChanged: 'Username changed successfully',
usernameTooShort: 'Username must be at least 2 characters',
setupDescription: 'Set up a username and password for convenient login. The access token will continue to work as a backup.',
removeConfirm: 'Are you sure you want to remove password login? You will need to use the access token to log in.',
setupDescription: 'Manage the username and password used to sign in.',
removeConfirm: 'Password login is required for user accounts.',
passwordLoginNotConfigured: 'Password login is not configured',
passwordLoginConfigured: 'Password login enabled ({username})',
passwordLoginConfigured: 'Current account: {username}',
},
users: {
title: 'Account Management',
description: 'Create users, assign roles, and control which profiles regular admins can access.',
create: 'Create User',
edit: 'Edit User',
username: 'Username',
role: 'Role',
statusLabel: 'Status',
profiles: 'Profiles',
profilesPlaceholder: 'Select accessible profiles',
allProfiles: 'All profiles',
noProfiles: 'No profiles assigned',
lastLogin: 'Last Login',
newPasswordOptional: 'New Password (leave blank to keep)',
loadFailed: 'Failed to load users',
deleteConfirm: 'Delete this user?',
enable: 'Enable',
disable: 'Disable',
roles: {
superAdmin: 'Super Admin',
admin: 'Admin',
},
status: {
active: 'Active',
disabled: 'Disabled',
},
},
// Common
@@ -686,10 +716,10 @@ export default {
export: 'Export',
rename: 'Rename',
delete: 'Delete',
switchTo: 'Switch to',
switchConfirm: 'Switching to profile "{name}" will restart the gateway. Continue?',
switchSuccess: 'Switched to profile "{name}"',
switchFailed: 'Failed to switch profile. Gateway may need manual restart.',
switchTo: 'Switch Hermes Profile',
switchConfirm: 'This will run `hermes profile use {name}` and change the active Hermes CLI profile. Continue?',
switchSuccess: 'Hermes active profile switched to "{name}"',
switchFailed: 'Failed to switch Hermes profile. Gateway may need manual restart.',
createSuccess: 'Profile "{name}" created',
createFailed: 'Failed to create profile',
renameSuccess: 'Profile renamed',
@@ -776,7 +806,8 @@ export default {
saveFailed: 'Save failed',
tabs: {
display: 'Display',
account: 'Account',
account: 'Current Account',
users: 'Account Management',
agent: 'Agent',
memory: 'Memory',
compression: 'Compression',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Introduce tu token de acceso para continuar. Encuentralo en los registros de inicio del servidor.',
description: 'Introduce tu nombre de usuario y contrasena para continuar.',
placeholder: 'Token de acceso',
submit: 'Iniciar sesion',
tokenRequired: 'Por favor, introduce tu token de acceso',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
tooManyAttempts: 'Demasiados intentos fallidos, por favor intente mas tarde',
sessionExpired: 'La sesion expiro. Inicia sesion de nuevo.',
accessDenied: 'No tienes permiso para acceder a este recurso.',
passwordMismatch: 'Las contrasenas no coinciden',
passwordTooShort: 'La contrasena debe tener al menos 6 caracteres',
setupSuccess: 'Login con contrasena configurado correctamente',
@@ -31,10 +33,38 @@ export default {
newUsername: 'Nuevo nombre de usuario',
usernameChanged: 'Nombre de usuario cambiado correctamente',
usernameTooShort: 'El nombre de usuario debe tener al menos 2 caracteres',
setupDescription: 'Configure un nombre de usuario y contrasena para un inicio de sesion rapido. El token de acceso seguira funcionando.',
removeConfirm: 'Esta seguro de eliminar el login con contrasena? Necesitara usar el token de acceso.',
setupDescription: 'Administra el nombre de usuario y la contrasena usados para iniciar sesion.',
removeConfirm: 'El login con contrasena es obligatorio para las cuentas de usuario.',
passwordLoginNotConfigured: 'Login con contrasena no configurado',
passwordLoginConfigured: 'Login con contrasena habilitado ({username})',
passwordLoginConfigured: 'Cuenta actual: {username}',
},
users: {
title: 'Gestion de cuentas',
description: 'Crea usuarios, asigna roles y controla que Profile pueden usar los administradores normales.',
create: 'Crear usuario',
edit: 'Editar usuario',
username: 'Nombre de usuario',
role: 'Rol',
statusLabel: 'Estado',
profiles: 'Profiles',
profilesPlaceholder: 'Selecciona Profile accesibles',
allProfiles: 'Todos los Profile',
noProfiles: 'Sin Profile asignados',
lastLogin: 'Ultimo inicio',
newPasswordOptional: 'Nueva contrasena (dejar vacio para conservar)',
loadFailed: 'No se pudieron cargar los usuarios',
deleteConfirm: 'Eliminar este usuario?',
enable: 'Activar',
disable: 'Desactivar',
roles: {
superAdmin: 'Super admin',
admin: 'Admin',
},
status: {
active: 'Activo',
disabled: 'Desactivado',
},
},
// Common
@@ -583,10 +613,10 @@ jobTriggered: 'Job ejecutado',
export: 'Exportar',
rename: 'Renombrar',
delete: 'Eliminar',
switchTo: 'Cambiar a',
switchConfirm: 'Cambiar al perfil "{name}" reiniciara la pasarela. Continuar?',
switchSuccess: 'Se ha cambiado al perfil "{name}"',
switchFailed: 'Error al cambiar de perfil. Es posible que la pasarela necesite un reinicio manual.',
switchTo: 'Cambiar Hermes Profile',
switchConfirm: 'Esto ejecutara `hermes profile use {name}` y cambiara el active profile de Hermes CLI. Continuar?',
switchSuccess: 'Hermes active profile cambiado a "{name}"',
switchFailed: 'Error al cambiar Hermes Profile. Es posible que la pasarela necesite un reinicio manual.',
createSuccess: 'Perfil "{name}" creado',
createFailed: 'Error al crear el perfil',
renameSuccess: 'Perfil renombrado',
@@ -673,7 +703,8 @@ jobTriggered: 'Job ejecutado',
saveFailed: 'Error al guardar',
tabs: {
display: 'Pantalla',
account: 'Cuenta',
account: 'Cuenta actual',
users: 'Gestion de cuentas',
agent: 'Agente',
memory: 'Memoria',
compression: 'Compresion',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Entrez votre jeton d\'acces pour continuer. Retrouvez-le dans les journaux de demarrage du serveur.',
description: 'Entrez votre nom d\'utilisateur et votre mot de passe pour continuer.',
placeholder: 'Jeton d\'acces',
submit: 'Connexion',
tokenRequired: 'Veuillez entrer votre jeton d\'acces',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'Veuillez entrer le nom d\'utilisateur et le mot de passe',
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
tooManyAttempts: 'Trop de tentatives echouees, veuillez reessayer plus tard',
sessionExpired: 'La session a expire. Veuillez vous reconnecter.',
accessDenied: 'Vous n\'avez pas l\'autorisation d\'acceder a cette ressource.',
passwordMismatch: 'Les mots de passe ne correspondent pas',
passwordTooShort: 'Le mot de passe doit contenir au moins 6 caracteres',
setupSuccess: 'Login par mot de passe configure avec succes',
@@ -31,10 +33,38 @@ export default {
newUsername: 'Nouveau nom d\'utilisateur',
usernameChanged: 'Nom d\'utilisateur change avec succes',
usernameTooShort: 'Le nom d\'utilisateur doit contenir au moins 2 caracteres',
setupDescription: 'Configurez un nom d\'utilisateur et un mot de passe pour un login rapide. Le jeton d\'acces reste disponible.',
removeConfirm: 'Voulez-vous vraiment supprimer le login par mot de passe? Vous devrez utiliser le jeton d\'acces.',
setupDescription: 'Gerez le nom d\'utilisateur et le mot de passe utilises pour vous connecter.',
removeConfirm: 'Le login par mot de passe est requis pour les comptes utilisateur.',
passwordLoginNotConfigured: 'Login par mot de passe non configure',
passwordLoginConfigured: 'Login par mot de passe active ({username})',
passwordLoginConfigured: 'Compte actuel : {username}',
},
users: {
title: 'Gestion des comptes',
description: 'Creez des utilisateurs, attribuez des roles et controlez les Profile accessibles aux administrateurs standard.',
create: 'Creer un utilisateur',
edit: 'Modifier l utilisateur',
username: 'Nom d utilisateur',
role: 'Role',
statusLabel: 'Statut',
profiles: 'Profiles',
profilesPlaceholder: 'Selectionner les Profile accessibles',
allProfiles: 'Tous les Profile',
noProfiles: 'Aucun Profile assigne',
lastLogin: 'Derniere connexion',
newPasswordOptional: 'Nouveau mot de passe (laisser vide pour conserver)',
loadFailed: 'Echec du chargement des utilisateurs',
deleteConfirm: 'Supprimer cet utilisateur ?',
enable: 'Activer',
disable: 'Desactiver',
roles: {
superAdmin: 'Super admin',
admin: 'Admin',
},
status: {
active: 'Actif',
disabled: 'Desactive',
},
},
// Common
@@ -583,10 +613,10 @@ jobTriggered: 'Job declenche',
export: 'Exporter',
rename: 'Renommer',
delete: 'Supprimer',
switchTo: 'Passer a',
switchConfirm: 'Le passage au profil "{name}" redemarrera la passerelle. Continuer ?',
switchSuccess: 'Profil "{name}" actif',
switchFailed: 'Echec du changement de profil. La passerelle peut necessiter un redemarrage manuel.',
switchTo: 'Changer Hermes Profile',
switchConfirm: 'Cette action execute `hermes profile use {name}` et change le active profile Hermes CLI. Continuer ?',
switchSuccess: 'Hermes active profile change vers "{name}"',
switchFailed: 'Echec du changement Hermes Profile. La passerelle peut necessiter un redemarrage manuel.',
createSuccess: 'Profil "{name}" cree',
createFailed: 'Echec de la creation du profil',
renameSuccess: 'Profil renomme',
@@ -673,7 +703,8 @@ jobTriggered: 'Job declenche',
saveFailed: 'Echec de l\'enregistrement',
tabs: {
display: 'Affichage',
account: 'Compte',
account: 'Compte actuel',
users: 'Gestion des comptes',
agent: 'Agent',
memory: 'Memoire',
compression: 'Compression',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// ログイン
login: {
title: 'Hermes Web UI',
description: 'アクセストークンを入力して続行してください。サーバーの起動ログで確認できます。',
description: 'ユーザー名とパスワードを入力して続行してください。',
placeholder: 'アクセストークン',
submit: 'ログイン',
tokenRequired: 'アクセストークンを入力してください',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'ユーザー名とパスワードを入力してください',
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
tooManyAttempts: 'ログイン試行回数が多すぎます。しばらくしてからお試しください',
sessionExpired: 'ログインの有効期限が切れました。再度ログインしてください。',
accessDenied: 'このリソースにアクセスする権限がありません。',
passwordMismatch: 'パスワードが一致しません',
passwordTooShort: 'パスワードは6文字以上必要です',
setupSuccess: 'パスワードログインが設定されました',
@@ -31,10 +33,38 @@ export default {
newUsername: '新しいユーザー名',
usernameChanged: 'ユーザー名が変更されました',
usernameTooShort: 'ユーザー名は2文字以上必要です',
setupDescription: 'ユーザー名とパスワードを設定して、簡単にログインできるようにします。アクセストークンは引き続きバックアップとして使用できます。',
removeConfirm: 'パスワードログインを削除しますか?アクセストークンを使用してログインする必要があります。',
setupDescription: 'ログインに使用するユーザー名とパスワードを管理します。',
removeConfirm: 'ユーザーアカウントにはパスワードログインが必要です。',
passwordLoginNotConfigured: 'パスワードログイン未設定',
passwordLoginConfigured: 'パスワードログイン有効({username}',
passwordLoginConfigured: '現在のアカウント:{username}',
},
users: {
title: 'アカウント管理',
description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできる Profile を制御します。',
create: 'ユーザー作成',
edit: 'ユーザー編集',
username: 'ユーザー名',
role: 'ロール',
statusLabel: 'ステータス',
profiles: 'Profiles',
profilesPlaceholder: 'アクセス可能な Profile を選択',
allProfiles: 'すべての Profile',
noProfiles: 'Profile 未割り当て',
lastLogin: '最終ログイン',
newPasswordOptional: '新しいパスワード(空欄なら変更なし)',
loadFailed: 'ユーザー一覧の読み込みに失敗しました',
deleteConfirm: 'このユーザーを削除しますか?',
enable: '有効化',
disable: '無効化',
roles: {
superAdmin: 'スーパー管理者',
admin: '管理者',
},
status: {
active: '有効',
disabled: '無効',
},
},
// 共通
@@ -583,10 +613,10 @@ export default {
export: 'エクスポート',
rename: '名前変更',
delete: '削除',
switchTo: '切り替え',
switchConfirm: 'プロファイル「{name}」に切り替えるとゲートウェイが再起動されます。続行しますか?',
switchSuccess: 'プロファイル「{name}」に切り替えました',
switchFailed: 'プロファイルの切り替えに失敗しました。ゲートウェイの手動再起動が必要な場合があります。',
switchTo: 'Hermes Profile を切り替え',
switchConfirm: '`hermes profile use {name}` を実行し、Hermes CLI の active profile を切り替えます。続行しますか?',
switchSuccess: 'Hermes active profile を「{name}」に切り替えました',
switchFailed: 'Hermes Profile の切り替えに失敗しました。ゲートウェイの手動再起動が必要な場合があります。',
createSuccess: 'プロファイル「{name}」を作成しました',
createFailed: 'プロファイルの作成に失敗しました',
renameSuccess: 'プロファイル名を変更しました',
@@ -673,7 +703,8 @@ export default {
saveFailed: '保存に失敗しました',
tabs: {
display: '表示',
account: 'アカウント',
account: '現在のアカウント',
users: 'アカウント管理',
agent: 'エージェント',
memory: 'メモリ',
compression: '圧縮',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// 로그인
login: {
title: 'Hermes Web UI',
description: '계속하려면 액세스 토큰을 입력하세요. 서버 시작 로그에서 확인할 수 있습니다.',
description: '계속하려면 사용자 이름과 비밀번호를 입력하세요.',
placeholder: '액세스 토큰',
submit: '로그인',
tokenRequired: '액세스 토큰을 입력해 주세요',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
tooManyAttempts: '로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요',
sessionExpired: '로그인이 만료되었습니다. 다시 로그인해 주세요.',
accessDenied: '이 리소스에 접근할 권한이 없습니다.',
passwordMismatch: '비밀번호가 일치하지 않습니다',
passwordTooShort: '비밀번호는 6자 이상이어야 합니다',
setupSuccess: '비밀번호 로그인이 설정되었습니다',
@@ -31,10 +33,38 @@ export default {
newUsername: '새 사용자 이름',
usernameChanged: '사용자 이름이 변경되었습니다',
usernameTooShort: '사용자 이름은 2자 이상이어야 합니다',
setupDescription: '사용자 이름과 비밀번호를 설정하여 편리하게 로그인하세요. 액세스 토큰은 백업으로 계속 사용할 수 있습니다.',
removeConfirm: '비밀번호 로그인을 제거하시겠습니까? 액세스 토큰을 사용하여 로그인해야 합니다.',
setupDescription: '로그인에 사용할 사용자 이름과 비밀번호를 관리합니다.',
removeConfirm: '사용자 계정에는 비밀번호 로그인이 필요합니다.',
passwordLoginNotConfigured: '비밀번호 로그인 미설정',
passwordLoginConfigured: '비밀번호 로그인 활성화됨 ({username})',
passwordLoginConfigured: '현재 계정: {username}',
},
users: {
title: '계정 관리',
description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 Profile 을 제어합니다.',
create: '사용자 만들기',
edit: '사용자 편집',
username: '사용자 이름',
role: '역할',
statusLabel: '상태',
profiles: 'Profiles',
profilesPlaceholder: '접근 가능한 Profile 선택',
allProfiles: '모든 Profile',
noProfiles: '할당된 Profile 없음',
lastLogin: '마지막 로그인',
newPasswordOptional: '새 비밀번호 (비워두면 유지)',
loadFailed: '사용자 목록을 불러오지 못했습니다',
deleteConfirm: '이 사용자를 삭제하시겠습니까?',
enable: '활성화',
disable: '비활성화',
roles: {
superAdmin: '슈퍼 관리자',
admin: '관리자',
},
status: {
active: '활성',
disabled: '비활성',
},
},
// 공통
@@ -583,10 +613,10 @@ export default {
export: '내보내기',
rename: '이름 변경',
delete: '삭제',
switchTo: '전환',
switchConfirm: '프로필 "{name}"(으)로 전환하면 게이트웨이가 재시작됩니다. 계속하시겠습니까?',
switchSuccess: '프로필 "{name}"(으)로 전환되었습니다',
switchFailed: '프로필 전환 실패. 게이트웨이를 수동으로 재시작해야 할 수 있습니다.',
switchTo: 'Hermes Profile 전환',
switchConfirm: '`hermes profile use {name}`를 실행하고 Hermes CLI active profile을 변경합니다. 계속하시겠습니까?',
switchSuccess: 'Hermes active profile이 "{name}"(으)로 전환되었습니다',
switchFailed: 'Hermes Profile 전환 실패. 게이트웨이를 수동으로 재시작해야 할 수 있습니다.',
createSuccess: '프로필 "{name}"이(가) 생성되었습니다',
createFailed: '프로필 생성 실패',
renameSuccess: '프로필 이름이 변경되었습니다',
@@ -673,7 +703,8 @@ export default {
saveFailed: '저장 실패',
tabs: {
display: '표시',
account: '계정',
account: '현재 계정',
users: '계정 관리',
agent: '에이전트',
memory: '메모리',
compression: '압축',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Insira seu token de acesso para continuar. Encontre-o nos logs de inicializacao do servidor.',
description: 'Insira seu nome de usuario e senha para continuar.',
placeholder: 'Token de acesso',
submit: 'Entrar',
tokenRequired: 'Por favor, insira seu token de acesso',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: 'Por favor, insira nome de usuario e senha',
invalidCredentials: 'Nome de usuario ou senha incorretos',
tooManyAttempts: 'Muitas tentativas falhadas, por favor tente novamente mais tarde',
sessionExpired: 'Login expirado. Entre novamente.',
accessDenied: 'Voce nao tem permissao para acessar este recurso.',
passwordMismatch: 'As senhas nao conferem',
passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
setupSuccess: 'Login por senha configurado com sucesso',
@@ -31,10 +33,38 @@ export default {
newUsername: 'Novo nome de usuario',
usernameChanged: 'Nome de usuario alterado com sucesso',
usernameTooShort: 'O nome de usuario deve ter pelo menos 2 caracteres',
setupDescription: 'Configure um nome de usuario e senha para login conveniente. O token de acesso continuara funcionando como backup.',
removeConfirm: 'Tem certeza de que deseja remover o login por senha? Voce precisara usar o token de acesso.',
setupDescription: 'Gerencie o nome de usuario e a senha usados para entrar.',
removeConfirm: 'Login por senha e obrigatorio para contas de usuario.',
passwordLoginNotConfigured: 'Login por senha nao configurado',
passwordLoginConfigured: 'Login por senha habilitado ({username})',
passwordLoginConfigured: 'Conta atual: {username}',
},
users: {
title: 'Gerenciamento de contas',
description: 'Crie usuarios, atribua funcoes e controle quais Profile administradores comuns podem acessar.',
create: 'Criar usuario',
edit: 'Editar usuario',
username: 'Nome de usuario',
role: 'Funcao',
statusLabel: 'Status',
profiles: 'Profiles',
profilesPlaceholder: 'Selecione Profile acessiveis',
allProfiles: 'Todos os Profile',
noProfiles: 'Nenhum Profile atribuido',
lastLogin: 'Ultimo login',
newPasswordOptional: 'Nova senha (deixe em branco para manter)',
loadFailed: 'Falha ao carregar usuarios',
deleteConfirm: 'Excluir este usuario?',
enable: 'Ativar',
disable: 'Desativar',
roles: {
superAdmin: 'Super admin',
admin: 'Admin',
},
status: {
active: 'Ativo',
disabled: 'Desativado',
},
},
// Common
@@ -583,10 +613,10 @@ jobTriggered: 'Job acionado',
export: 'Exportar',
rename: 'Renomear',
delete: 'Excluir',
switchTo: 'Mudar para',
switchConfirm: 'Mudar para o perfil "{name}" reiniciara o gateway. Continuar?',
switchSuccess: 'Mudou para o perfil "{name}"',
switchFailed: 'Falha ao mudar de perfil. O gateway pode precisar de reinicio manual.',
switchTo: 'Trocar Hermes Profile',
switchConfirm: 'Isto executara `hermes profile use {name}` e alterara o active profile do Hermes CLI. Continuar?',
switchSuccess: 'Hermes active profile alterado para "{name}"',
switchFailed: 'Falha ao trocar Hermes Profile. O gateway pode precisar de reinicio manual.',
createSuccess: 'Perfil "{name}" criado',
createFailed: 'Falha ao criar o perfil',
renameSuccess: 'Perfil renomeado',
@@ -673,7 +703,8 @@ jobTriggered: 'Job acionado',
saveFailed: 'Falha ao salvar',
tabs: {
display: 'Exibicao',
account: 'Conta',
account: 'Conta atual',
users: 'Gerenciamento de contas',
agent: 'Agente',
memory: 'Memoria',
compression: 'Compressao',
+41 -10
View File
@@ -2,7 +2,7 @@ export default {
// 登入
login: {
title: 'Hermes Web UI',
description: '輸入存取權杖以繼續。權杖可在伺服器啟動日誌中查看。',
description: '輸入使用者名稱和密碼以繼續。',
placeholder: '存取權杖',
submit: '登入',
tokenRequired: '請輸入存取權杖',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: '請輸入使用者名稱和密碼',
invalidCredentials: '使用者名稱或密碼錯誤',
tooManyAttempts: '登入失敗次數過多,請稍後再試',
sessionExpired: '登入已過期,請重新登入',
accessDenied: '你沒有權限存取此資源',
passwordMismatch: '兩次密碼不一致',
passwordTooShort: '密碼長度至少 6 個字元',
setupSuccess: '密碼登入設定成功',
@@ -31,10 +33,38 @@ export default {
newUsername: '新使用者名稱',
usernameChanged: '使用者名稱修改成功',
usernameTooShort: '使用者名稱至少 2 個字元',
setupDescription: '設定使用者名稱和密碼以便快速登入。存取權杖仍可繼續使用。',
removeConfirm: '確定要移除密碼登入嗎?移除後需使用存取權杖登入。',
setupDescription: '管理用於登入的使用者名稱和密碼。',
removeConfirm: '使用者帳號必須保留密碼登入。',
passwordLoginNotConfigured: '密碼登入未設定',
passwordLoginConfigured: '密碼登入已啟用({username}',
passwordLoginConfigured: '目前帳號:{username}',
},
users: {
title: '帳號管理',
description: '建立使用者、分配角色,並控制一般管理員可存取的 Profile。',
create: '建立使用者',
edit: '編輯使用者',
username: '使用者名稱',
role: '角色',
statusLabel: '狀態',
profiles: 'Profiles',
profilesPlaceholder: '選擇可存取的 Profile',
allProfiles: '全部 Profile',
noProfiles: '未關聯 Profile',
lastLogin: '最後登入',
newPasswordOptional: '新密碼(留空不修改)',
loadFailed: '使用者列表載入失敗',
deleteConfirm: '確認刪除此使用者?',
enable: '啟用',
disable: '停用',
roles: {
superAdmin: '超級管理員',
admin: '一般管理員',
},
status: {
active: '啟用',
disabled: '停用',
},
},
// 通用
@@ -678,10 +708,10 @@ export default {
export: '匯出',
rename: '重新命名',
delete: '刪除',
switchTo: '切換',
switchConfirm: '切換至設定檔「{name}」將重新啟動閘道,是否繼續?',
switchSuccess: '已切換至設定檔「{name}」',
switchFailed: '切換設定檔失敗,閘道可能需要手動重新啟動',
switchTo: '切換 Hermes Profile',
switchConfirm: '將執行 `hermes profile use {name}` 並切換 Hermes CLI 的 active profile,是否繼續?',
switchSuccess: 'Hermes active profile 已切換為「{name}」',
switchFailed: '切換 Hermes Profile 失敗,閘道可能需要手動重新啟動',
createSuccess: '設定檔「{name}」已建立',
createFailed: '建立設定檔失敗',
renameSuccess: '設定檔已重新命名',
@@ -744,7 +774,7 @@ export default {
stopped: '已停止',
restartGateway: '重啟閘道',
restartProfile: '重啟設定檔',
switchProfile: '切換設定檔',
switchProfile: '切換前端 Profile',
gatewayRestarted: '閘道已重啟:{name}',
gatewayRestartFailed: '重啟閘道失敗',
profileRestarted: '設定檔已重啟:{name}',
@@ -768,7 +798,8 @@ export default {
saveFailed: '儲存失敗',
tabs: {
display: '顯示',
account: '帳號',
account: '目前帳號',
users: '帳號管理',
agent: '代理',
memory: '記憶',
compression: '上下文壓縮',
+40 -9
View File
@@ -2,7 +2,7 @@ export default {
// 登录
login: {
title: 'Hermes Web UI',
description: '输入访问令牌以继续。令牌在服务端启动日志中查看。',
description: '输入用户名和密码以继续。',
placeholder: '访问令牌',
submit: '登录',
tokenRequired: '请输入访问令牌',
@@ -15,6 +15,8 @@ export default {
credentialsRequired: '请输入用户名和密码',
invalidCredentials: '用户名或密码错误',
tooManyAttempts: '登录失败次数过多,请稍后重试',
sessionExpired: '登录已过期,请重新登录',
accessDenied: '你没有权限访问该资源',
passwordMismatch: '两次密码不一致',
passwordTooShort: '密码长度至少 6 个字符',
setupSuccess: '密码登录配置成功',
@@ -31,10 +33,38 @@ export default {
newUsername: '新用户名',
usernameChanged: '用户名修改成功',
usernameTooShort: '用户名至少 2 个字符',
setupDescription: '设置用户名和密码以便快速登录。访问令牌仍可继续使用。',
removeConfirm: '确定要移除密码登录吗?移除后需要使用访问令牌登录。',
setupDescription: '管理用于登录的用户名和密码。',
removeConfirm: '用户账号必须保留密码登录。',
passwordLoginNotConfigured: '密码登录未配置',
passwordLoginConfigured: '密码登录已启用({username}',
passwordLoginConfigured: '当前账户:{username}',
},
users: {
title: '账户管理',
description: '创建用户、分配角色,并控制普通管理员可访问的 Profile。',
create: '创建用户',
edit: '编辑用户',
username: '用户名',
role: '角色',
statusLabel: '状态',
profiles: 'Profiles',
profilesPlaceholder: '选择可访问的 Profile',
allProfiles: '全部 Profile',
noProfiles: '未关联 Profile',
lastLogin: '最后登录',
newPasswordOptional: '新密码(留空不修改)',
loadFailed: '用户列表加载失败',
deleteConfirm: '确认删除该用户?',
enable: '启用',
disable: '禁用',
roles: {
superAdmin: '超级管理员',
admin: '普通管理员',
},
status: {
active: '启用',
disabled: '禁用',
},
},
// 通用
@@ -678,10 +708,10 @@ export default {
export: '导出',
rename: '重命名',
delete: '删除',
switchTo: '切换',
switchConfirm: '切换到配置 "{name}" 将重启网关,是否继续?',
switchSuccess: '已切换到配置 "{name}"',
switchFailed: '切换配置失败,网关可能需要手动重启',
switchTo: '切换 Hermes Profile',
switchConfirm: '将执行 `hermes profile use {name}` 并切换 Hermes CLI 的 active profile,是否继续?',
switchSuccess: 'Hermes active profile 已切换为 "{name}"',
switchFailed: '切换 Hermes Profile 失败,网关可能需要手动重启',
createSuccess: '配置 "{name}" 已创建',
createFailed: '创建配置失败',
renameSuccess: '配置已重命名',
@@ -768,7 +798,8 @@ export default {
saveFailed: '保存失败',
tabs: {
display: '显示',
account: '账户',
account: '当前账户',
users: '账户管理',
agent: '代理',
memory: '记忆',
compression: '上下文压缩',
+8 -1
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { hasApiKey } from '@/api/client'
import { hasApiKey, isStoredSuperAdmin } from '@/api/client'
const router = createRouter({
history: createWebHashHistory(),
@@ -39,6 +39,7 @@ const router = createRouter({
path: '/hermes/profiles',
name: 'hermes.profiles',
component: () => import('@/views/hermes/ProfilesView.vue'),
meta: { requiresSuperAdmin: true },
},
{
path: '/hermes/logs',
@@ -54,6 +55,7 @@ const router = createRouter({
path: '/hermes/performance',
name: 'hermes.performance',
component: () => import('@/views/hermes/PerformanceView.vue'),
meta: { requiresSuperAdmin: true },
},
{
path: '/hermes/skills-usage',
@@ -121,6 +123,11 @@ router.beforeEach((to, _from, next) => {
return
}
if (to.meta.requiresSuperAdmin && !isStoredSuperAdmin()) {
next({ name: 'hermes.chat' })
return
}
next()
})
+48 -38
View File
@@ -19,11 +19,23 @@ export const useProfilesStore = defineStore('profiles', () => {
loading.value = true
try {
profiles.value = await profilesApi.fetchProfiles()
activeProfile.value = profiles.value.find(p => p.active) ?? null
// 同步缓存 profile name,供其他 store 启动时读取
if (activeProfile.value) {
activeProfileName.value = activeProfile.value.name
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, activeProfile.value.name)
const storedName = activeProfileName.value || localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY)
let selected = profiles.value.find(p => p.name === storedName) ?? null
if (!selected && profiles.value.length > 0) {
selected = profiles.value[0]
activeProfileName.value = selected.name
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, selected.name)
}
profiles.value = profiles.value.map(profile => ({
...profile,
active: !!selected && profile.name === selected.name,
}))
activeProfile.value = selected
if (selected) {
activeProfileName.value = selected.name
} else {
activeProfileName.value = null
localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY)
}
// 清理所有会话缓存(不再使用 localStorage 缓存)
clearAllSessionCaches()
@@ -34,6 +46,19 @@ export const useProfilesStore = defineStore('profiles', () => {
}
}
async function fetchHermesProfiles() {
loading.value = true
try {
profiles.value = await profilesApi.fetchProfiles()
activeProfile.value = profiles.value.find(profile => profile.active) ?? null
clearAllSessionCaches()
} catch (err) {
console.error('Failed to fetch Hermes profiles:', err)
} finally {
loading.value = false
}
}
async function fetchProfileDetail(name: string) {
if (detailMap.value[name]) return detailMap.value[name]
try {
@@ -107,41 +132,13 @@ export const useProfilesStore = defineStore('profiles', () => {
try {
const ok = await profilesApi.switchProfile(name)
if (ok) {
// 保存旧值,用于可能的回滚
const oldName = activeProfileName.value
// 立即更新 activeProfileName,确保前端显示正确
// 不要完全依赖 fetchProfiles 的返回值,以防后端数据同步延迟
activeProfileName.value = name
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, name)
// 尝试刷新 profiles 列表并验证
try {
await fetchProfiles()
// 验证:检查后端返回的 active profile 是否与我们期望的一致
// 如果不一致,说明后端实际上没有切换成功,需要回滚
const actualActive = profiles.value.find(p => p.active)
if (actualActive && actualActive.name !== name) {
console.warn(
`[switchProfile] Backend verification failed: expected active profile "${name}", ` +
`but backend reports "${actualActive.name}". Rolling back frontend state.`
)
// 回滚到旧值
activeProfileName.value = oldName
if (oldName) {
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, oldName)
} else {
localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY)
}
// 返回 false 以触发 UI 错误提示
return false
}
} catch (err) {
// fetchProfiles 失败,无法验证
// 假设切换成功(API 返回了 200),保持已设置的状态
console.warn('Failed to refresh profiles list after switch, assuming switch succeeded:', err)
}
profiles.value = profiles.value.map(profile => ({
...profile,
active: profile.name === name,
}))
activeProfile.value = profiles.value.find(profile => profile.name === name) ?? null
await useAppStore().reloadModels()
}
return ok
@@ -150,6 +147,17 @@ export const useProfilesStore = defineStore('profiles', () => {
}
}
async function switchHermesProfile(name: string) {
switching.value = true
try {
const ok = await profilesApi.switchHermesProfile(name)
if (ok) await fetchHermesProfiles()
return ok
} finally {
switching.value = false
}
}
async function exportProfile(name: string) {
return profilesApi.exportProfile(name)
}
@@ -168,11 +176,13 @@ export const useProfilesStore = defineStore('profiles', () => {
loading,
switching,
fetchProfiles,
fetchHermesProfiles,
fetchProfileDetail,
createProfile,
deleteProfile,
renameProfile,
switchProfile,
switchHermesProfile,
exportProfile,
importProfile,
updateAvatar,
+17 -125
View File
@@ -8,19 +8,11 @@ import { fetchAuthStatus, loginWithPassword } from "@/api/auth";
const { t } = useI18n();
const router = useRouter();
// Read token saved by main.ts (before router strips URL params)
const urlToken = (window as any).__LOGIN_TOKEN__ || "";
const token = ref(urlToken);
const username = ref("");
const password = ref("");
const loading = ref(false);
const errorMsg = ref("");
// Login method: 'token' or 'password'
const loginMethod = ref<"token" | "password">("token");
const hasPasswordLogin = ref(false);
// If already has a key, try to go to main page
if (hasApiKey()) {
router.replace("/hermes/chat");
@@ -28,58 +20,14 @@ if (hasApiKey()) {
onMounted(async () => {
try {
const status = await fetchAuthStatus();
hasPasswordLogin.value = status.hasPasswordLogin;
if (status.hasPasswordLogin && !urlToken) {
loginMethod.value = "password";
}
await fetchAuthStatus();
} catch {
// Fallback to token-only
// Login remains available; the submit request will surface connection errors.
}
});
async function handleLogin() {
if (loginMethod.value === "token") {
await handleTokenLogin();
} else {
await handlePasswordLogin();
}
}
async function handleTokenLogin() {
const key = token.value.trim();
if (!key) {
errorMsg.value = t("login.tokenRequired");
return;
}
loading.value = true;
errorMsg.value = "";
try {
const res = await fetch("/api/hermes/sessions", {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 401) {
errorMsg.value = t("login.invalidToken");
loading.value = false;
return;
}
if (res.status === 429 || res.status === 503) {
errorMsg.value = t("login.tooManyAttempts");
loading.value = false;
return;
}
setApiKey(key);
router.replace("/hermes/chat");
} catch {
errorMsg.value = t("login.connectionFailed");
} finally {
loading.value = false;
}
await handlePasswordLogin();
}
async function handlePasswordLogin() {
@@ -116,49 +64,21 @@ async function handlePasswordLogin() {
<h1 class="login-title">{{ t("login.title") }}</h1>
<p class="login-desc">{{ t("login.description") }}</p>
<!-- Method toggle -->
<div v-if="hasPasswordLogin" class="login-method-toggle">
<button
class="toggle-btn"
:class="{ active: loginMethod === 'password' }"
@click="loginMethod = 'password'"
>{{ t("login.passwordLogin") }}</button>
<button
class="toggle-btn"
:class="{ active: loginMethod === 'token' }"
@click="loginMethod = 'token'"
>{{ t("login.tokenLogin") }}</button>
</div>
<form class="login-form" @submit.prevent="handleLogin">
<!-- Token login -->
<template v-if="loginMethod === 'token'">
<input
v-model="token"
type="password"
class="login-input"
:placeholder="t('login.placeholder')"
autofocus
/>
</template>
<!-- Password login -->
<template v-if="loginMethod === 'password'">
<input
v-model="username"
type="text"
class="login-input"
:placeholder="t('login.usernamePlaceholder')"
autofocus
/>
<input
v-model="password"
type="password"
class="login-input"
:placeholder="t('login.passwordPlaceholder')"
@keyup.enter="handleLogin"
/>
</template>
<input
v-model="username"
type="text"
class="login-input"
:placeholder="t('login.usernamePlaceholder')"
autofocus
/>
<input
v-model="password"
type="password"
class="login-input"
:placeholder="t('login.passwordPlaceholder')"
@keyup.enter="handleLogin"
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button type="submit" class="login-btn" :disabled="loading">
@@ -212,34 +132,6 @@ async function handlePasswordLogin() {
line-height: 1.6;
}
.login-method-toggle {
display: flex;
margin-bottom: 24px;
border: 1px solid $border-color;
border-radius: $radius-sm;
overflow: hidden;
.toggle-btn {
flex: 1;
padding: 10px;
border: none;
background: transparent;
color: $text-muted;
font-size: 13px;
cursor: pointer;
transition: all $transition-fast;
&.active {
background: $text-primary;
color: var(--text-on-accent);
}
&:not(.active):hover {
background: rgba(var(--accent-primary-rgb), 0.06);
}
}
}
.login-form {
display: flex;
flex-direction: column;
@@ -49,9 +49,15 @@ const showSessions = ref(
let mobileQuery: MediaQueryList | null = null
const isMobile = ref(false)
async function handleSessionClick(sessionId: string) {
function findHistorySession(sessionId: string): SessionSummary | undefined {
return hermesSessions.value.find(session => session.id === sessionId)
}
async function handleSessionClick(sessionId: string, profile?: string | null) {
const summary = findHistorySession(sessionId)
const sessionProfile = profile || summary?.profile || null
// First, fetch the Hermes session detail
const sessionDetail = await fetchHermesSession(sessionId)
const sessionDetail = await fetchHermesSession(sessionId, sessionProfile)
if (!sessionDetail) {
message.error(t('chat.sessionNotFound'))
return
@@ -60,6 +66,7 @@ async function handleSessionClick(sessionId: string) {
// Convert SessionDetail to Session format and add to chatStore
const sessionData: Session = {
id: sessionDetail.id,
profile: sessionDetail.profile || sessionProfile || undefined,
title: sessionDetail.title || '',
source: sessionDetail.source,
createdAt: sessionDetail.started_at * 1000,
@@ -132,7 +139,7 @@ const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem
function sessionSummaryToSession(summary: SessionSummary): Session {
return {
id: summary.id,
profile: summary.profile,
profile: summary.profile || undefined,
title: summary.title || '',
source: summary.source,
createdAt: summary.started_at * 1000,
@@ -212,7 +219,7 @@ function toggleGroup(source: string) {
const group = groupedSessions.value.find(g => g.source === source)
if (group?.sessions.length) {
// Auto-select and load first session when expanding group
handleSessionClick(group.sessions[0].id)
handleSessionClick(group.sessions[0].id, group.sessions[0].profile)
}
}
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
@@ -247,7 +254,7 @@ watch(hermesSessionsLoaded, (loaded) => {
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
}
// Load session details
handleSessionClick(firstCliSession.id)
handleSessionClick(firstCliSession.id, firstCliSession.profile)
}
// If no CLI session exists, don't auto-load any session
}
@@ -271,8 +278,10 @@ async function copySessionId(id?: string) {
}
}
async function handleDeleteSession(id: string) {
const ok = await deleteSession(id)
async function handleDeleteSession(id: string, profile?: string | null) {
const summary = findHistorySession(id)
const sessionProfile = profile || summary?.profile || null
const ok = await deleteSession(id, sessionProfile)
if (!ok) {
message.error(t('common.deleteFailed'))
return
@@ -285,7 +294,7 @@ async function handleDeleteSession(id: string) {
historySessionId.value = null
historySession.value = null
const next = historySessions.value[0]
if (next) await handleSessionClick(next.id)
if (next) await handleSessionClick(next.id, next.profile)
}
message.success(t('chat.sessionDeleted'))
@@ -326,8 +335,8 @@ async function handleDeleteSession(id: string) {
:can-delete="true"
:streaming="false"
:show-profile="false"
@select="handleSessionClick(s.id)"
@delete="handleDeleteSession(s.id)"
@select="handleSessionClick(s.id, s.profile)"
@delete="handleDeleteSession(s.id, s.profile)"
/>
</template>
@@ -347,8 +356,8 @@ async function handleDeleteSession(id: string) {
:can-delete="true"
:streaming="false"
:show-profile="false"
@select="handleSessionClick(s.id)"
@delete="handleDeleteSession(s.id)"
@select="handleSessionClick(s.id, s.profile)"
@delete="handleDeleteSession(s.id, s.profile)"
/>
</template>
</template>
@@ -16,7 +16,7 @@ const showImportModal = ref(false)
const renamingProfile = ref<string | null>(null)
onMounted(() => {
profilesStore.fetchProfiles()
profilesStore.fetchHermesProfiles()
})
function handleCreated() {
@@ -15,10 +15,13 @@ import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
import ModelSettings from "@/components/hermes/settings/ModelSettings.vue";
import AccountSettings from "@/components/hermes/settings/AccountSettings.vue";
import UserManagementSettings from "@/components/hermes/settings/UserManagementSettings.vue";
import VoiceSettings from "@/components/hermes/settings/VoiceSettings.vue";
import { isStoredSuperAdmin } from "@/api/client";
const settingsStore = useSettingsStore();
const { t } = useI18n();
const canManageUsers = isStoredSuperAdmin();
onMounted(() => {
settingsStore.fetchSettings();
@@ -41,6 +44,9 @@ onMounted(() => {
<NTabPane name="account" :tab="t('settings.tabs.account')">
<AccountSettings />
</NTabPane>
<NTabPane v-if="canManageUsers" name="users" :tab="t('settings.tabs.users')">
<UserManagementSettings />
</NTabPane>
<NTabPane name="display" :tab="t('settings.tabs.display')">
<DisplaySettings />
</NTabPane>