Add user-scoped Hermes profile access
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '圧縮',
|
||||
|
||||
@@ -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: '압축',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '上下文壓縮',
|
||||
|
||||
@@ -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: '上下文压缩',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,24 +1,68 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
|
||||
import { getToken } from '../services/auth'
|
||||
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
||||
import {
|
||||
DEFAULT_USERNAME,
|
||||
bootstrapDefaultSuperAdmin,
|
||||
countActiveSuperAdmins,
|
||||
countUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
findFirstUser,
|
||||
findUserById,
|
||||
findUserByUsername,
|
||||
listUsers,
|
||||
updateUser,
|
||||
updateUsername,
|
||||
updateUserPassword,
|
||||
verifyPassword,
|
||||
type UserRole,
|
||||
type UserStatus,
|
||||
} from '../db/hermes/users-store'
|
||||
import { issueUserJwt } from '../middleware/user-auth'
|
||||
import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile'
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Check if username/password login is configured (public).
|
||||
*/
|
||||
export async function authStatus(ctx: Context) {
|
||||
const cred = await getCredentials()
|
||||
const firstUser = findFirstUser()
|
||||
ctx.body = {
|
||||
hasPasswordLogin: !!cred,
|
||||
username: cred?.username || null,
|
||||
hasPasswordLogin: true,
|
||||
username: firstUser?.username || DEFAULT_USERNAME,
|
||||
hasUsers: countUsers() > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Return the authenticated account.
|
||||
*/
|
||||
export async function currentUser(ctx: Context) {
|
||||
const userId = ctx.state.user?.id
|
||||
const user = userId ? findUserById(userId) : null
|
||||
if (!user) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'User not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
last_login_at: user.last_login_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate with username/password (public).
|
||||
* Returns the static token on success.
|
||||
* Returns a user-scoped JWT on success.
|
||||
*/
|
||||
export async function login(ctx: Context) {
|
||||
const { username, password } = ctx.request.body as { username?: string; password?: string }
|
||||
@@ -36,18 +80,24 @@ export async function login(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await verifyCredentials(username, password)
|
||||
if (!valid) {
|
||||
const existingUserCount = countUsers()
|
||||
const user = existingUserCount === 0
|
||||
? bootstrapDefaultSuperAdmin(username, password)
|
||||
: findUserByUsername(username)
|
||||
|
||||
if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) {
|
||||
recordPasswordFailure(ip)
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Invalid username or password' }
|
||||
return
|
||||
}
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) {
|
||||
let token: string
|
||||
try {
|
||||
token = await issueUserJwt(user)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Auth is disabled on this server' }
|
||||
ctx.body = { error: err?.message || 'Auth is disabled on this server' }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,25 +110,8 @@ export async function login(ctx: Context) {
|
||||
* Set up username/password (protected).
|
||||
*/
|
||||
export async function setupPassword(ctx: Context) {
|
||||
const { username, password } = ctx.request.body as { username?: string; password?: string }
|
||||
if (!username || !password) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Username and password are required' }
|
||||
return
|
||||
}
|
||||
if (username.length < 2) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Username must be at least 2 characters' }
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password must be at least 6 characters' }
|
||||
return
|
||||
}
|
||||
|
||||
await setCredentials(username, password)
|
||||
ctx.body = { success: true }
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login is managed by user accounts' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,22 +131,15 @@ export async function changePassword(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
const cred = await getCredentials()
|
||||
if (!cred) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login not configured' }
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password — use the username from stored credentials
|
||||
const valid = await verifyCredentials(cred.username, currentPassword)
|
||||
if (!valid) {
|
||||
const userId = ctx.state.user?.id
|
||||
const user = userId ? findUserById(userId) : null
|
||||
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Current password is incorrect' }
|
||||
return
|
||||
}
|
||||
|
||||
await setCredentials(cred.username, newPassword)
|
||||
updateUserPassword(user.id, newPassword)
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
@@ -134,22 +160,22 @@ export async function changeUsername(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
const cred = await getCredentials()
|
||||
if (!cred) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login not configured' }
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await verifyCredentials(cred.username, currentPassword)
|
||||
if (!valid) {
|
||||
const userId = ctx.state.user?.id
|
||||
const user = userId ? findUserById(userId) : null
|
||||
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Current password is incorrect' }
|
||||
return
|
||||
}
|
||||
|
||||
// Update username, keep the same password
|
||||
await setCredentials(newUsername, currentPassword)
|
||||
const existing = findUserByUsername(newUsername)
|
||||
if (existing && existing.id !== user.id) {
|
||||
ctx.status = 409
|
||||
ctx.body = { error: 'Username already exists' }
|
||||
return
|
||||
}
|
||||
|
||||
updateUsername(user.id, newUsername)
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
@@ -158,8 +184,211 @@ export async function changeUsername(ctx: Context) {
|
||||
* Remove username/password login (protected).
|
||||
*/
|
||||
export async function removePassword(ctx: Context) {
|
||||
await deleteCredentials()
|
||||
ctx.body = { success: true }
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login cannot be removed for user accounts' }
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): UserRole | null {
|
||||
return value === 'super_admin' || value === 'admin' ? value : null
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): UserStatus | null {
|
||||
return value === 'active' || value === 'disabled' ? value : null
|
||||
}
|
||||
|
||||
function normalizeProfiles(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function validateProfiles(profiles: string[]): string | null {
|
||||
const available = new Set(listProfileNamesFromDisk())
|
||||
const missing = profiles.find(profile => !available.has(profile))
|
||||
return missing || null
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/users
|
||||
* Super admin user management list.
|
||||
*/
|
||||
export async function listManagedUsers(ctx: Context) {
|
||||
ctx.body = {
|
||||
users: listUsers(),
|
||||
profiles: listProfileNamesFromDisk(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/users
|
||||
* Create a user account. Super admin only.
|
||||
*/
|
||||
export async function createManagedUser(ctx: Context) {
|
||||
const body = ctx.request.body as {
|
||||
username?: string
|
||||
password?: string
|
||||
role?: unknown
|
||||
status?: unknown
|
||||
profiles?: unknown
|
||||
defaultProfile?: string | null
|
||||
}
|
||||
const username = String(body.username || '').trim()
|
||||
const password = String(body.password || '')
|
||||
const role = normalizeRole(body.role || 'admin')
|
||||
const status = normalizeStatus(body.status || 'active')
|
||||
const profiles = normalizeProfiles(body.profiles)
|
||||
|
||||
if (username.length < 2) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Username must be at least 2 characters' }
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password must be at least 6 characters' }
|
||||
return
|
||||
}
|
||||
if (!role || !status) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid role or status' }
|
||||
return
|
||||
}
|
||||
if (findUserByUsername(username)) {
|
||||
ctx.status = 409
|
||||
ctx.body = { error: 'Username already exists' }
|
||||
return
|
||||
}
|
||||
|
||||
const missingProfile = validateProfiles(profiles)
|
||||
if (missingProfile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
|
||||
return
|
||||
}
|
||||
|
||||
const user = createUser({
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
status,
|
||||
profiles: role === 'super_admin' ? [] : profiles,
|
||||
defaultProfile: body.defaultProfile,
|
||||
})
|
||||
ctx.status = 201
|
||||
ctx.body = { user, users: listUsers() }
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/auth/users/:id
|
||||
* Update user account metadata, password, and profile bindings.
|
||||
*/
|
||||
export async function updateManagedUser(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
const user = Number.isInteger(id) ? findUserById(id) : null
|
||||
if (!user) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'User not found' }
|
||||
return
|
||||
}
|
||||
|
||||
const body = ctx.request.body as {
|
||||
username?: string
|
||||
password?: string
|
||||
role?: unknown
|
||||
status?: unknown
|
||||
profiles?: unknown
|
||||
defaultProfile?: string | null
|
||||
}
|
||||
const username = body.username == null ? undefined : String(body.username).trim()
|
||||
const password = body.password == null ? undefined : String(body.password)
|
||||
const role = body.role == null ? undefined : normalizeRole(body.role)
|
||||
const status = body.status == null ? undefined : normalizeStatus(body.status)
|
||||
const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles)
|
||||
|
||||
if (username !== undefined && username.length < 2) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Username must be at least 2 characters' }
|
||||
return
|
||||
}
|
||||
if (password !== undefined && password.length > 0 && password.length < 6) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password must be at least 6 characters' }
|
||||
return
|
||||
}
|
||||
if (body.role != null && !role || body.status != null && !status) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid role or status' }
|
||||
return
|
||||
}
|
||||
if (username && username !== user.username) {
|
||||
const existing = findUserByUsername(username)
|
||||
if (existing && existing.id !== user.id) {
|
||||
ctx.status = 409
|
||||
ctx.body = { error: 'Username already exists' }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const nextRole = role || user.role
|
||||
const nextStatus = status || user.status
|
||||
const currentUserId = ctx.state.user?.id
|
||||
if (user.id === currentUserId && nextStatus !== 'active') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'You cannot disable your own account' }
|
||||
return
|
||||
}
|
||||
if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'At least one active super administrator is required' }
|
||||
return
|
||||
}
|
||||
|
||||
if (profiles) {
|
||||
const missingProfile = validateProfiles(profiles)
|
||||
if (missingProfile) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateUser({
|
||||
userId: user.id,
|
||||
username,
|
||||
password: password || undefined,
|
||||
role: role || undefined,
|
||||
status: status || undefined,
|
||||
profiles: nextRole === 'super_admin' ? [] : profiles,
|
||||
defaultProfile: body.defaultProfile,
|
||||
})
|
||||
ctx.body = { user: findUserById(user.id), users: listUsers() }
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/users/:id
|
||||
* Delete a user account. Super admin only.
|
||||
*/
|
||||
export async function deleteManagedUser(ctx: Context) {
|
||||
const id = Number(ctx.params.id)
|
||||
const user = Number.isInteger(id) ? findUserById(id) : null
|
||||
if (!user) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'User not found' }
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.state.user?.id === user.id) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'You cannot delete your own account' }
|
||||
return
|
||||
}
|
||||
if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'At least one active super administrator is required' }
|
||||
return
|
||||
}
|
||||
|
||||
deleteUser(user.id)
|
||||
ctx.body = { success: true, users: listUsers() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,85 @@ import {
|
||||
getExactSessionDetailFromDbWithProfile,
|
||||
findLatestExactSessionIdWithProfile,
|
||||
} from '../../db/hermes/sessions-db'
|
||||
import { listUserProfiles } from '../../db/hermes/users-store'
|
||||
|
||||
const DEFAULT_PROFILE = 'default'
|
||||
|
||||
function profileName(value: string | null | undefined): string {
|
||||
return value?.trim() || DEFAULT_PROFILE
|
||||
}
|
||||
|
||||
function requestedProfile(ctx: Context): string | null {
|
||||
return ctx.state?.profile?.name || null
|
||||
}
|
||||
|
||||
function allowedProfileSet(ctx: Context): Set<string> | null {
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return null
|
||||
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
||||
}
|
||||
|
||||
function visibleProfileSet(ctx: Context): Set<string> | null {
|
||||
const profile = requestedProfile(ctx)
|
||||
if (profile) return new Set([profile])
|
||||
return allowedProfileSet(ctx)
|
||||
}
|
||||
|
||||
function canUseProfile(ctx: Context, profile: string | null | undefined): boolean {
|
||||
const allowed = allowedProfileSet(ctx)
|
||||
return !allowed || allowed.has(profileName(profile))
|
||||
}
|
||||
|
||||
function denyProfileAccess(ctx: Context, profile: string | null | undefined): boolean {
|
||||
if (canUseProfile(ctx, profile)) return false
|
||||
ctx.status = 403
|
||||
ctx.body = { error: `Profile "${profileName(profile)}" is not available for this user` }
|
||||
return true
|
||||
}
|
||||
|
||||
function taskAssigneeProfile(task: { assignee: string | null }): string {
|
||||
return profileName(task.assignee)
|
||||
}
|
||||
|
||||
function filterTasksByVisibleProfiles(ctx: Context, tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanTask[] {
|
||||
const visible = visibleProfileSet(ctx)
|
||||
if (!visible) return tasks
|
||||
return tasks.filter(task => visible.has(taskAssigneeProfile(task)))
|
||||
}
|
||||
|
||||
function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats {
|
||||
const by_status: Record<string, number> = {}
|
||||
const by_assignee: Record<string, number> = {}
|
||||
for (const task of tasks) {
|
||||
by_status[task.status] = (by_status[task.status] || 0) + 1
|
||||
const assignee = taskAssigneeProfile(task)
|
||||
by_assignee[assignee] = (by_assignee[assignee] || 0) + 1
|
||||
}
|
||||
return { by_status, by_assignee, total: tasks.length }
|
||||
}
|
||||
|
||||
function filterAssigneesByVisibleProfiles(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] {
|
||||
const visible = visibleProfileSet(ctx)
|
||||
if (!visible) return assignees
|
||||
return assignees.filter(assignee => visible.has(profileName(assignee.name)))
|
||||
}
|
||||
|
||||
async function getVisibleTasksForBoard(ctx: Context, board: string, opts: {
|
||||
status?: string
|
||||
assignee?: string
|
||||
tenant?: string
|
||||
includeArchived?: boolean
|
||||
} = {}): Promise<kanbanCli.KanbanTask[]> {
|
||||
if (opts.assignee && denyProfileAccess(ctx, opts.assignee)) return []
|
||||
const tasks = await kanbanCli.listTasks({
|
||||
board,
|
||||
status: opts.status,
|
||||
assignee: opts.assignee,
|
||||
tenant: opts.tenant,
|
||||
includeArchived: opts.includeArchived,
|
||||
})
|
||||
return filterTasksByVisibleProfiles(ctx, tasks)
|
||||
}
|
||||
|
||||
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
|
||||
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||||
@@ -211,7 +290,8 @@ export async function list(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant, includeArchived })
|
||||
const tasks = await getVisibleTasksForBoard(ctx, board, { status, assignee, tenant, includeArchived })
|
||||
if (ctx.status === 403) return
|
||||
ctx.body = { tasks }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -229,6 +309,11 @@ export async function get(ctx: Context) {
|
||||
ctx.body = { error: 'Task not found' }
|
||||
return
|
||||
}
|
||||
if (!filterTasksByVisibleProfiles(ctx, [detail.task]).length) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Task not found' }
|
||||
return
|
||||
}
|
||||
|
||||
// For terminal tasks, find related session from the worker's profile DB.
|
||||
// Archived tasks can still carry the worker result/session users need to inspect.
|
||||
@@ -291,10 +376,12 @@ export async function create(ctx: Context) {
|
||||
const priority = optionalInteger(payload.priority, 'priority')
|
||||
const tenant = optionalString(payload.tenant, 'tenant')
|
||||
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
|
||||
const targetAssignee = assignee.value || requestedProfile(ctx) || undefined
|
||||
if (targetAssignee && denyProfileAccess(ctx, targetAssignee)) return
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: assignee.value, priority: priority.value, tenant: tenant.value })
|
||||
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: targetAssignee, priority: priority.value, tenant: tenant.value })
|
||||
ctx.body = { task }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -357,6 +444,7 @@ export async function assign(ctx: Context) {
|
||||
if (rejectBadRequest(ctx, bodyResult.error)) return
|
||||
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
|
||||
if (rejectBadRequest(ctx, profile.error)) return
|
||||
if (denyProfileAccess(ctx, profile.value)) return
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
@@ -426,6 +514,7 @@ export async function bulkUpdateTasks(ctx: Context) {
|
||||
const summary = optionalString(body.summary, 'summary')
|
||||
const reason = optionalString(body.reason, 'reason')
|
||||
if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return
|
||||
if (assignee.value && denyProfileAccess(ctx, assignee.value)) return
|
||||
if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'at least one bulk action is required' }
|
||||
@@ -516,6 +605,7 @@ export async function reassign(ctx: Context) {
|
||||
const reclaim = optionalBoolean(body.reclaim, 'reclaim')
|
||||
const reason = optionalString(body.reason, 'reason')
|
||||
if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return
|
||||
if (denyProfileAccess(ctx, profile.value)) return
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
@@ -566,7 +656,10 @@ export async function stats(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const stats = await kanbanCli.getStats({ board })
|
||||
const visible = visibleProfileSet(ctx)
|
||||
const stats = visible
|
||||
? statsForTasks(await getVisibleTasksForBoard(ctx, board, { includeArchived: true }))
|
||||
: await kanbanCli.getStats({ board })
|
||||
ctx.body = { stats }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -578,7 +671,7 @@ export async function assignees(ctx: Context) {
|
||||
const board = requestBoard(ctx)
|
||||
if (!board) return
|
||||
try {
|
||||
const assignees = await kanbanCli.getAssignees({ board })
|
||||
const assignees = filterAssigneesByVisibleProfiles(ctx, await kanbanCli.getAssignees({ board }))
|
||||
ctx.body = { assignees }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -628,6 +721,7 @@ export async function searchSessions(ctx: Context) {
|
||||
ctx.body = { error: 'task_id and profile are required' }
|
||||
return
|
||||
}
|
||||
if (denyProfileAccess(ctx, profile)) return
|
||||
try {
|
||||
if (!q) {
|
||||
const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMe
|
||||
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
||||
import { getDb } from '../../db'
|
||||
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
|
||||
import { listUserProfiles } from '../../db/hermes/users-store'
|
||||
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
@@ -194,6 +195,19 @@ function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
|
||||
|
||||
type ProviderFetchCache = Map<string, Promise<string[]>>
|
||||
|
||||
function requestedProfileName(ctx: any): string {
|
||||
const queryProfile = ctx.query?.profile
|
||||
return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : ''
|
||||
}
|
||||
|
||||
function visibleProfileNamesForUser(ctx: any): string[] {
|
||||
const diskProfiles = listProfileNamesFromDisk()
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return diskProfiles
|
||||
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
||||
return diskProfiles.filter(profile => allowed.has(profile))
|
||||
}
|
||||
|
||||
function cachedProviderModels(
|
||||
cache: ProviderFetchCache,
|
||||
baseUrl: string,
|
||||
@@ -379,17 +393,16 @@ async function buildAvailableForProfile(
|
||||
|
||||
export async function getAvailable(ctx: any) {
|
||||
try {
|
||||
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
|
||||
? ctx.query.profile.trim()
|
||||
: ''
|
||||
const requestedProfile = requestedProfileName(ctx)
|
||||
if (!requestedProfile) {
|
||||
const appConfig = await readAppConfig()
|
||||
const modelAliases = normalizeAliases(appConfig.modelAliases)
|
||||
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
||||
const customModels = normalizeCustomModels(appConfig.customModels)
|
||||
const fetchCache: ProviderFetchCache = new Map()
|
||||
const visibleProfiles = visibleProfileNamesForUser(ctx)
|
||||
const profileResults = await Promise.all(
|
||||
listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
|
||||
visibleProfiles.map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
|
||||
)
|
||||
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
|
||||
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { HermesSkillInjector } from '../../services/hermes/skill-injector'
|
||||
import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
||||
import { listUserProfiles } from '../../db/hermes/users-store'
|
||||
|
||||
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
|
||||
|
||||
@@ -127,6 +128,30 @@ function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
|
||||
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
|
||||
}
|
||||
|
||||
function requestedProfileName(ctx: any): string {
|
||||
return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName()
|
||||
}
|
||||
|
||||
function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] {
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return profiles
|
||||
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
||||
return profiles.filter(profile => allowed.has(profile.name))
|
||||
}
|
||||
|
||||
function canAccessProfile(ctx: any, profileName: string): boolean {
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return true
|
||||
return listUserProfiles(user.id).some(profile => profile.profile_name === profileName)
|
||||
}
|
||||
|
||||
function denyProfile(ctx: any, profileName: string): boolean {
|
||||
if (canAccessProfile(ctx, profileName)) return false
|
||||
ctx.status = 403
|
||||
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
|
||||
return true
|
||||
}
|
||||
|
||||
function profileMetadataRoot(): string {
|
||||
return join(getWebUiHome(), 'profile-metadata')
|
||||
}
|
||||
@@ -299,21 +324,12 @@ export async function list(ctx: any) {
|
||||
profiles = listProfilesFromDisk('default')
|
||||
}
|
||||
|
||||
// Override active flag from the authoritative source (active_profile file)
|
||||
// CLI output may be stale, but the file is written by hermes profile use
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const activeProfileName = getActiveProfileName()
|
||||
const activeProfileName = requestedProfileName(ctx)
|
||||
|
||||
profiles = filterVisibleProfiles(profiles)
|
||||
profiles = filterProfilesForUser(ctx, profiles)
|
||||
|
||||
// Check if CLI's active flag matches the file (warn if inconsistent)
|
||||
const cliActive = profiles.find(p => p.active)
|
||||
if (cliActive?.name !== activeProfileName) {
|
||||
logger.warn('[listProfiles] CLI active flag (%s) differs from active_profile file (%s) - using file as authoritative source',
|
||||
cliActive?.name || 'none', activeProfileName)
|
||||
}
|
||||
|
||||
// Fix the active flag based on the actual active_profile file
|
||||
// Web UI active profile is request-scoped and comes from X-Hermes-Profile.
|
||||
profiles.forEach(p => {
|
||||
p.active = (p.name === activeProfileName)
|
||||
})
|
||||
@@ -388,8 +404,10 @@ export async function create(ctx: any) {
|
||||
}
|
||||
|
||||
export async function get(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
try {
|
||||
const profile = await hermesCli.getProfile(ctx.params.name)
|
||||
const profile = await hermesCli.getProfile(name)
|
||||
ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message.includes('not found') ? 404 : 500
|
||||
@@ -399,6 +417,7 @@ export async function get(ctx: any) {
|
||||
|
||||
export async function updateAvatar(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
@@ -438,6 +457,7 @@ export async function updateAvatar(ctx: any) {
|
||||
|
||||
export async function deleteAvatar(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
try {
|
||||
removeProfileMetadata(name)
|
||||
ctx.body = { success: true }
|
||||
@@ -449,6 +469,7 @@ export async function deleteAvatar(ctx: any) {
|
||||
|
||||
export async function runtimeStatus(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
@@ -465,7 +486,7 @@ export async function runtimeStatus(ctx: any) {
|
||||
|
||||
export async function runtimeStatuses(ctx: any) {
|
||||
try {
|
||||
const profiles = await listProfilesForStatus()
|
||||
const profiles = filterProfilesForUser(ctx, await listProfilesForStatus())
|
||||
const bridge = await readBridgeWorkers()
|
||||
const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge)))
|
||||
ctx.body = { profiles: statuses }
|
||||
@@ -487,6 +508,7 @@ async function listProfilesForStatus(): Promise<HermesProfile[]> {
|
||||
|
||||
export async function restartGatewayForProfile(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
@@ -509,6 +531,7 @@ export async function restartGatewayForProfile(ctx: any) {
|
||||
|
||||
export async function restartProfileRuntime(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (denyProfile(ctx, name)) return
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
@@ -532,6 +555,7 @@ export async function restartProfileRuntime(ctx: any) {
|
||||
|
||||
export async function remove(ctx: any) {
|
||||
const { name } = ctx.params
|
||||
if (denyProfile(ctx, name)) return
|
||||
if (name === 'default') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Cannot delete the default profile' }
|
||||
@@ -562,6 +586,7 @@ export async function remove(ctx: any) {
|
||||
}
|
||||
|
||||
export async function rename(ctx: any) {
|
||||
if (denyProfile(ctx, ctx.params.name)) return
|
||||
const { new_name } = ctx.request.body as { new_name?: string }
|
||||
if (!new_name) {
|
||||
ctx.status = 400
|
||||
@@ -596,32 +621,20 @@ export async function switchProfile(ctx: any) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (denyProfile(ctx, name)) return
|
||||
|
||||
const output = await useProfileWithFallback(name)
|
||||
|
||||
// Verify the active_profile file immediately (Hermes CLI writes synchronously)
|
||||
// Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency)
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
let actualActive = getActiveProfileName()
|
||||
|
||||
// Quick retry (max 2 times, 100ms delay each)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (actualActive === name) break
|
||||
logger.debug('[switchProfile] Quick retry %d: current=%s, expected=%s', i + 1, actualActive, name)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
actualActive = getActiveProfileName()
|
||||
}
|
||||
|
||||
const actualActive = getActiveProfileName()
|
||||
if (actualActive !== name) {
|
||||
logger.error('[switchProfile] Verification failed: active_profile is %s (expected %s)', actualActive, name)
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` }
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy all bridge sessions so they get recreated with the new profile config
|
||||
try {
|
||||
await bridgeCleanupClient().destroyAll()
|
||||
logger.info('[switchProfile] destroyed all bridge sessions for profile "%s"', name)
|
||||
const result = await bridgeCleanupClient().destroyAll()
|
||||
logger.info('[switchProfile] destroyed all bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
|
||||
} catch (err: any) {
|
||||
logger.warn(err, '[switchProfile] failed to destroy bridge sessions')
|
||||
}
|
||||
@@ -630,7 +643,6 @@ export async function switchProfile(ctx: any) {
|
||||
const detail = await hermesCli.getProfile(name)
|
||||
logger.debug('Profile detail.path = %s', detail.path)
|
||||
|
||||
// 确保配置文件存在,但不调用 setupReset()(会重置端口配置)
|
||||
const profileConfig = join(detail.path, 'config.yaml')
|
||||
if (!existsSync(profileConfig)) {
|
||||
writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8')
|
||||
@@ -647,20 +659,13 @@ export async function switchProfile(ctx: any) {
|
||||
}
|
||||
|
||||
await injectBundledSkillsForProfile(name)
|
||||
|
||||
// TODO: re-enable pending session delete drain after confirming safety
|
||||
// const drainResult = await SessionDeleter.getInstance().drain(name)
|
||||
SessionDeleter.getInstance().switchProfile(name)
|
||||
logger.info('[switchProfile] switched session deleter to profile "%s"', name)
|
||||
// if (drainResult.failed.length > 0) {
|
||||
// logger.warn({ profile: name, failed: drainResult.failed }, 'Failed to drain some pending session deletes after profile switch')
|
||||
// }
|
||||
logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name)
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: output.trim(),
|
||||
// drained_session_deletes: drainResult.deleted.length,
|
||||
// failed_session_deletes: drainResult.failed.length,
|
||||
active: name,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
@@ -670,6 +675,7 @@ export async function switchProfile(ctx: any) {
|
||||
|
||||
export async function exportProfile(ctx: any) {
|
||||
const { name } = ctx.params
|
||||
if (denyProfile(ctx, name)) return
|
||||
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
|
||||
try {
|
||||
await hermesCli.exportProfile(name, outputPath)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
import {
|
||||
listSessions as localListSessions,
|
||||
searchSessions as localSearchSessions,
|
||||
@@ -17,6 +17,7 @@ import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||
import { getGroupChatServer } from '../../routes/hermes/group-chat'
|
||||
import { logger } from '../../services/logger'
|
||||
import type { ConversationSummary } from '../../services/hermes/conversations'
|
||||
import { listUserProfiles } from '../../db/hermes/users-store'
|
||||
|
||||
function getPendingDeletedSessionIds(): Set<string> {
|
||||
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
|
||||
@@ -32,6 +33,35 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
|
||||
return filterPendingDeletedSessions(items)
|
||||
}
|
||||
|
||||
function requestedProfile(ctx: any): string | undefined {
|
||||
const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '')
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function allowedProfileSet(ctx: any): Set<string> | null {
|
||||
const user = ctx.state?.user
|
||||
if (!user || user.role === 'super_admin') return null
|
||||
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
||||
}
|
||||
|
||||
function canAccessProfile(ctx: any, profile: string | null | undefined): boolean {
|
||||
const allowed = allowedProfileSet(ctx)
|
||||
return !allowed || allowed.has(profile || 'default')
|
||||
}
|
||||
|
||||
function filterByAllowedProfiles<T>(ctx: any, items: T[]): T[] {
|
||||
const allowed = allowedProfileSet(ctx)
|
||||
if (!allowed) return items
|
||||
return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default'))
|
||||
}
|
||||
|
||||
function denySessionAccess(ctx: any, session: any | null | undefined): boolean {
|
||||
if (!session || canAccessProfile(ctx, session.profile)) return false
|
||||
ctx.status = 403
|
||||
ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` }
|
||||
return true
|
||||
}
|
||||
|
||||
interface HermesDeleteResult {
|
||||
attempted: boolean
|
||||
deleted: boolean
|
||||
@@ -73,10 +103,11 @@ export async function listConversations(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
|
||||
const profile = getActiveProfileName()
|
||||
const profile = requestedProfile(ctx)
|
||||
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
|
||||
const summaries: ConversationSummary[] = sessions.map(s => ({
|
||||
id: s.id,
|
||||
profile: s.profile || null,
|
||||
source: s.source,
|
||||
model: s.model,
|
||||
provider: s.provider,
|
||||
@@ -100,7 +131,7 @@ export async function listConversations(ctx: any) {
|
||||
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
|
||||
thread_session_count: 1,
|
||||
}))
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) }
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) }
|
||||
}
|
||||
|
||||
export async function getConversationMessages(ctx: any) {
|
||||
@@ -112,6 +143,7 @@ export async function getConversationMessages(ctx: any) {
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
if (denySessionAccess(ctx, detail)) return
|
||||
const messages = detail.messages
|
||||
.filter(m => {
|
||||
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
|
||||
@@ -136,15 +168,13 @@ export async function getConversationMessages(ctx: any) {
|
||||
export async function list(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
|
||||
? ctx.query.profile.trim()
|
||||
: undefined
|
||||
const profile = requestedProfile(ctx)
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
const allSessions = localListSessions(profile, source, effectiveLimit)
|
||||
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
||||
ctx.body = {
|
||||
sessions: filterPendingDeletedSessions(allSessions.filter(s =>
|
||||
sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s =>
|
||||
(s.source === 'api_server' || s.source === 'cli') &&
|
||||
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
|
||||
)),
|
||||
@@ -158,23 +188,22 @@ export async function list(ctx: any) {
|
||||
export async function listHermesSessions(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const profile = requestedProfile(ctx)
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
const allSessions = await listSessionSummaries(source, effectiveLimit, profile)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source !== 'api_server')) }
|
||||
const allSessions = (await listSessionSummaries(source, effectiveLimit, profile))
|
||||
.map(session => profile ? { ...session, profile } : session)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) }
|
||||
}
|
||||
|
||||
export async function search(ctx: any) {
|
||||
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
|
||||
? ctx.query.profile.trim()
|
||||
: undefined
|
||||
const profile = requestedProfile(ctx)
|
||||
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
|
||||
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
||||
ctx.body = {
|
||||
results: filterPendingDeletedSessions(results.filter(s =>
|
||||
results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s =>
|
||||
!knownProfiles || knownProfiles.has(s.profile || 'default'),
|
||||
)),
|
||||
}
|
||||
@@ -187,6 +216,7 @@ export async function get(ctx: any) {
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
if (denySessionAccess(ctx, session)) return
|
||||
ctx.body = { session }
|
||||
}
|
||||
|
||||
@@ -195,20 +225,28 @@ export async function get(ctx: any) {
|
||||
* GET /api/hermes/sessions/hermes/:id
|
||||
*/
|
||||
export async function getHermesSession(ctx: any) {
|
||||
const profile = requestedProfile(ctx)
|
||||
|
||||
// Prefer the Web UI local session store. Hermes state.db can lag behind or
|
||||
// miss messages for Bridge-backed runs, while the local store is the source
|
||||
// used by chat rendering and compression.
|
||||
const localSession = localGetSessionDetail(ctx.params.id)
|
||||
if (localSession && localSession.source !== 'api_server') {
|
||||
const localSessionProfile = (localSession?.profile || 'default') as string
|
||||
if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) {
|
||||
if (denySessionAccess(ctx, localSession)) return
|
||||
ctx.body = { session: localSession }
|
||||
return
|
||||
}
|
||||
|
||||
// Try Hermes state.db next (consistent with listHermesSessions)
|
||||
try {
|
||||
const session = await getSessionDetailFromDb(ctx.params.id)
|
||||
const session = profile
|
||||
? await getSessionDetailFromDbWithProfile(ctx.params.id, profile)
|
||||
: await getSessionDetailFromDb(ctx.params.id)
|
||||
if (session && session.source !== 'api_server') {
|
||||
ctx.body = { session }
|
||||
const sessionWithProfile = profile ? { ...session, profile } : session
|
||||
if (denySessionAccess(ctx, sessionWithProfile)) return
|
||||
ctx.body = { session: sessionWithProfile }
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -228,13 +266,15 @@ export async function getHermesSession(ctx: any) {
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
if (denySessionAccess(ctx, session)) return
|
||||
ctx.body = { session }
|
||||
}
|
||||
|
||||
export async function remove(ctx: any) {
|
||||
const sessionId = ctx.params.id
|
||||
const existing = localGetSession(sessionId)
|
||||
const hermesProfile = existing?.profile || getActiveProfileName()
|
||||
if (denySessionAccess(ctx, existing)) return
|
||||
const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName()
|
||||
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
|
||||
const localDeleted = existing ? localDeleteSession(sessionId) : true
|
||||
if (!localDeleted) {
|
||||
@@ -272,6 +312,11 @@ export async function batchRemove(ctx: any) {
|
||||
|
||||
for (const id of validIds) {
|
||||
const existing = localGetSession(id)
|
||||
if (existing && !canAccessProfile(ctx, existing.profile)) {
|
||||
results.failed++
|
||||
results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` })
|
||||
continue
|
||||
}
|
||||
const hermes = await deleteHermesSessionIfPresent(id, existing?.profile)
|
||||
if (hermes.deleted) {
|
||||
results.hermesDeleted++
|
||||
@@ -304,6 +349,8 @@ export async function usageBatch(ctx: any) {
|
||||
}
|
||||
|
||||
export async function usageSingle(ctx: any) {
|
||||
const session = localGetSession(ctx.params.id)
|
||||
if (denySessionAccess(ctx, session)) return
|
||||
const result = getUsage(ctx.params.id)
|
||||
if (!result) {
|
||||
ctx.body = { input_tokens: 0, output_tokens: 0 }
|
||||
@@ -319,6 +366,8 @@ export async function rename(ctx: any) {
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
const existing = localGetSession(ctx.params.id)
|
||||
if (denySessionAccess(ctx, existing)) return
|
||||
const ok = localRenameSession(ctx.params.id, title.trim())
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
@@ -336,10 +385,11 @@ export async function setWorkspace(ctx: any) {
|
||||
return
|
||||
}
|
||||
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const id = ctx.params.id
|
||||
if (!getSession(id)) {
|
||||
createSession({ id, profile: getActiveProfileName(), title: '' })
|
||||
const existing = getSession(id)
|
||||
if (denySessionAccess(ctx, existing)) return
|
||||
if (!existing) {
|
||||
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
|
||||
}
|
||||
updateSession(id, { workspace: workspace || null } as any)
|
||||
ctx.body = { ok: true }
|
||||
@@ -358,17 +408,18 @@ export async function setModel(ctx: any) {
|
||||
return
|
||||
}
|
||||
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const id = ctx.params.id
|
||||
if (!getSession(id)) {
|
||||
createSession({ id, profile: getActiveProfileName(), title: '' })
|
||||
const existing = getSession(id)
|
||||
if (denySessionAccess(ctx, existing)) return
|
||||
if (!existing) {
|
||||
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
|
||||
}
|
||||
updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any)
|
||||
ctx.body = { ok: true }
|
||||
}
|
||||
|
||||
export async function contextLength(ctx: any) {
|
||||
const profile = (ctx.query.profile as string) || undefined
|
||||
const profile = requestedProfile(ctx)
|
||||
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
|
||||
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
|
||||
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
|
||||
@@ -484,6 +535,7 @@ export async function exportSession(ctx: any) {
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
if (denySessionAccess(ctx, session)) return
|
||||
|
||||
const mode = (ctx.query.mode as string) || 'full'
|
||||
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
|
||||
@@ -560,6 +612,7 @@ export async function getConversationMessagesPaginated(ctx: any) {
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
if (denySessionAccess(ctx, result.session)) return
|
||||
|
||||
ctx.body = {
|
||||
session: {
|
||||
|
||||
@@ -104,6 +104,38 @@ export const MODEL_CONTEXT_SCHEMA: Record<string, string> = {
|
||||
|
||||
export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)'
|
||||
|
||||
// ============================================================================
|
||||
// Users and Profile Access
|
||||
// ============================================================================
|
||||
|
||||
export const USERS_TABLE = 'users'
|
||||
|
||||
export const USERS_SCHEMA: Record<string, string> = {
|
||||
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
username: 'TEXT NOT NULL UNIQUE',
|
||||
password_hash: 'TEXT NOT NULL',
|
||||
role: "TEXT NOT NULL DEFAULT 'admin'",
|
||||
status: "TEXT NOT NULL DEFAULT 'active'",
|
||||
created_at: 'INTEGER NOT NULL',
|
||||
updated_at: 'INTEGER NOT NULL',
|
||||
last_login_at: 'INTEGER',
|
||||
}
|
||||
|
||||
export const USER_PROFILES_TABLE = 'user_profiles'
|
||||
|
||||
export const USER_PROFILES_SCHEMA: Record<string, string> = {
|
||||
user_id: 'INTEGER NOT NULL',
|
||||
profile_name: "TEXT NOT NULL DEFAULT 'default'",
|
||||
is_default: 'INTEGER NOT NULL DEFAULT 0',
|
||||
created_at: 'INTEGER NOT NULL',
|
||||
}
|
||||
|
||||
export const USER_PROFILES_INDEXES = {
|
||||
idx_user_profiles_user: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_user ON user_profiles(user_id)',
|
||||
idx_user_profiles_profile: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_profile ON user_profiles(profile_name)',
|
||||
idx_user_profiles_default: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_default ON user_profiles(user_id) WHERE is_default = 1',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group Chat (services/hermes/group-chat/index.ts)
|
||||
// ============================================================================
|
||||
@@ -329,6 +361,13 @@ export function initAllHermesTables(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Users and profile access
|
||||
syncTable(USERS_TABLE, USERS_SCHEMA)
|
||||
syncTable(USER_PROFILES_TABLE, USER_PROFILES_SCHEMA, {
|
||||
primaryKey: 'user_id, profile_name',
|
||||
indexes: USER_PROFILES_INDEXES,
|
||||
})
|
||||
|
||||
// Group chat - basic tables
|
||||
syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA)
|
||||
syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'
|
||||
import { getDb } from '../index'
|
||||
import { USER_PROFILES_TABLE, USERS_TABLE } from './schemas'
|
||||
|
||||
export type UserRole = 'super_admin' | 'admin'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type UserId = number | string
|
||||
|
||||
export interface UserRecord {
|
||||
id: number
|
||||
username: string
|
||||
password_hash: string
|
||||
role: UserRole
|
||||
status: UserStatus
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_login_at: number | null
|
||||
}
|
||||
|
||||
export interface UserProfileRecord {
|
||||
user_id: number
|
||||
profile_name: string
|
||||
is_default: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
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 const DEFAULT_USERNAME = 'admin'
|
||||
export const DEFAULT_PASSWORD = '123456'
|
||||
export const DEFAULT_PROFILE_NAME = 'default'
|
||||
|
||||
const SCRYPT_KEY_LEN = 64
|
||||
|
||||
function normalizeUserId(id: UserId): number | null {
|
||||
const userId = typeof id === 'number' ? id : Number(id)
|
||||
return Number.isInteger(userId) && userId > 0 ? userId : null
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, SCRYPT_KEY_LEN).toString('hex')
|
||||
return `scrypt:${salt}:${hash}`
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, passwordHash: string): boolean {
|
||||
const [scheme, salt, expectedHex] = passwordHash.split(':')
|
||||
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
|
||||
try {
|
||||
const expected = Buffer.from(expectedHex, 'hex')
|
||||
const actual = scryptSync(password, salt, expected.length)
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findUserById(id: UserId): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const userId = normalizeUserId(id)
|
||||
if (!userId) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE id = ?`).get(userId) as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function findUserByUsername(username: string): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE username = ?`).get(username) as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function findFirstUser(): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} ORDER BY id ASC LIMIT 1`).get() as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function listUsers(): UserSummary[] {
|
||||
const db = getDb()
|
||||
if (!db) return []
|
||||
const users = db.prepare(
|
||||
`SELECT id, username, role, status, created_at, updated_at, last_login_at FROM ${USERS_TABLE} ORDER BY id ASC`
|
||||
).all() as Array<Omit<UserSummary, 'profiles' | 'default_profile'>>
|
||||
return users.map(user => {
|
||||
const profiles = listUserProfiles(user.id)
|
||||
return {
|
||||
...user,
|
||||
profiles: profiles.map(profile => profile.profile_name),
|
||||
default_profile: profiles.find(profile => profile.is_default === 1)?.profile_name || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function listUserProfiles(userId: UserId): UserProfileRecord[] {
|
||||
const db = getDb()
|
||||
if (!db) return []
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return []
|
||||
return db.prepare(
|
||||
`SELECT * FROM ${USER_PROFILES_TABLE} WHERE user_id = ? ORDER BY is_default DESC, profile_name ASC`
|
||||
).all(id) as unknown as UserProfileRecord[]
|
||||
}
|
||||
|
||||
export function userCanAccessProfile(userId: UserId, profileName: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const row = db.prepare(
|
||||
`SELECT 1 FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND profile_name = ?`
|
||||
).get(id, profileName)
|
||||
return !!row
|
||||
}
|
||||
|
||||
export function getDefaultProfileForUser(userId: UserId): string {
|
||||
const db = getDb()
|
||||
if (!db) return DEFAULT_PROFILE_NAME
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return DEFAULT_PROFILE_NAME
|
||||
const row = db.prepare(
|
||||
`SELECT profile_name FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND is_default = 1 LIMIT 1`
|
||||
).get(id) as { profile_name?: string } | undefined
|
||||
return row?.profile_name || DEFAULT_PROFILE_NAME
|
||||
}
|
||||
|
||||
export function countUsers(): number {
|
||||
const db = getDb()
|
||||
if (!db) return 0
|
||||
const row = db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE}`).get() as { count?: number } | undefined
|
||||
return Number(row?.count || 0)
|
||||
}
|
||||
|
||||
export function countActiveSuperAdmins(excludeUserId?: UserId): number {
|
||||
const db = getDb()
|
||||
if (!db) return 0
|
||||
const exclude = excludeUserId == null ? null : normalizeUserId(excludeUserId)
|
||||
const row = exclude
|
||||
? db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active' AND id != ?`).get(exclude)
|
||||
: db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active'`).get()
|
||||
return Number((row as { count?: number } | undefined)?.count || 0)
|
||||
}
|
||||
|
||||
export function touchUserLogin(userId: UserId, at = Date.now()): void {
|
||||
const db = getDb()
|
||||
if (!db) return
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return
|
||||
db.prepare(`UPDATE ${USERS_TABLE} SET last_login_at = ?, updated_at = ? WHERE id = ?`).run(at, at, id)
|
||||
}
|
||||
|
||||
export function updateUserPassword(userId: UserId, password: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const result = db.prepare(`UPDATE ${USERS_TABLE} SET password_hash = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(hashPassword(password), Date.now(), id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function updateUsername(userId: UserId, username: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const result = db.prepare(`UPDATE ${USERS_TABLE} SET username = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(username, Date.now(), id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function createUser(input: {
|
||||
username: string
|
||||
password: string
|
||||
role?: UserRole
|
||||
status?: UserStatus
|
||||
profiles?: string[]
|
||||
defaultProfile?: string | null
|
||||
}): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const now = Date.now()
|
||||
const role = input.role || 'admin'
|
||||
const status = input.status || 'active'
|
||||
db.prepare(
|
||||
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(input.username, hashPassword(input.password), role, status, now, now)
|
||||
|
||||
const user = findUserByUsername(input.username)
|
||||
if (user) replaceUserProfiles(user.id, input.profiles || [], input.defaultProfile)
|
||||
return user
|
||||
}
|
||||
|
||||
export function updateUser(input: {
|
||||
userId: UserId
|
||||
username?: string
|
||||
role?: UserRole
|
||||
status?: UserStatus
|
||||
password?: string
|
||||
profiles?: string[]
|
||||
defaultProfile?: string | null
|
||||
}): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const id = normalizeUserId(input.userId)
|
||||
if (!id) return null
|
||||
|
||||
const current = findUserById(id)
|
||||
if (!current) return null
|
||||
|
||||
const nextUsername = input.username ?? current.username
|
||||
const nextRole = input.role ?? current.role
|
||||
const nextStatus = input.status ?? current.status
|
||||
const nextPasswordHash = input.password ? hashPassword(input.password) : current.password_hash
|
||||
const now = Date.now()
|
||||
|
||||
db.prepare(
|
||||
`UPDATE ${USERS_TABLE}
|
||||
SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(nextUsername, nextPasswordHash, nextRole, nextStatus, now, id)
|
||||
|
||||
if (input.profiles) replaceUserProfiles(id, input.profiles, input.defaultProfile)
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
export function deleteUser(userId: UserId): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
|
||||
const result = db.prepare(`DELETE FROM ${USERS_TABLE} WHERE id = ?`).run(id)
|
||||
db.exec('COMMIT')
|
||||
return result.changes > 0
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceUserProfiles(userId: UserId, profiles: string[], defaultProfile?: string | null): void {
|
||||
const db = getDb()
|
||||
if (!db) return
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return
|
||||
|
||||
const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))]
|
||||
const defaultName = defaultProfile && uniqueProfiles.includes(defaultProfile) ? defaultProfile : uniqueProfiles[0] || null
|
||||
const now = Date.now()
|
||||
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ${USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
uniqueProfiles.forEach(profile => {
|
||||
stmt.run(id, profile, profile === defaultName ? 1 : 0, now)
|
||||
})
|
||||
db.exec('COMMIT')
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultSuperAdmin(): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
|
||||
const now = Date.now()
|
||||
db.prepare(
|
||||
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(DEFAULT_USERNAME, hashPassword(DEFAULT_PASSWORD), 'super_admin', 'active', now, now)
|
||||
|
||||
return findUserByUsername(DEFAULT_USERNAME)
|
||||
}
|
||||
|
||||
export function bootstrapDefaultSuperAdmin(username: string, password: string): UserRecord | null {
|
||||
if (countUsers() > 0) return null
|
||||
if (username !== DEFAULT_USERNAME || password !== DEFAULT_PASSWORD) return null
|
||||
return createDefaultSuperAdmin()
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { resolve } from 'path'
|
||||
import { mkdir } from 'fs/promises'
|
||||
import { readFileSync } from 'fs'
|
||||
import { config } from './config'
|
||||
import { getToken, requireAuth } from './services/auth'
|
||||
import { initLoginLimiter } from './services/login-limiter'
|
||||
import { bindShutdown } from './services/shutdown'
|
||||
import { setupTerminalWebSocket } from './routes/hermes/terminal'
|
||||
@@ -23,6 +22,7 @@ import { startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { HermesSkillInjector } from './services/hermes/skill-injector'
|
||||
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
|
||||
import { logger } from './services/logger'
|
||||
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
|
||||
|
||||
// Injected by esbuild at build time; fallback to reading package.json in dev mode
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -86,7 +86,6 @@ export async function bootstrap() {
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
await mkdir(config.dataDir, { recursive: true })
|
||||
|
||||
const authToken = await getToken()
|
||||
await initLoginLimiter()
|
||||
try {
|
||||
const skillInjector = new HermesSkillInjector()
|
||||
@@ -138,15 +137,10 @@ export async function bootstrap() {
|
||||
console.log('[bootstrap] cors + bodyParser registered')
|
||||
|
||||
// Register all routes (handles auth internally)
|
||||
const proxyMiddleware = registerRoutes(app, requireAuth(authToken))
|
||||
const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile])
|
||||
app.use(proxyMiddleware)
|
||||
console.log('[bootstrap] routes registered')
|
||||
|
||||
if (authToken) {
|
||||
console.log(`Auth enabled — token: ${authToken}`)
|
||||
logger.info('Auth enabled — token: %s', authToken)
|
||||
}
|
||||
|
||||
// SPA fallback
|
||||
const distDir = resolve(__dirname, '..', 'client')
|
||||
app.use(serve(distDir))
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Context, Next } from 'koa'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import { getToken } from '../services/auth'
|
||||
import {
|
||||
findUserById,
|
||||
touchUserLogin,
|
||||
userCanAccessProfile,
|
||||
type UserRecord,
|
||||
type UserRole,
|
||||
} from '../db/hermes/users-store'
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export interface RequestProfile {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string
|
||||
username: string
|
||||
role: UserRole
|
||||
type: 'access'
|
||||
aud: 'hermes-web-ui'
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
declare module 'koa' {
|
||||
interface DefaultState {
|
||||
user?: AuthenticatedUser
|
||||
profile?: RequestProfile
|
||||
}
|
||||
}
|
||||
|
||||
const JWT_AUDIENCE = 'hermes-web-ui'
|
||||
const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
function base64UrlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(input).digest('base64url')
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
try {
|
||||
const left = Buffer.from(a)
|
||||
const right = Buffer.from(b)
|
||||
return left.length === right.length && timingSafeEqual(left, right)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getJwtSecret(): Promise<string | null> {
|
||||
return process.env.AUTH_JWT_SECRET || await getToken()
|
||||
}
|
||||
|
||||
function requestToken(ctx: Context): string {
|
||||
const auth = ctx.headers.authorization || ''
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim()
|
||||
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
|
||||
}
|
||||
|
||||
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
|
||||
const iat = Math.floor(now / 1000)
|
||||
const payload: JwtPayload = {
|
||||
sub: String(user.id),
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
type: 'access',
|
||||
aud: JWT_AUDIENCE,
|
||||
iat,
|
||||
exp: iat + DEFAULT_EXPIRES_SECONDS,
|
||||
}
|
||||
const header = base64UrlJson({ alg: 'HS256', typ: 'JWT' })
|
||||
const body = base64UrlJson(payload)
|
||||
const unsigned = `${header}.${body}`
|
||||
return `${unsigned}.${sign(unsigned, secret)}`
|
||||
}
|
||||
|
||||
export function verifyUserJwt(token: string, secret: string, now = Date.now()): JwtPayload | null {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
const [header, body, signature] = parts
|
||||
const expected = sign(`${header}.${body}`, secret)
|
||||
if (!safeEqual(signature, expected)) return null
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial<JwtPayload>
|
||||
if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null
|
||||
if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null
|
||||
if (Math.floor(now / 1000) >= payload.exp) return null
|
||||
return payload as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>): Promise<string> {
|
||||
const secret = await getJwtSecret()
|
||||
if (!secret) throw new Error('Auth is disabled on this server')
|
||||
return signUserJwt(user, secret)
|
||||
}
|
||||
|
||||
export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateUserToken(token: string): Promise<AuthenticatedUser | null> {
|
||||
const secret = await getJwtSecret()
|
||||
if (!secret) return null
|
||||
|
||||
const payload = token ? verifyUserJwt(token, secret) : null
|
||||
if (!payload) return null
|
||||
|
||||
const user = findUserById(payload.sub)
|
||||
if (!user || user.status !== 'active') return null
|
||||
return toAuthenticatedUser(user)
|
||||
}
|
||||
|
||||
export async function isAuthEnabled(): Promise<boolean> {
|
||||
return !!await getJwtSecret()
|
||||
}
|
||||
|
||||
export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
|
||||
const secret = await getJwtSecret()
|
||||
if (!secret) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = requestToken(ctx)
|
||||
const payload = token ? verifyUserJwt(token, secret) : null
|
||||
if (!payload) {
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
return
|
||||
}
|
||||
|
||||
const user = findUserById(payload.sub)
|
||||
if (!user || user.status !== 'active') {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'User is disabled or does not exist' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.state.user = toAuthenticatedUser(user)
|
||||
touchUserLogin(user.id)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function requireSuperAdmin(ctx: Context, next: Next): Promise<void> {
|
||||
if (ctx.state.user?.role !== 'super_admin') {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Super administrator privileges are required' }
|
||||
return
|
||||
}
|
||||
await next()
|
||||
}
|
||||
|
||||
export function resolveRequestedProfile(ctx: Context): string {
|
||||
if (ctx.path === '/api/hermes/available-models' && typeof ctx.query.profile !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const headerProfile = ctx.get('x-hermes-profile')
|
||||
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
|
||||
const body = ctx.request.body as { profile?: unknown } | undefined
|
||||
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
|
||||
return (headerProfile || queryProfile || bodyProfile || '').trim()
|
||||
}
|
||||
|
||||
export async function resolveUserProfile(ctx: Context, next: Next): Promise<void> {
|
||||
const user = ctx.state.user
|
||||
if (!user) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const profileName = resolveRequestedProfile(ctx)
|
||||
if (!profileName) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
if (user.role !== 'super_admin' && !userCanAccessProfile(user.id, profileName)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.state.profile = { name: profileName }
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function requireUserProfile(ctx: Context, next: Next): Promise<void> {
|
||||
if (!ctx.state.profile?.name) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Profile is required' }
|
||||
return
|
||||
}
|
||||
await next()
|
||||
}
|
||||
|
||||
export const userAuthMiddleware = [requireUserJwt, resolveUserProfile]
|
||||
@@ -1,5 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../controllers/auth'
|
||||
import { requireSuperAdmin } from '../middleware/user-auth'
|
||||
|
||||
// Public routes (no auth required)
|
||||
export const authPublicRoutes = new Router()
|
||||
@@ -9,8 +10,13 @@ authPublicRoutes.post('/api/auth/login', ctrl.login)
|
||||
// Protected routes (auth required)
|
||||
export const authProtectedRoutes = new Router()
|
||||
authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword)
|
||||
authProtectedRoutes.get('/api/auth/me', ctrl.currentUser)
|
||||
authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword)
|
||||
authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername)
|
||||
authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword)
|
||||
authProtectedRoutes.get('/api/auth/users', requireSuperAdmin, ctrl.listManagedUsers)
|
||||
authProtectedRoutes.post('/api/auth/users', requireSuperAdmin, ctrl.createManagedUser)
|
||||
authProtectedRoutes.put('/api/auth/users/:id', requireSuperAdmin, ctrl.updateManagedUser)
|
||||
authProtectedRoutes.delete('/api/auth/users/:id', requireSuperAdmin, ctrl.deleteManagedUser)
|
||||
authProtectedRoutes.get('/api/auth/locked-ips', ctrl.listLockedIps)
|
||||
authProtectedRoutes.delete('/api/auth/locked-ips', ctrl.unlockIpHandler)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { WebSocket } from 'ws'
|
||||
import type { Server as HttpServer, IncomingMessage } from 'http'
|
||||
import { getToken } from '../../services/auth'
|
||||
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
|
||||
import { userCanAccessProfile } from '../../db/hermes/users-store'
|
||||
import { logger } from '../../services/logger'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
|
||||
interface KanbanEventsRequest extends IncomingMessage {
|
||||
kanbanBoard?: string
|
||||
kanbanProfile?: string
|
||||
}
|
||||
|
||||
function sendJson(ws: WebSocket, payload: Record<string, unknown>) {
|
||||
@@ -35,14 +37,21 @@ export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[
|
||||
const url = new URL(req.url || '', `http://${req.headers.host}`)
|
||||
if (url.pathname !== '/api/hermes/kanban/events') return
|
||||
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
if (await isAuthEnabled()) {
|
||||
const token = url.searchParams.get('token') || ''
|
||||
if (token !== authToken) {
|
||||
const user = await authenticateUserToken(token)
|
||||
if (!user) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
const profile = (url.searchParams.get('profile') || '').trim()
|
||||
if (profile && user.role !== 'super_admin' && !userCanAccessProfile(user.id, profile)) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
req.kanbanProfile = profile || undefined
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -74,7 +83,7 @@ export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[
|
||||
|
||||
child.stdout?.on('data', streamLines((line) => {
|
||||
if (line.toLowerCase().startsWith('watching kanban events')) return
|
||||
sendJson(ws, { type: 'event', board, line })
|
||||
sendJson(ws, { type: 'event', board })
|
||||
}))
|
||||
|
||||
child.stderr?.on('data', streamLines((line) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/performance-monitor'
|
||||
import { requireSuperAdmin } from '../../middleware/user-auth'
|
||||
|
||||
export const performanceMonitorRoutes = new Router()
|
||||
|
||||
performanceMonitorRoutes.get('/api/hermes/performance/runtime', ctrl.runtime)
|
||||
performanceMonitorRoutes.get('/api/hermes/performance/runtime', requireSuperAdmin, ctrl.runtime)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/profiles'
|
||||
import { requireSuperAdmin } from '../../middleware/user-auth'
|
||||
|
||||
export const profileRoutes = new Router()
|
||||
|
||||
@@ -14,6 +15,6 @@ profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
|
||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
||||
profileRoutes.put('/api/hermes/profiles/active', ctrl.switchProfile)
|
||||
profileRoutes.put('/api/hermes/profiles/active', requireSuperAdmin, ctrl.switchProfile)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile)
|
||||
profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, join, isAbsolute, resolve as resolvePath } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
||||
import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider'
|
||||
import { getToken } from '../../services/auth'
|
||||
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
|
||||
import { logger } from '../../services/logger'
|
||||
|
||||
let pty: any = null
|
||||
@@ -151,10 +151,9 @@ export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) {
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const authToken = await getToken()
|
||||
if (authToken) {
|
||||
if (await isAuthEnabled()) {
|
||||
const token = url.searchParams.get('token') || ''
|
||||
if (token !== authToken) {
|
||||
if (!await authenticateUserToken(token)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
|
||||
@@ -38,7 +38,7 @@ import { performanceMonitorRoutes } from './hermes/performance-monitor'
|
||||
* Public routes are registered first, then auth middleware,
|
||||
* then all protected routes. Returns the proxy middleware (must be mounted last).
|
||||
*/
|
||||
export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) => Promise<void>) {
|
||||
export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, next: Next) => Promise<void>>) {
|
||||
// --- Public routes (no auth required) ---
|
||||
app.use(healthRoutes.routes())
|
||||
app.use(webhookRoutes.routes())
|
||||
@@ -46,7 +46,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(ttsRoutes.routes()) // TTS proxy/generation — must be before auth
|
||||
|
||||
// --- Auth middleware: all routes below require authentication ---
|
||||
app.use(requireAuth)
|
||||
authMiddleware.forEach((middleware) => app.use(middleware))
|
||||
|
||||
// --- Protected routes (auth required) ---
|
||||
app.use(authProtectedRoutes.routes())
|
||||
|
||||
@@ -34,6 +34,7 @@ const exportCache = new Map<string, CachedExport>()
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
profile?: string | null
|
||||
source: string
|
||||
model: string
|
||||
title: string | null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Server, Socket, Namespace } from 'socket.io'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { getToken } from '../../../services/auth'
|
||||
import { logger } from '../../../services/logger'
|
||||
import { getDb } from '../../../db'
|
||||
import { normalizeMessageContentForStorage, normalizeMessageContentForStorageRole } from '../../../db/hermes/message-content'
|
||||
@@ -9,6 +8,7 @@ import { ContextEngine } from '../context-engine/compressor'
|
||||
import { SessionDeleter } from '../session-deleter'
|
||||
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
import { AgentBridgeClient } from '../agent-bridge'
|
||||
import { authenticateUserToken, isAuthEnabled } from '../../../middleware/user-auth'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -780,12 +780,16 @@ export class GroupChatServer {
|
||||
// ─── Auth ───────────────────────────────────────────────────
|
||||
|
||||
private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise<void> {
|
||||
const authToken = await getToken()
|
||||
const token = socket.handshake.auth.token || socket.handshake.query.token || ''
|
||||
if (authToken) {
|
||||
if (token !== authToken) {
|
||||
return next(new Error('Unauthorized'))
|
||||
}
|
||||
const auth = socket.handshake.auth as { source?: string; agentSocketSecret?: string; token?: string }
|
||||
const isAgentSocket = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET
|
||||
if (isAgentSocket) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = auth.token || socket.handshake.query.token || ''
|
||||
if (await isAuthEnabled() && !await authenticateUserToken(String(token))) {
|
||||
return next(new Error('Unauthorized'))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { handleAbort } from './abort'
|
||||
import { getOrCreateSession } from './compression'
|
||||
import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command'
|
||||
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth'
|
||||
import { userCanAccessProfile } from '../../../db/hermes/users-store'
|
||||
|
||||
export type { ContentBlock } from './types'
|
||||
|
||||
@@ -43,31 +45,55 @@ export class ChatRunSocket {
|
||||
|
||||
private async authMiddleware(socket: Socket, next: (err?: Error) => void) {
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
if (!process.env.AUTH_DISABLED && process.env.AUTH_DISABLED !== '1') {
|
||||
const { getToken } = await import('../../auth')
|
||||
const serverToken = await getToken()
|
||||
if (serverToken && token !== serverToken) {
|
||||
return next(new Error('Authentication failed'))
|
||||
}
|
||||
if (!await isAuthEnabled()) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const user = await authenticateUserToken(token || '')
|
||||
if (!user) {
|
||||
return next(new Error('Authentication failed'))
|
||||
}
|
||||
const socketProfile = String(socket.handshake.query?.profile || '').trim()
|
||||
if (socketProfile && !this.canAccessProfile(user, socketProfile)) {
|
||||
return next(new Error('Profile access denied'))
|
||||
}
|
||||
socket.data.user = user
|
||||
next()
|
||||
}
|
||||
|
||||
// --- Connection handler ---
|
||||
|
||||
private onConnection(socket: Socket) {
|
||||
const socketUser = socket.data.user as AuthenticatedUser | undefined
|
||||
const socketProfile = (socket.handshake.query?.profile as string) || 'default'
|
||||
const currentProfile = () => getActiveProfileName() || socketProfile || 'default'
|
||||
const currentProfile = () => socketProfile || getActiveProfileName() || 'default'
|
||||
const profileExists = (profile: string) => {
|
||||
if (!profile || profile === 'default') return true
|
||||
return listProfileNamesFromDisk().includes(profile)
|
||||
}
|
||||
const resolveRunProfile = (sessionId?: string, requested?: string) => {
|
||||
const requestedProfile = typeof requested === 'string' ? requested.trim() : ''
|
||||
if (requestedProfile && profileExists(requestedProfile)) return requestedProfile
|
||||
if (!sessionId) return currentProfile()
|
||||
if (requestedProfile) {
|
||||
if (!profileExists(requestedProfile)) throw new Error(`Profile "${requestedProfile}" does not exist`)
|
||||
if (socketUser && !this.canAccessProfile(socketUser, requestedProfile)) {
|
||||
throw new Error(`Profile "${requestedProfile}" is not available for this user`)
|
||||
}
|
||||
return requestedProfile
|
||||
}
|
||||
if (!sessionId) {
|
||||
const profile = currentProfile()
|
||||
if (socketUser && !this.canAccessProfile(socketUser, profile)) {
|
||||
throw new Error(`Profile "${profile}" is not available for this user`)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
const storedProfile = getSession(sessionId)?.profile || ''
|
||||
return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
|
||||
const profile = storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
|
||||
if (socketUser && !this.canAccessProfile(socketUser, profile)) {
|
||||
throw new Error(`Profile "${profile}" is not available for this user`)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
socket.on('run', async (data: {
|
||||
@@ -81,7 +107,17 @@ export class ChatRunSocket {
|
||||
source?: string
|
||||
profile?: string
|
||||
}) => {
|
||||
const runProfile = resolveRunProfile(data.session_id, data.profile)
|
||||
let runProfile: string
|
||||
try {
|
||||
runProfile = resolveRunProfile(data.session_id, data.profile)
|
||||
} catch (err) {
|
||||
socket.emit('run.failed', {
|
||||
event: 'run.failed',
|
||||
session_id: data.session_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (data.session_id) {
|
||||
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
||||
const source = resolveRunSource(data.source, data.session_id)
|
||||
@@ -313,6 +349,10 @@ export class ChatRunSocket {
|
||||
}
|
||||
}
|
||||
|
||||
private canAccessProfile(user: AuthenticatedUser, profile: string): boolean {
|
||||
return user.role === 'super_admin' || userCanAccessProfile(user.id, profile)
|
||||
}
|
||||
|
||||
/** Close all active upstream response streams */
|
||||
close() {
|
||||
for (const [sessionId, state] of this.sessionMap.entries()) {
|
||||
|
||||
+54
-17
@@ -12,9 +12,15 @@ vi.mock('@/router', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, request } from '../../packages/client/src/api/client'
|
||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
||||
import router from '@/router'
|
||||
|
||||
function fakeJwt(payload: Record<string, unknown>) {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
@@ -42,6 +48,17 @@ describe('API Client', () => {
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(getApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('reads the role from the stored JWT payload', () => {
|
||||
setApiKey(fakeJwt({ sub: '1', role: 'super_admin' }))
|
||||
|
||||
expect(getStoredUserRole()).toBe('super_admin')
|
||||
expect(isStoredSuperAdmin()).toBe(true)
|
||||
|
||||
setApiKey(fakeJwt({ sub: '2', role: 'admin' }))
|
||||
expect(getStoredUserRole()).toBe('admin')
|
||||
expect(isStoredSuperAdmin()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request', () => {
|
||||
@@ -56,6 +73,16 @@ describe('API Client', () => {
|
||||
expect(options.headers.Authorization).toBe('Bearer secret-key')
|
||||
})
|
||||
|
||||
it('adds the active profile header, including default', async () => {
|
||||
localStorage.setItem('hermes_active_profile_name', 'default')
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
await request('/api/hermes/sessions')
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0]
|
||||
expect(options.headers['X-Hermes-Profile']).toBe('default')
|
||||
})
|
||||
|
||||
it('does not add Authorization header when no token', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) })
|
||||
|
||||
@@ -74,6 +101,32 @@ describe('API Client', () => {
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('emits a global auth notice on local 403 responses', async () => {
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('hermes-auth-notice', listener)
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') })
|
||||
|
||||
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' })
|
||||
window.removeEventListener('hermes-auth-notice', listener)
|
||||
})
|
||||
|
||||
it('clears token and redirects when the JWT user no longer exists', async () => {
|
||||
setApiKey('stale-jwt')
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'),
|
||||
})
|
||||
|
||||
await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403')
|
||||
|
||||
expect(hasApiKey()).toBe(false)
|
||||
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied v1 endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
@@ -82,22 +135,6 @@ describe('API Client', () => {
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied jobs endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/jobs')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT clear token on 401 for proxied skills endpoints', async () => {
|
||||
setApiKey('secret-key')
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') })
|
||||
|
||||
await expect(request('/api/hermes/skills')).rejects.toThrow('API Error 401')
|
||||
expect(hasApiKey()).toBe(true)
|
||||
})
|
||||
|
||||
it('throws error on non-401 failure', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -49,9 +49,10 @@ describe('Kanban API', () => {
|
||||
it('builds board-scoped kanban event websocket URLs with auth token', () => {
|
||||
mockGetBaseUrlValue.mockReturnValue('https://wui.example.test')
|
||||
mockGetApiKey.mockReturnValue('token value')
|
||||
localStorage.setItem('hermes_active_profile_name', 'research')
|
||||
|
||||
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value')
|
||||
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value')
|
||||
expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value&profile=research')
|
||||
expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value&profile=research')
|
||||
})
|
||||
|
||||
it('serializes board, list filters, and archived inclusion into query params', async () => {
|
||||
|
||||
@@ -32,43 +32,38 @@ vi.mock('@/api/auth', () => ({
|
||||
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
describe('LoginView token login', () => {
|
||||
describe('LoginView password login', () => {
|
||||
beforeEach(() => {
|
||||
delete (window as any).__LOGIN_TOKEN__
|
||||
vi.clearAllMocks()
|
||||
mockHasApiKey.mockReturnValue(false)
|
||||
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: false })
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200 })
|
||||
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' })
|
||||
})
|
||||
|
||||
it('validates token login against the Hermes sessions endpoint', async () => {
|
||||
it('logs in with username and password', async () => {
|
||||
mockLoginWithPassword.mockResolvedValue('jwt-token')
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
await wrapper.find('input.login-input').setValue('secret-token')
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('123456')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', {
|
||||
headers: { Authorization: 'Bearer secret-token' },
|
||||
})
|
||||
expect(mockSetApiKey).toHaveBeenCalledWith('secret-token')
|
||||
expect(mockLoginWithPassword).toHaveBeenCalledWith('admin', '123456')
|
||||
expect(mockSetApiKey).toHaveBeenCalledWith('jwt-token')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
|
||||
})
|
||||
|
||||
it('keeps the existing invalid-token behavior on 401', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 401 })
|
||||
it('shows an error when password login fails', async () => {
|
||||
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
await wrapper.find('input.login-input').setValue('bad-token')
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('bad-password')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', {
|
||||
headers: { Authorization: 'Bearer bad-token' },
|
||||
})
|
||||
expect(wrapper.find('.login-error').text()).toBe('login.invalidToken')
|
||||
expect(wrapper.find('.login-error').text()).toBe('Invalid username or password')
|
||||
expect(mockSetApiKey).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -207,12 +207,11 @@ describe('Profiles Store', () => {
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
|
||||
it('switchProfile rolls back if backend reports different active profile', async () => {
|
||||
it('switchProfile keeps the local selected profile independent of backend active flags', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
// Backend returns success, but active profile is still default (not the one we switched to)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', alias: '' },
|
||||
@@ -222,11 +221,8 @@ describe('Profiles Store', () => {
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return false (backend verification failed)
|
||||
expect(result).toBe(false)
|
||||
// activeProfileName should be rolled back to default
|
||||
expect(store.activeProfileName).toBe('default')
|
||||
// localStorage should be rolled back
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('default')
|
||||
expect(result).toBe(true)
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('Hermes schema initialization', () => {
|
||||
})
|
||||
|
||||
it('initializes all tables with correct schemas', async () => {
|
||||
const { initAllHermesTables, USAGE_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, GC_ROOMS_TABLE } =
|
||||
const { initAllHermesTables, USAGE_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, GC_ROOMS_TABLE, USERS_TABLE, USER_PROFILES_TABLE } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
expect(() => initAllHermesTables()).not.toThrow()
|
||||
@@ -32,6 +32,8 @@ describe('Hermes schema initialization', () => {
|
||||
expect(tables.map(t => t.name)).toContain(SESSIONS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(MESSAGES_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(GC_ROOMS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(USERS_TABLE)
|
||||
expect(tables.map(t => t.name)).toContain(USER_PROFILES_TABLE)
|
||||
|
||||
// Verify USAGE_TABLE structure
|
||||
const usageCols = db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string }>
|
||||
@@ -39,6 +41,17 @@ describe('Hermes schema initialization', () => {
|
||||
expect(usageCols.some(c => c.name === 'session_id')).toBe(true)
|
||||
expect(usageCols.some(c => c.name === 'input_tokens')).toBe(true)
|
||||
expect(usageCols.some(c => c.name === 'output_tokens')).toBe(true)
|
||||
|
||||
const userCols = db.prepare(`PRAGMA table_info("${USERS_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(userCols.some(c => c.name === 'id')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'username')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'password_hash')).toBe(true)
|
||||
expect(userCols.some(c => c.name === 'role')).toBe(true)
|
||||
|
||||
const profileCols = db.prepare(`PRAGMA table_info("${USER_PROFILES_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(profileCols.some(c => c.name === 'user_id')).toBe(true)
|
||||
expect(profileCols.some(c => c.name === 'profile_name')).toBe(true)
|
||||
expect(profileCols.some(c => c.name === 'is_default')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves existing data when adding safe schema columns', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ const mockSearchSessions = vi.hoisted(() => vi.fn())
|
||||
const mockGetSessionDetail = vi.hoisted(() => vi.fn())
|
||||
const mockGetExactSessionDetail = vi.hoisted(() => vi.fn())
|
||||
const mockFindLatestExactSessionId = vi.hoisted(() => vi.fn())
|
||||
const mockListUserProfiles = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
@@ -75,6 +76,10 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
findLatestExactSessionIdWithProfile: mockFindLatestExactSessionId,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
||||
listUserProfiles: mockListUserProfiles,
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/kanban'
|
||||
|
||||
function ctx(overrides: Record<string, any> = {}) {
|
||||
@@ -91,6 +96,7 @@ function ctx(overrides: Record<string, any> = {}) {
|
||||
describe('kanban controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockListUserProfiles.mockReturnValue([{ profile_name: 'research' }])
|
||||
})
|
||||
|
||||
it('lists boards and tasks with explicit/default board context', async () => {
|
||||
@@ -129,6 +135,48 @@ describe('kanban controller', () => {
|
||||
expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false })
|
||||
})
|
||||
|
||||
it('filters kanban tasks, stats, and assignees to the requested profile', async () => {
|
||||
const tasks = [
|
||||
{ id: 'task-1', assignee: 'research', status: 'todo' },
|
||||
{ id: 'task-2', assignee: 'travel', status: 'done' },
|
||||
{ id: 'task-3', assignee: null, status: 'blocked' },
|
||||
]
|
||||
mockListTasks.mockResolvedValue(tasks)
|
||||
mockGetAssignees.mockResolvedValue([
|
||||
{ name: 'research', on_disk: true, counts: { todo: 1 } },
|
||||
{ name: 'travel', on_disk: true, counts: { done: 1 } },
|
||||
{ name: 'default', on_disk: true, counts: { blocked: 1 } },
|
||||
])
|
||||
|
||||
const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } }
|
||||
const listCtx = ctx({ state, query: { board: 'default', includeArchived: 'true' } })
|
||||
await ctrl.list(listCtx)
|
||||
expect(listCtx.body).toEqual({ tasks: [tasks[0]] })
|
||||
|
||||
const statsCtx = ctx({ state, query: { board: 'default' } })
|
||||
await ctrl.stats(statsCtx)
|
||||
expect(statsCtx.body).toEqual({ stats: { by_status: { todo: 1 }, by_assignee: { research: 1 }, total: 1 } })
|
||||
|
||||
const assigneesCtx = ctx({ state, query: { board: 'default' } })
|
||||
await ctrl.assignees(assigneesCtx)
|
||||
expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'research', on_disk: true, counts: { todo: 1 } }] })
|
||||
})
|
||||
|
||||
it('defaults created kanban tasks to the requested profile and rejects unauthorized assignees', async () => {
|
||||
mockCreateTask.mockResolvedValue({ id: 'task-1', assignee: 'research' })
|
||||
const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } }
|
||||
|
||||
const createCtx = ctx({ state, query: { board: 'default' }, request: { body: { title: 'Ship it' } } })
|
||||
await ctrl.create(createCtx)
|
||||
expect(mockCreateTask).toHaveBeenCalledWith('Ship it', { board: 'default', body: undefined, assignee: 'research', priority: undefined, tenant: undefined })
|
||||
expect(createCtx.body).toEqual({ task: { id: 'task-1', assignee: 'research' } })
|
||||
|
||||
const assignCtx = ctx({ state, query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'travel' } } })
|
||||
await ctrl.assign(assignCtx)
|
||||
expect(assignCtx.status).toBe(403)
|
||||
expect(mockAssignTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proxies comment/log/diagnostics with explicit board context', async () => {
|
||||
const taskLog = { task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false }
|
||||
mockAddComment.mockResolvedValue({ ok: true, output: 'commented' })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync, mockListProfileNamesFromDisk, mockListUserProfiles } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockReadConfigYamlForProfile: vi.fn(),
|
||||
@@ -10,6 +10,8 @@ const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetc
|
||||
mockWriteAppConfig: vi.fn(),
|
||||
mockExistsSync: vi.fn(() => false),
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockListProfileNamesFromDisk: vi.fn(() => ['default']),
|
||||
mockListUserProfiles: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
@@ -26,7 +28,11 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveAuthPath: () => '/fake/home/.hermes/auth.json',
|
||||
getActiveProfileName: () => 'default',
|
||||
getProfileDir: () => '/fake/home/.hermes',
|
||||
listProfileNamesFromDisk: () => ['default'],
|
||||
listProfileNamesFromDisk: mockListProfileNamesFromDisk,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
||||
listUserProfiles: mockListUserProfiles,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
@@ -104,6 +110,8 @@ beforeEach(() => {
|
||||
mockWriteAppConfig.mockImplementation(async patch => patch)
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockReadFileSync.mockReturnValue('{}')
|
||||
mockListProfileNamesFromDisk.mockReturnValue(['default'])
|
||||
mockListUserProfiles.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('models controller — model visibility', () => {
|
||||
@@ -151,6 +159,44 @@ describe('models controller — model visibility', () => {
|
||||
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
|
||||
})
|
||||
})
|
||||
|
||||
it('limits the default available-models response to profiles bound to regular admins', async () => {
|
||||
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research', 'private'])
|
||||
mockListUserProfiles.mockReturnValue([
|
||||
{ user_id: 7, profile_name: 'research', is_default: 1, created_at: 1 },
|
||||
])
|
||||
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({
|
||||
model: {
|
||||
default: `${profile}-model`,
|
||||
provider: 'deepseek',
|
||||
},
|
||||
}))
|
||||
|
||||
const ctx = makeCtx()
|
||||
ctx.state = { user: { id: 7, username: 'ops', role: 'admin' } }
|
||||
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : '')
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
|
||||
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
|
||||
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
|
||||
expect(ctx.body.groups).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ provider: 'deepseek' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('uses explicit query profile for single-profile model fetches', async () => {
|
||||
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research'])
|
||||
|
||||
const ctx = makeCtx()
|
||||
ctx.query = { profile: 'research' }
|
||||
ctx.state = { profile: { name: 'default' }, user: { id: 1, username: 'admin', role: 'super_admin' } }
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
|
||||
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
|
||||
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
|
||||
})
|
||||
it('accepts OAuth providers stored in credential_pool entries', async () => {
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
|
||||
@@ -37,4 +37,22 @@ describe('performance monitor controller', () => {
|
||||
expect(ctx.status).toBeUndefined()
|
||||
expect(ctx.body).toEqual({ timestamp: 0, error: 'boom' })
|
||||
})
|
||||
|
||||
it('requires super admin on the runtime route', async () => {
|
||||
const { performanceMonitorRoutes } = await import('../../packages/server/src/routes/hermes/performance-monitor')
|
||||
const layer = performanceMonitorRoutes.stack.find((entry: any) => entry.path === '/api/hermes/performance/runtime')
|
||||
expect(layer).toBeTruthy()
|
||||
|
||||
const deniedCtx: any = { state: { user: { role: 'admin' } }, status: 200, body: null }
|
||||
const deniedNext = vi.fn(async () => {})
|
||||
await layer.stack[0](deniedCtx, deniedNext)
|
||||
|
||||
expect(deniedCtx.status).toBe(403)
|
||||
expect(deniedNext).not.toHaveBeenCalled()
|
||||
|
||||
const allowedCtx: any = { state: { user: { role: 'super_admin' } }, status: 200, body: null }
|
||||
const allowedNext = vi.fn(async () => {})
|
||||
await layer.stack[0](allowedCtx, allowedNext)
|
||||
expect(allowedNext).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ const getConversationDetailFromDbMock = vi.fn()
|
||||
const listConversationSummariesMock = vi.fn()
|
||||
const getConversationDetailMock = vi.fn()
|
||||
const getSessionDetailFromDbMock = vi.fn()
|
||||
const getSessionDetailFromDbWithProfileMock = vi.fn()
|
||||
const getExactSessionDetailFromDbWithProfileMock = vi.fn()
|
||||
const getUsageStatsFromDbMock = vi.fn()
|
||||
const getSessionMock = vi.fn()
|
||||
@@ -51,6 +52,7 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: vi.fn(),
|
||||
searchSessionSummaries: vi.fn(),
|
||||
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
||||
getSessionDetailFromDbWithProfile: getSessionDetailFromDbWithProfileMock,
|
||||
getExactSessionDetailFromDbWithProfile: getExactSessionDetailFromDbWithProfileMock,
|
||||
getUsageStatsFromDb: getUsageStatsFromDbMock,
|
||||
}))
|
||||
@@ -109,6 +111,7 @@ describe('session conversations controller', () => {
|
||||
listConversationSummariesMock.mockReset()
|
||||
getConversationDetailMock.mockReset()
|
||||
getSessionDetailFromDbMock.mockReset()
|
||||
getSessionDetailFromDbWithProfileMock.mockReset()
|
||||
getExactSessionDetailFromDbWithProfileMock.mockReset()
|
||||
getUsageStatsFromDbMock.mockReset()
|
||||
getSessionMock.mockReset()
|
||||
@@ -157,7 +160,7 @@ describe('session conversations controller', () => {
|
||||
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
|
||||
await mod.listConversations(ctx)
|
||||
|
||||
expect(localListSessionsMock).toHaveBeenCalledWith('default', undefined, 5)
|
||||
expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 5)
|
||||
expect(listConversationSummariesMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' })
|
||||
})
|
||||
@@ -261,6 +264,33 @@ describe('session conversations controller', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads Hermes history detail from the requested profile database', async () => {
|
||||
localGetSessionDetailMock.mockReturnValue(null)
|
||||
getSessionDetailFromDbWithProfileMock.mockResolvedValue({
|
||||
id: 'travel-session',
|
||||
source: 'cli',
|
||||
title: 'Travel detail',
|
||||
messages: [
|
||||
{ id: 1, session_id: 'travel-session', role: 'user', content: 'from travel', timestamp: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = { params: { id: 'travel-session' }, query: { profile: 'travel' }, body: null }
|
||||
await mod.getHermesSession(ctx)
|
||||
|
||||
expect(localGetSessionDetailMock).toHaveBeenCalledWith('travel-session')
|
||||
expect(getSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('travel-session', 'travel')
|
||||
expect(getSessionDetailFromDbMock).not.toHaveBeenCalled()
|
||||
expect(getSessionMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body.session).toMatchObject({
|
||||
id: 'travel-session',
|
||||
profile: 'travel',
|
||||
title: 'Travel detail',
|
||||
messages: [{ content: 'from travel' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not return api_server sessions from the Hermes history detail endpoint', async () => {
|
||||
localGetSessionDetailMock.mockReturnValue({
|
||||
id: 'api-1',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('user auth tables and middleware', () => {
|
||||
let db: any = null
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('AUTH_JWT_SECRET', 'test-secret')
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
db = new DatabaseSync(':memory:')
|
||||
vi.doMock('../../packages/server/src/db/index', () => ({
|
||||
getDb: () => db,
|
||||
getStoragePath: () => ':memory:',
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = null
|
||||
vi.doUnmock('../../packages/server/src/db/index')
|
||||
vi.doUnmock('../../packages/server/src/services/hermes/hermes-profile')
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function initUsers() {
|
||||
const schemas = await import('../../packages/server/src/db/hermes/schemas')
|
||||
schemas.initAllHermesTables()
|
||||
return {
|
||||
schemas,
|
||||
users: await import('../../packages/server/src/db/hermes/users-store'),
|
||||
auth: await import('../../packages/server/src/middleware/user-auth'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx(user: any, profile: string) {
|
||||
return {
|
||||
state: { user },
|
||||
query: { profile },
|
||||
request: { body: {} },
|
||||
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? '' : ''),
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
}
|
||||
|
||||
it('creates the default super admin without profile bindings', async () => {
|
||||
const { schemas, users } = await initUsers()
|
||||
|
||||
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
||||
expect(created?.id).toBe(1)
|
||||
|
||||
const row = db.prepare(`SELECT * FROM ${schemas.USERS_TABLE} WHERE id = ?`).get(1) as any
|
||||
expect(row.username).toBe('admin')
|
||||
expect(row.role).toBe('super_admin')
|
||||
expect(row.status).toBe('active')
|
||||
expect(row.password_hash).not.toBe('123456')
|
||||
expect(users.verifyPassword('123456', row.password_hash)).toBe(true)
|
||||
|
||||
const profileCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USER_PROFILES_TABLE} WHERE user_id = ?`).get(1) as any
|
||||
expect(profileCount.count).toBe(0)
|
||||
})
|
||||
|
||||
it('allows super admin to access profiles without explicit binding', async () => {
|
||||
const { users, auth } = await initUsers()
|
||||
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
||||
expect(created?.role).toBe('super_admin')
|
||||
|
||||
const ctx = makeCtx({ id: created?.id, username: 'admin', role: 'super_admin' }, 'research')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await auth.resolveUserProfile(ctx, next)
|
||||
|
||||
expect(ctx.state.profile).toEqual({ name: 'research' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires regular admins to be associated with the requested profile', async () => {
|
||||
const { schemas, users, auth } = await initUsers()
|
||||
const now = Date.now()
|
||||
db.prepare(
|
||||
`INSERT INTO ${schemas.USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run('ops', users.hashPassword('secret'), 'admin', 'active', now, now)
|
||||
const admin = users.findUserByUsername('ops')
|
||||
expect(admin?.id).toBe(1)
|
||||
|
||||
const deniedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
|
||||
await auth.resolveUserProfile(deniedCtx, vi.fn(async () => {}))
|
||||
expect(deniedCtx.status).toBe(403)
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO ${schemas.USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at)
|
||||
VALUES (?, ?, 1, ?)`
|
||||
).run(admin!.id, 'research', now)
|
||||
|
||||
const allowedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research')
|
||||
const next = vi.fn(async () => {})
|
||||
await auth.resolveUserProfile(allowedCtx, next)
|
||||
|
||||
expect(allowedCtx.state.profile).toEqual({ name: 'research' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not infer a profile when the frontend does not send one', async () => {
|
||||
const { auth } = await initUsers()
|
||||
const ctx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, '')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await auth.resolveUserProfile(ctx, next)
|
||||
|
||||
expect(ctx.state.profile).toBeUndefined()
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
|
||||
await auth.requireUserProfile(ctx, vi.fn(async () => {}))
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Profile is required' })
|
||||
})
|
||||
|
||||
it('ignores stale profile headers for the aggregate available-models endpoint', async () => {
|
||||
const { auth } = await initUsers()
|
||||
const ctx = {
|
||||
path: '/api/hermes/available-models',
|
||||
state: { user: { id: 1, username: 'ops', role: 'admin' } },
|
||||
query: {},
|
||||
request: { body: {} },
|
||||
get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : ''),
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await auth.resolveUserProfile(ctx, next)
|
||||
|
||||
expect(ctx.state.profile).toBeUndefined()
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not create the default super admin until first valid bootstrap login', async () => {
|
||||
const { schemas, users } = await initUsers()
|
||||
|
||||
expect(users.countUsers()).toBe(0)
|
||||
expect(users.bootstrapDefaultSuperAdmin('admin', 'bad-password')).toBeNull()
|
||||
expect(users.countUsers()).toBe(0)
|
||||
|
||||
const created = users.bootstrapDefaultSuperAdmin('admin', '123456')
|
||||
expect(created?.role).toBe('super_admin')
|
||||
expect(users.countUsers()).toBe(1)
|
||||
|
||||
const userCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USERS_TABLE}`).get() as any
|
||||
expect(userCount.count).toBe(1)
|
||||
})
|
||||
|
||||
it('signs and verifies user JWTs', async () => {
|
||||
const { auth } = await initUsers()
|
||||
const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000)
|
||||
|
||||
const payload = auth.verifyUserJwt(token, 'secret', 1000)
|
||||
expect(payload?.sub).toBe('1')
|
||||
expect(payload?.username).toBe('admin')
|
||||
expect(payload?.role).toBe('super_admin')
|
||||
|
||||
expect(auth.verifyUserJwt(token, 'wrong', 1000)).toBeNull()
|
||||
})
|
||||
|
||||
it('authenticates JWTs passed as query tokens for download and websocket URLs', async () => {
|
||||
const { users, auth } = await initUsers()
|
||||
const user = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
||||
const token = auth.signUserJwt(user, 'test-secret')
|
||||
const ctx = {
|
||||
headers: {},
|
||||
query: { token },
|
||||
state: {},
|
||||
request: { body: {} },
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await auth.requireUserJwt(ctx, next)
|
||||
|
||||
expect(ctx.state.user).toEqual({ id: user.id, username: 'admin', role: 'super_admin' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bootstraps the default super admin through password login and returns a user JWT', async () => {
|
||||
await initUsers()
|
||||
const ctrl = await import('../../packages/server/src/controllers/auth')
|
||||
const ctx = {
|
||||
request: { body: { username: 'admin', password: '123456' } },
|
||||
headers: {},
|
||||
ip: '127.0.0.1',
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
|
||||
await ctrl.login(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/)
|
||||
})
|
||||
|
||||
it('lets super admins create regular admins with profile bindings', async () => {
|
||||
const { users } = await initUsers()
|
||||
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
listProfileNamesFromDisk: () => ['default', 'research'],
|
||||
}))
|
||||
const ctrl = await import('../../packages/server/src/controllers/auth')
|
||||
const ctx = {
|
||||
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
|
||||
request: {
|
||||
body: {
|
||||
username: 'ops',
|
||||
password: 'secret1',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
profiles: ['research'],
|
||||
},
|
||||
},
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
|
||||
await ctrl.createManagedUser(ctx)
|
||||
|
||||
expect(ctx.status).toBe(201)
|
||||
const created = users.findUserByUsername('ops')
|
||||
expect(created?.role).toBe('admin')
|
||||
expect(users.listUserProfiles(created!.id).map(profile => profile.profile_name)).toEqual(['research'])
|
||||
})
|
||||
|
||||
it('does not allow disabling the last active super admin', async () => {
|
||||
const { users } = await initUsers()
|
||||
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
||||
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
listProfileNamesFromDisk: () => ['default'],
|
||||
}))
|
||||
const ctrl = await import('../../packages/server/src/controllers/auth')
|
||||
const ctx = {
|
||||
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
|
||||
params: { id: String(admin.id) },
|
||||
request: { body: { status: 'disabled' } },
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
|
||||
await ctrl.updateManagedUser(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'You cannot disable your own account' })
|
||||
})
|
||||
|
||||
it('requires super admin for super-admin-only middleware', async () => {
|
||||
const { auth } = await initUsers()
|
||||
const adminCtx = makeCtx({ id: 2, username: 'ops', role: 'admin' }, 'default')
|
||||
await auth.requireSuperAdmin(adminCtx, vi.fn(async () => {}))
|
||||
expect(adminCtx.status).toBe(403)
|
||||
|
||||
const superCtx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, 'default')
|
||||
const next = vi.fn(async () => {})
|
||||
await auth.requireSuperAdmin(superCtx, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user