feat: add username/password login, account settings, and changelog (#133)

- Add username/password login as additional auth mechanism alongside existing token
- First login must use token; password can be configured in Settings > Account
- Password login returns the existing static token (no auth middleware changes)
- Add account settings: setup, change password, change username, remove password
- Add logout button to sidebar footer
- Add version changelog popup (click version number in sidebar)
- Support all 8 locales (en, zh, de, es, fr, ja, ko, pt)
- Bump version to 0.4.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-22 20:25:31 +08:00
parent 6f69c69802
commit 6554e52d35
19 changed files with 1155 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-web-ui",
"version": "0.4.2",
"version": "0.4.3",
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
"repository": {
"type": "git",
+53
View File
@@ -0,0 +1,53 @@
import { request } from './client'
export interface AuthStatus {
hasPasswordLogin: boolean
username: string | null
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch('/api/auth/status')
if (!res.ok) throw new Error('Failed to fetch auth status')
return res.json()
}
export async function loginWithPassword(username: string, password: string): Promise<string> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Login failed')
}
const data = await res.json()
return data.token
}
export async function setupPassword(username: string, password: string): Promise<void> {
return request('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return request('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
}
export async function changeUsername(currentPassword: string, newUsername: string): Promise<void> {
return request('/api/auth/change-username', {
method: 'POST',
body: JSON.stringify({ currentPassword, newUsername }),
})
}
export async function removePassword(): Promise<void> {
return request('/api/auth/password', {
method: 'DELETE',
})
}
@@ -0,0 +1,258 @@
<script setup lang="ts">
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 } 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("");
const newPasswordVal = ref("");
const newPasswordConfirm = ref("");
// Change username form
const showChangeUsernameModal = ref(false);
const currentPasswordForName = ref("");
const newUsernameVal = ref("");
onMounted(async () => {
try {
const status = await fetchAuthStatus();
hasPasswordLogin.value = status.hasPasswordLogin;
username.value = status.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"));
return;
}
if (newPasswordVal.value.length < 6) {
message.error(t("login.passwordTooShort"));
return;
}
loading.value = true;
try {
await changePassword(currentPasswordForPwd.value, newPasswordVal.value);
showChangePasswordModal.value = false;
currentPasswordForPwd.value = "";
newPasswordVal.value = "";
newPasswordConfirm.value = "";
message.success(t("login.passwordChanged"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
async function handleChangeUsername() {
if (newUsernameVal.value.trim().length < 2) {
message.error(t("login.usernameTooShort"));
return;
}
loading.value = true;
try {
await changeUsername(currentPasswordForName.value, newUsernameVal.value.trim());
username.value = newUsernameVal.value.trim();
showChangeUsernameModal.value = false;
currentPasswordForName.value = "";
newUsernameVal.value = "";
message.success(t("login.usernameChanged"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
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 = "";
newPasswordConfirm.value = "";
showChangePasswordModal.value = true;
}
function openChangeUsernameModal() {
currentPasswordForName.value = "";
newUsernameVal.value = "";
showChangeUsernameModal.value = true;
}
</script>
<template>
<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="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>
<!-- 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">
<NFormItem :label="t('login.currentPassword')">
<NInput v-model:value="currentPasswordForPwd" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
</NFormItem>
<NFormItem :label="t('login.newPassword')">
<NInput v-model:value="newPasswordVal" type="password" show-password-on="click" :placeholder="t('login.newPassword')" />
</NFormItem>
<NFormItem :label="t('login.confirmPassword')">
<NInput v-model:value="newPasswordConfirm" type="password" show-password-on="click" :placeholder="t('login.confirmPassword')" @keyup.enter="handleChangePassword" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showChangePasswordModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleChangePassword">{{ t("common.save") }}</NButton>
</template>
</NModal>
<!-- Change username modal -->
<NModal v-model:show="showChangeUsernameModal" preset="dialog" :title="t('login.changeUsername')">
<NForm label-placement="top">
<NFormItem :label="t('login.currentPassword')">
<NInput v-model:value="currentPasswordForName" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
</NFormItem>
<NFormItem :label="t('login.newUsername')">
<NInput v-model:value="newUsernameVal" :placeholder="t('login.usernamePlaceholder')" @keyup.enter="handleChangeUsername" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showChangeUsernameModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleChangeUsername">{{ t("common.save") }}</NButton>
</template>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.account-settings {
padding: 8px 0;
}
.section-desc {
font-size: 13px;
color: $text-muted;
margin: 0 0 20px;
line-height: 1.6;
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.action-label {
font-size: 14px;
color: $text-secondary;
}
.action-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
</style>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, reactive } from "vue";
import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { NButton, useMessage } from "naive-ui";
import { NButton, NModal, useMessage } from "naive-ui";
import { useAppStore } from "@/stores/hermes/app";
import ModelSelector from "./ModelSelector.vue";
import ProfileSelector from "./ProfileSelector.vue";
@@ -13,6 +13,8 @@ import danceVideoLight from "@/assets/dance-light.mp4";
import danceVideoDark from "@/assets/dance-dark.mp4";
import { useTheme } from "@/composables/useTheme";
import { clearApiKey } from "@/api/client";
import { changelog } from "@/data/changelog";
const { t } = useI18n();
const { isDark } = useTheme();
@@ -46,6 +48,18 @@ async function handleUpdate() {
message.error(t('sidebar.updateFailed'));
}
}
function handleLogout() {
clearApiKey();
router.replace({ name: 'login' });
}
// Changelog
const showChangelog = ref(false);
function openChangelog() {
showChangelog.value = true;
}
</script>
<template>
@@ -219,6 +233,14 @@ async function handleUpdate() {
<ModelSelector />
<div class="sidebar-footer">
<button class="nav-item logout-item" @click="handleLogout">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>{{ t("sidebar.logout") }}</span>
</button>
<div class="status-row">
<div
class="status-indicator"
@@ -240,13 +262,28 @@ async function handleUpdate() {
<a class="github-link" href="https://github.com/EKKOLearnAI/hermes-web-ui" target="_blank" rel="noopener noreferrer" title="GitHub">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</a>
<span>Hermes Web UI v{{ appStore.serverVersion || "0.1.0" }}</span>
<span class="version-text" @click="openChangelog">Hermes Web UI v{{ appStore.serverVersion || "0.1.0" }}</span>
<ThemeSwitch />
</div>
<NButton v-if="appStore.updateAvailable" type="primary" size="tiny" block class="update-btn" :loading="appStore.updating" @click="handleUpdate">
{{ appStore.updating ? t('sidebar.updating') : t('sidebar.updateVersion', { version: appStore.latestVersion }) }}
</NButton>
</div>
<!-- Changelog modal -->
<NModal v-model:show="showChangelog" preset="dialog" :title="t('sidebar.changelog')" style="width: 520px;">
<div class="changelog-list">
<div v-for="entry in changelog" :key="entry.version" class="changelog-version-block">
<div class="changelog-version-header">
<span class="changelog-version-tag">v{{ entry.version }}</span>
<span class="changelog-date">{{ entry.date }}</span>
</div>
<ul class="changelog-changes">
<li v-for="(change, idx) in entry.changes" :key="idx">{{ t(change) }}</li>
</ul>
</div>
</div>
</NModal>
</aside>
</template>
@@ -396,10 +433,23 @@ async function handleUpdate() {
}
.sidebar-footer {
padding-top: 16px;
padding-top: 8px;
border-top: 1px solid $border-color;
}
.logout-item {
margin: 0 -12px;
padding: 10px 12px;
border-radius: 0;
font-size: 13px;
color: $text-muted;
&:hover {
color: $error;
background: rgba(var(--error-rgb, 239, 68, 68), 0.06);
}
}
.status-row {
display: flex;
align-items: center;
@@ -461,6 +511,66 @@ async function handleUpdate() {
border-radius: 4px;
}
.version-text {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $accent-primary;
}
}
.changelog-list {
max-height: 400px;
overflow-y: auto;
}
.changelog-version-block {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.changelog-version-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.changelog-version-tag {
font-weight: 600;
font-size: 14px;
color: $text-primary;
font-family: $font-code;
}
.changelog-changes {
list-style: none;
padding: 0;
margin: 0;
li {
font-size: 13px;
color: $text-secondary;
padding: 4px 0 4px 16px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: $text-muted;
}
}
}
@media (max-width: $breakpoint-mobile) {
.logo-dance {
display: none;
+23
View File
@@ -0,0 +1,23 @@
export interface ChangelogEntry {
version: string
date: string
changes: string[]
}
export const changelog: ChangelogEntry[] = [
{
version: '0.4.3',
date: '2026-04-22',
changes: ['changelog.new_0_4_3_1', 'changelog.new_0_4_3_2', 'changelog.new_0_4_3_3', 'changelog.new_0_4_3_4'],
},
{
version: '0.4.2',
date: '2026-03-20',
changes: ['changelog.new_0_4_2_1', 'changelog.new_0_4_2_2', 'changelog.new_0_4_2_3', 'changelog.new_0_4_2_4', 'changelog.new_0_4_2_5'],
},
{
version: '0.4.1',
date: '2026-04-21',
changes: ['changelog.new_0_4_1_1'],
},
]
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'Bitte geben Sie Ihren Zugangs-Token ein',
invalidToken: 'Ungultiger Token',
connectionFailed: 'Verbindung zum Server nicht moglich',
passwordLogin: 'Passwort',
tokenLogin: 'Token',
usernamePlaceholder: 'Benutzername',
passwordPlaceholder: 'Passwort',
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
passwordMismatch: 'Passworter stimmen nicht uberein',
passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
setupSuccess: 'Passwort-Login erfolgreich konfiguriert',
passwordChanged: 'Passwort erfolgreich geandert',
passwordRemoved: 'Passwort-Login entfernt',
setupPassword: 'Passwort-Login einrichten',
changePassword: 'Passwort andern',
changeUsername: 'Benutzername andern',
removePasswordLogin: 'Entfernen',
username: 'Benutzername',
currentPassword: 'Aktuelles Passwort',
newPassword: 'Neues Passwort',
confirmPassword: 'Passwort bestatigen',
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.',
passwordLoginNotConfigured: 'Passwort-Login ist nicht konfiguriert',
passwordLoginConfigured: 'Passwort-Login aktiviert ({username})',
},
// Common
@@ -57,6 +83,9 @@ export default {
updating: 'Aktualisierung...',
updateSuccess: 'Aktualisierung abgeschlossen, bitte Server neu starten',
updateFailed: 'Aktualisierung fehlgeschlagen',
logout: 'Abmelden',
changelog: 'Anderungsprotokoll',
noChangelog: 'Kein Anderungsprotokoll verfugbar',
},
// Chat
@@ -291,6 +320,7 @@ export default {
saveFailed: 'Speichern fehlgeschlagen',
tabs: {
display: 'Anzeige',
account: 'Konto',
agent: 'Agent',
memory: 'Gedachtnis',
session: 'Sitzung',
@@ -470,4 +500,18 @@ export default {
cost: 'Kosten',
noData: 'Keine Nutzungsdaten',
},
// Anderungsprotokoll
changelog: {
new_0_4_3_1: 'Benutzername/Passwort-Login neben Token-Authentifizierung hinzugefugt',
new_0_4_3_2: 'Kontoeinstellungen fur Anmeldeinformationen hinzugefugt',
new_0_4_3_3: 'Abmelden-Schaltflache in der Seitenleiste hinzugefugt',
new_0_4_3_4: 'Anderungsprotokoll-Popup durch Klick auf Versionsnummer hinzugefugt',
new_0_4_2_1: 'Token-Nutzungsverfolgung und dynamische Kontextlange hinzugefugt',
new_0_4_2_2: 'Sitzungssuche-Modal hinzugefugt',
new_0_4_2_3: 'Gruppenchat-System mit Socket.IO und SQLite wiederhergestellt',
new_0_4_2_4: 'Angeheftete Sitzungen und Live-Monitor hinzugefugt',
new_0_4_2_5: 'Eingebaute Provider-Erkennung und Modellabgleich behoben',
new_0_4_1_1: 'Authentifizierungsumgehung und SPA-Bereitstellung behoben',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'Please enter your access token',
invalidToken: 'Invalid token',
connectionFailed: 'Cannot connect to server',
passwordLogin: 'Password',
tokenLogin: 'Token',
usernamePlaceholder: 'Username',
passwordPlaceholder: 'Password',
credentialsRequired: 'Please enter username and password',
invalidCredentials: 'Invalid username or password',
passwordMismatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 6 characters',
setupSuccess: 'Password login configured successfully',
passwordChanged: 'Password changed successfully',
passwordRemoved: 'Password login removed',
setupPassword: 'Set Up Password Login',
changePassword: 'Change Password',
changeUsername: 'Change Username',
removePasswordLogin: 'Remove',
username: 'Username',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmPassword: 'Confirm Password',
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.',
passwordLoginNotConfigured: 'Password login is not configured',
passwordLoginConfigured: 'Password login enabled ({username})',
},
// Common
@@ -69,6 +95,9 @@ export default {
updating: 'Updating...',
updateSuccess: 'Update complete, please restart the server',
updateFailed: 'Update failed',
logout: 'Sign Out',
changelog: 'Changelog',
noChangelog: 'No changelog available',
},
// Chat
@@ -324,6 +353,7 @@ export default {
saveFailed: 'Save failed',
tabs: {
display: 'Display',
account: 'Account',
agent: 'Agent',
memory: 'Memory',
session: 'Session',
@@ -511,4 +541,18 @@ export default {
cost: 'Cost',
noData: 'No usage data',
},
// Changelog
changelog: {
new_0_4_3_1: 'Add username/password login alongside token authentication',
new_0_4_3_2: 'Add account settings for managing credentials (setup, change password, change username)',
new_0_4_3_3: 'Add logout button to sidebar',
new_0_4_3_4: 'Add version changelog popup (click version number)',
new_0_4_2_1: 'Add token usage tracking, context display, and dynamic context length',
new_0_4_2_2: 'Add session search modal',
new_0_4_2_3: 'Restore group chat system with Socket.IO and SQLite persistence',
new_0_4_2_4: 'Add pinned sessions and live monitor in Chat page',
new_0_4_2_5: 'Fix builtin provider detection and model matching',
new_0_4_1_1: 'Fix auth bypass, SPA serving, and provider improvements',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'Por favor, introduce tu token de acceso',
invalidToken: 'Token invalido',
connectionFailed: 'No se puede conectar al servidor',
passwordLogin: 'Contrasena',
tokenLogin: 'Token',
usernamePlaceholder: 'Nombre de usuario',
passwordPlaceholder: 'Contrasena',
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
passwordMismatch: 'Las contrasenas no coinciden',
passwordTooShort: 'La contrasena debe tener al menos 6 caracteres',
setupSuccess: 'Login con contrasena configurado correctamente',
passwordChanged: 'Contrasena cambiada correctamente',
passwordRemoved: 'Login con contrasena eliminado',
setupPassword: 'Configurar login con contrasena',
changePassword: 'Cambiar contrasena',
changeUsername: 'Cambiar nombre de usuario',
removePasswordLogin: 'Eliminar',
username: 'Nombre de usuario',
currentPassword: 'Contrasena actual',
newPassword: 'Nueva contrasena',
confirmPassword: 'Confirmar contrasena',
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.',
passwordLoginNotConfigured: 'Login con contrasena no configurado',
passwordLoginConfigured: 'Login con contrasena habilitado ({username})',
},
// Common
@@ -57,6 +83,9 @@ export default {
updating: 'Actualizando...',
updateSuccess: 'Actualizacion completa, por favor reinicia el servidor',
updateFailed: 'Error al actualizar',
logout: 'Cerrar sesion',
changelog: 'Registro de cambios',
noChangelog: 'No hay registro de cambios',
},
// Chat
@@ -291,6 +320,7 @@ export default {
saveFailed: 'Error al guardar',
tabs: {
display: 'Pantalla',
account: 'Cuenta',
agent: 'Agente',
memory: 'Memoria',
session: 'Sesion',
@@ -470,4 +500,18 @@ export default {
cost: 'Costo',
noData: 'Sin datos de uso',
},
// Registro de cambios
changelog: {
new_0_4_3_1: 'Agregar inicio de sesion con usuario/contrasena junto a autenticacion por token',
new_0_4_3_2: 'Agregar configuracion de cuenta para gestionar credenciales',
new_0_4_3_3: 'Agregar boton de cerrar sesion en la barra lateral',
new_0_4_3_4: 'Agregar popup de registro de cambios al hacer clic en el numero de version',
new_0_4_2_1: 'Agregar seguimiento de uso de tokens y longitud de contexto dinamico',
new_0_4_2_2: 'Agregar modal de busqueda de sesiones',
new_0_4_2_3: 'Restaurar sistema de chat grupal con Socket.IO y SQLite',
new_0_4_2_4: 'Agregar sesiones fijas y monitor en vivo en la pagina de chat',
new_0_4_2_5: 'Corregir deteccion de proveedores integrados y coincidencia de modelos',
new_0_4_1_1: 'Corregir bypass de autenticacion y servicio de archivos SPA',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'Veuillez entrer votre jeton d\'acces',
invalidToken: 'Jeton invalide',
connectionFailed: 'Impossible de se connecter au serveur',
passwordLogin: 'Mot de passe',
tokenLogin: 'Jeton',
usernamePlaceholder: 'Nom d\'utilisateur',
passwordPlaceholder: 'Mot de passe',
credentialsRequired: 'Veuillez entrer le nom d\'utilisateur et le mot de passe',
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
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',
passwordChanged: 'Mot de passe change avec succes',
passwordRemoved: 'Login par mot de passe supprime',
setupPassword: 'Configurer le login par mot de passe',
changePassword: 'Changer le mot de passe',
changeUsername: 'Changer le nom d\'utilisateur',
removePasswordLogin: 'Supprimer',
username: 'Nom d\'utilisateur',
currentPassword: 'Mot de passe actuel',
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
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.',
passwordLoginNotConfigured: 'Login par mot de passe non configure',
passwordLoginConfigured: 'Login par mot de passe active ({username})',
},
// Common
@@ -57,6 +83,9 @@ export default {
updating: 'Mise a jour...',
updateSuccess: 'Mise a jour terminee, veuillez redemarrer le serveur',
updateFailed: 'Echec de la mise a jour',
logout: 'Deconnexion',
changelog: 'Journal des modifications',
noChangelog: 'Aucun journal disponible',
},
// Chat
@@ -291,6 +320,7 @@ export default {
saveFailed: 'Echec de l\'enregistrement',
tabs: {
display: 'Affichage',
account: 'Compte',
agent: 'Agent',
memory: 'Memoire',
session: 'Session',
@@ -470,4 +500,18 @@ export default {
cost: 'Cout',
noData: 'Aucune donnee d\'utilisation',
},
// Journal des modifications
changelog: {
new_0_4_3_1: 'Ajouter la connexion par nom d\'utilisateur/mot de passe en plus du token',
new_0_4_3_2: 'Ajouter les parametres de compte pour gerer les identifiants',
new_0_4_3_3: 'Ajouter le bouton de deconnexion dans la barre laterale',
new_0_4_3_4: 'Ajouter un popup de journal des modifications en cliquant sur le numero de version',
new_0_4_2_1: 'Ajouter le suivi de l\'utilisation des tokens et la longueur de contexte dynamique',
new_0_4_2_2: 'Ajouter la modal de recherche de sessions',
new_0_4_2_3: 'Restaurer le systeme de chat de groupe avec Socket.IO et SQLite',
new_0_4_2_4: 'Ajouter les sessions epinglees et le moniteur en direct',
new_0_4_2_5: 'Corriger la detection des fournisseurs integres et l\'appariement des modeles',
new_0_4_1_1: 'Corriger le contournement d\'authentification et le service de fichiers SPA',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'アクセストークンを入力してください',
invalidToken: '無効なトークンです',
connectionFailed: 'サーバーに接続できません',
passwordLogin: 'パスワード',
tokenLogin: 'トークン',
usernamePlaceholder: 'ユーザー名',
passwordPlaceholder: 'パスワード',
credentialsRequired: 'ユーザー名とパスワードを入力してください',
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
passwordMismatch: 'パスワードが一致しません',
passwordTooShort: 'パスワードは6文字以上必要です',
setupSuccess: 'パスワードログインが設定されました',
passwordChanged: 'パスワードが変更されました',
passwordRemoved: 'パスワードログインが削除されました',
setupPassword: 'パスワードログインを設定',
changePassword: 'パスワードを変更',
changeUsername: 'ユーザー名を変更',
removePasswordLogin: '削除',
username: 'ユーザー名',
currentPassword: '現在のパスワード',
newPassword: '新しいパスワード',
confirmPassword: 'パスワード確認',
newUsername: '新しいユーザー名',
usernameChanged: 'ユーザー名が変更されました',
usernameTooShort: 'ユーザー名は2文字以上必要です',
setupDescription: 'ユーザー名とパスワードを設定して、簡単にログインできるようにします。アクセストークンは引き続きバックアップとして使用できます。',
removeConfirm: 'パスワードログインを削除しますか?アクセストークンを使用してログインする必要があります。',
passwordLoginNotConfigured: 'パスワードログイン未設定',
passwordLoginConfigured: 'パスワードログイン有効({username}',
},
// 共通
@@ -57,6 +83,9 @@ export default {
updating: '更新中...',
updateSuccess: '更新が完了しました。サーバーを再起動してください',
updateFailed: '更新に失敗しました',
logout: 'ログアウト',
changelog: '更新履歴',
noChangelog: '更新履歴はありません',
},
// チャット
@@ -291,6 +320,7 @@ export default {
saveFailed: '保存に失敗しました',
tabs: {
display: '表示',
account: 'アカウント',
agent: 'エージェント',
memory: 'メモリ',
session: 'セッション',
@@ -470,4 +500,18 @@ export default {
cost: 'コスト',
noData: '使用データがありません',
},
// 更新履歴
changelog: {
new_0_4_3_1: 'トークン認証に加えてユーザー名/パスワードログインを追加',
new_0_4_3_2: '資格情報管理のためのアカウント設定を追加',
new_0_4_3_3: 'サイドバーにログアウトボタンを追加',
new_0_4_3_4: 'バージョン番号クリックで更新履歴ポップアップを追加',
new_0_4_2_1: 'トークン使用量追跡と動的コンテキスト長を追加',
new_0_4_2_2: 'セッション検索モーダルを追加',
new_0_4_2_3: 'Socket.IOとSQLiteによるグループチャットシステムを復元',
new_0_4_2_4: 'チャットページにピン留めセッションとライブモニターを追加',
new_0_4_2_5: '組み込みプロバイダー検出とモデルマッチングを修正',
new_0_4_1_1: '認証バイパスとSPAファイル配信を修正',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: '액세스 토큰을 입력해 주세요',
invalidToken: '유효하지 않은 토큰입니다',
connectionFailed: '서버에 연결할 수 없습니다',
passwordLogin: '비밀번호',
tokenLogin: '토큰',
usernamePlaceholder: '사용자 이름',
passwordPlaceholder: '비밀번호',
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
passwordMismatch: '비밀번호가 일치하지 않습니다',
passwordTooShort: '비밀번호는 6자 이상이어야 합니다',
setupSuccess: '비밀번호 로그인이 설정되었습니다',
passwordChanged: '비밀번호가 변경되었습니다',
passwordRemoved: '비밀번호 로그인이 제거되었습니다',
setupPassword: '비밀번호 로그인 설정',
changePassword: '비밀번호 변경',
changeUsername: '사용자 이름 변경',
removePasswordLogin: '제거',
username: '사용자 이름',
currentPassword: '현재 비밀번호',
newPassword: '새 비밀번호',
confirmPassword: '비밀번호 확인',
newUsername: '새 사용자 이름',
usernameChanged: '사용자 이름이 변경되었습니다',
usernameTooShort: '사용자 이름은 2자 이상이어야 합니다',
setupDescription: '사용자 이름과 비밀번호를 설정하여 편리하게 로그인하세요. 액세스 토큰은 백업으로 계속 사용할 수 있습니다.',
removeConfirm: '비밀번호 로그인을 제거하시겠습니까? 액세스 토큰을 사용하여 로그인해야 합니다.',
passwordLoginNotConfigured: '비밀번호 로그인 미설정',
passwordLoginConfigured: '비밀번호 로그인 활성화됨 ({username})',
},
// 공통
@@ -57,6 +83,9 @@ export default {
updating: '업데이트 중...',
updateSuccess: '업데이트 완료, 서버를 재시작해 주세요',
updateFailed: '업데이트 실패',
logout: '로그아웃',
changelog: '변경 이력',
noChangelog: '변경 이력이 없습니다',
},
// 채팅
@@ -291,6 +320,7 @@ export default {
saveFailed: '저장 실패',
tabs: {
display: '표시',
account: '계정',
agent: '에이전트',
memory: '메모리',
session: '세션',
@@ -470,4 +500,18 @@ export default {
cost: '비용',
noData: '사용량 데이터 없음',
},
// 변경 이력
changelog: {
new_0_4_3_1: '토큰 인증과 함께 사용자 이름/비밀번호 로그인 추가',
new_0_4_3_2: '자격 증명 관리를 위한 계정 설정 추가',
new_0_4_3_3: '사이드바에 로그아웃 버튼 추가',
new_0_4_3_4: '버전 번호 클릭으로 변경 이력 팝업 추가',
new_0_4_2_1: '토큰 사용량 추적 및 동적 컨텍스트 길이 추가',
new_0_4_2_2: '세션 검색 모달 추가',
new_0_4_2_3: 'Socket.IO 및 SQLite로 그룹 채팅 시스템 복원',
new_0_4_2_4: '채팅 페이지에 고정 세션 및 라이브 모니터 추가',
new_0_4_2_5: '내장 프로바이더 감지 및 모델 매칭 수정',
new_0_4_1_1: '인증 우회 및 SPA 파일 서비스 수정',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: 'Por favor, insira seu token de acesso',
invalidToken: 'Token invalido',
connectionFailed: 'Nao foi possivel conectar ao servidor',
passwordLogin: 'Senha',
tokenLogin: 'Token',
usernamePlaceholder: 'Nome de usuario',
passwordPlaceholder: 'Senha',
credentialsRequired: 'Por favor, insira nome de usuario e senha',
invalidCredentials: 'Nome de usuario ou senha incorretos',
passwordMismatch: 'As senhas nao conferem',
passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
setupSuccess: 'Login por senha configurado com sucesso',
passwordChanged: 'Senha alterada com sucesso',
passwordRemoved: 'Login por senha removido',
setupPassword: 'Configurar login por senha',
changePassword: 'Alterar senha',
changeUsername: 'Alterar nome de usuario',
removePasswordLogin: 'Remover',
username: 'Nome de usuario',
currentPassword: 'Senha atual',
newPassword: 'Nova senha',
confirmPassword: 'Confirmar senha',
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.',
passwordLoginNotConfigured: 'Login por senha nao configurado',
passwordLoginConfigured: 'Login por senha habilitado ({username})',
},
// Common
@@ -57,6 +83,9 @@ export default {
updating: 'Atualizando...',
updateSuccess: 'Atualizacao concluida, por favor reinicie o servidor',
updateFailed: 'Falha na atualizacao',
logout: 'Sair',
changelog: 'Registro de alteracoes',
noChangelog: 'Nenhum registro disponivel',
},
// Chat
@@ -291,6 +320,7 @@ export default {
saveFailed: 'Falha ao salvar',
tabs: {
display: 'Exibicao',
account: 'Conta',
agent: 'Agente',
memory: 'Memoria',
session: 'Sessao',
@@ -470,4 +500,18 @@ export default {
cost: 'Custo',
noData: 'Sem dados de uso',
},
// Registro de alteracoes
changelog: {
new_0_4_3_1: 'Adicionar login por usuario/senha junto com autenticacao por token',
new_0_4_3_2: 'Adicionar configuracoes de conta para gerenciar credenciais',
new_0_4_3_3: 'Adicionar botao de sair na barra lateral',
new_0_4_3_4: 'Adicionar popup de registro de alteracoes ao clicar no numero da versao',
new_0_4_2_1: 'Adicionar rastreamento de uso de tokens e comprimento de contexto dinamico',
new_0_4_2_2: 'Adicionar modal de busca de sessoes',
new_0_4_2_3: 'Restaurar sistema de chat em grupo com Socket.IO e SQLite',
new_0_4_2_4: 'Adicionar sessoes fixas e monitor ao vivo na pagina de chat',
new_0_4_2_5: 'Corrigir deteccao de provedores integrados e combinacao de modelos',
new_0_4_1_1: 'Corrigir bypass de autenticacao e servico de arquivos SPA',
},
}
+44
View File
@@ -8,6 +8,32 @@ export default {
tokenRequired: '请输入访问令牌',
invalidToken: '令牌无效',
connectionFailed: '无法连接到服务器',
passwordLogin: '密码登录',
tokenLogin: '令牌登录',
usernamePlaceholder: '用户名',
passwordPlaceholder: '密码',
credentialsRequired: '请输入用户名和密码',
invalidCredentials: '用户名或密码错误',
passwordMismatch: '两次密码不一致',
passwordTooShort: '密码长度至少 6 个字符',
setupSuccess: '密码登录配置成功',
passwordChanged: '密码修改成功',
passwordRemoved: '密码登录已移除',
setupPassword: '设置密码登录',
changePassword: '修改密码',
changeUsername: '修改用户名',
removePasswordLogin: '移除',
username: '用户名',
currentPassword: '当前密码',
newPassword: '新密码',
confirmPassword: '确认密码',
newUsername: '新用户名',
usernameChanged: '用户名修改成功',
usernameTooShort: '用户名至少 2 个字符',
setupDescription: '设置用户名和密码以便快速登录。访问令牌仍可继续使用。',
removeConfirm: '确定要移除密码登录吗?移除后需要使用访问令牌登录。',
passwordLoginNotConfigured: '密码登录未配置',
passwordLoginConfigured: '密码登录已启用({username}',
},
// 通用
@@ -69,6 +95,9 @@ export default {
updating: '正在更新...',
updateSuccess: '更新完成,请重启服务',
updateFailed: '更新失败',
logout: '退出登录',
changelog: '更新日志',
noChangelog: '暂无更新日志',
},
// 对话
@@ -316,6 +345,7 @@ export default {
saveFailed: '保存失败',
tabs: {
display: '显示',
account: '账户',
agent: '代理',
memory: '记忆',
session: '会话',
@@ -513,4 +543,18 @@ export default {
cost: '费用',
noData: '暂无用量数据',
},
// 更新日志
changelog: {
new_0_4_3_1: '新增用户名/密码登录,支持与令牌认证并存',
new_0_4_3_2: '新增账户设置:配置密码、修改密码、修改用户名',
new_0_4_3_3: '侧边栏新增退出登录按钮',
new_0_4_3_4: '点击版本号查看更新日志弹窗',
new_0_4_2_1: '新增 Token 用量追踪、上下文显示和动态上下文长度',
new_0_4_2_2: '新增会话搜索弹窗',
new_0_4_2_3: '恢复群聊系统(Socket.IO + SQLite 持久化)',
new_0_4_2_4: 'Chat 页面新增固定会话和实时监控',
new_0_4_2_5: '修复内置 Provider 检测和模型匹配问题',
new_0_4_1_1: '修复认证绕过、SPA 静态文件服务和 Provider 改进',
},
}
+122 -11
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from "vue";
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { setApiKey, hasApiKey } from "@/api/client";
import { fetchAuthStatus, loginWithPassword } from "@/api/auth";
const { t } = useI18n();
const router = useRouter();
@@ -11,14 +12,41 @@ const router = useRouter();
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");
}
onMounted(async () => {
try {
const status = await fetchAuthStatus();
hasPasswordLogin.value = status.hasPasswordLogin;
if (status.hasPasswordLogin && !urlToken) {
loginMethod.value = "password";
}
} catch {
// Fallback to token-only
}
});
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");
@@ -29,7 +57,6 @@ async function handleLogin() {
errorMsg.value = "";
try {
// Validate token by calling an auth-required endpoint
const res = await fetch("/api/sessions", {
headers: { Authorization: `Bearer ${key}` },
});
@@ -48,6 +75,26 @@ async function handleLogin() {
loading.value = false;
}
}
async function handlePasswordLogin() {
if (!username.value.trim() || !password.value) {
errorMsg.value = t("login.credentialsRequired");
return;
}
loading.value = true;
errorMsg.value = "";
try {
const sessionToken = await loginWithPassword(username.value.trim(), password.value);
setApiKey(sessionToken);
router.replace("/hermes/chat");
} catch (err: any) {
errorMsg.value = err.message || t("login.invalidCredentials");
} finally {
loading.value = false;
}
}
</script>
<template>
@@ -56,17 +103,53 @@ async function handleLogin() {
<div class="login-logo">
<img src="/logo.png" alt="Hermes" width="80" height="80" />
</div>
<h1 class="login-title">{{ t('login.title') }}</h1>
<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">
<input
v-model="token"
type="password"
class="login-input"
:placeholder="t('login.placeholder')"
autofocus
/>
<!-- 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>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button type="submit" class="login-btn" :disabled="loading">
{{ loading ? "..." : t("login.submit") }}
@@ -115,10 +198,38 @@ async function handleLogin() {
.login-desc {
font-size: 14px;
color: $text-muted;
margin: 0 0 40px;
margin: 0 0 32px;
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;
@@ -13,6 +13,7 @@ import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
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";
const settingsStore = useSettingsStore();
const { t } = useI18n();
@@ -35,6 +36,9 @@ onMounted(() => {
:description="t('common.loading')"
>
<NTabs type="line" animated>
<NTabPane name="account" :tab="t('settings.tabs.account')">
<AccountSettings />
</NTabPane>
<NTabPane name="display" :tab="t('settings.tabs.display')">
<DisplaySettings />
</NTabPane>
+152
View File
@@ -0,0 +1,152 @@
import type { Context } from 'koa'
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
import { getToken } from '../services/auth'
/**
* GET /api/auth/status
* Check if username/password login is configured (public).
*/
export async function authStatus(ctx: Context) {
const cred = await getCredentials()
ctx.body = {
hasPasswordLogin: !!cred,
username: cred?.username || null,
}
}
/**
* POST /api/auth/login
* Authenticate with username/password (public).
* Returns the static token on success.
*/
export async function login(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
}
const valid = await verifyCredentials(username, password)
if (!valid) {
ctx.status = 401
ctx.body = { error: 'Invalid username or password' }
return
}
const token = await getToken()
if (!token) {
ctx.status = 500
ctx.body = { error: 'Auth is disabled on this server' }
return
}
ctx.body = { token }
}
/**
* POST /api/auth/setup
* 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 }
}
/**
* POST /api/auth/change-password
* Change password (protected).
*/
export async function changePassword(ctx: Context) {
const { currentPassword, newPassword } = ctx.request.body as { currentPassword?: string; newPassword?: string }
if (!currentPassword || !newPassword) {
ctx.status = 400
ctx.body = { error: 'Current password and new password are required' }
return
}
if (newPassword.length < 6) {
ctx.status = 400
ctx.body = { error: 'New password must be at least 6 characters' }
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) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
await setCredentials(cred.username, newPassword)
ctx.body = { success: true }
}
/**
* POST /api/auth/change-username
* Change username (protected).
*/
export async function changeUsername(ctx: Context) {
const { currentPassword, newUsername } = ctx.request.body as { currentPassword?: string; newUsername?: string }
if (!currentPassword || !newUsername) {
ctx.status = 400
ctx.body = { error: 'Current password and new username are required' }
return
}
if (newUsername.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
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) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
// Update username, keep the same password
await setCredentials(newUsername, currentPassword)
ctx.body = { success: true }
}
/**
* DELETE /api/auth/password
* Remove username/password login (protected).
*/
export async function removePassword(ctx: Context) {
await deleteCredentials()
ctx.body = { success: true }
}
+14
View File
@@ -0,0 +1,14 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/auth'
// Public routes (no auth required)
export const authPublicRoutes = new Router()
authPublicRoutes.get('/api/auth/status', ctrl.authStatus)
authPublicRoutes.post('/api/auth/login', ctrl.login)
// Protected routes (auth required)
export const authProtectedRoutes = new Router()
authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword)
authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword)
authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername)
authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword)
+3
View File
@@ -5,6 +5,7 @@ import { healthRoutes } from './health'
import { webhookRoutes } from './webhook'
import { uploadRoutes } from './upload'
import { updateRoutes } from './update'
import { authPublicRoutes, authProtectedRoutes } from './auth'
// Hermes route modules
import { sessionRoutes } from './hermes/sessions'
@@ -29,11 +30,13 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
// --- Public routes (no auth required) ---
app.use(healthRoutes.routes())
app.use(webhookRoutes.routes())
app.use(authPublicRoutes.routes())
// --- Auth middleware: all routes below require authentication ---
app.use(requireAuth)
// --- Protected routes (auth required) ---
app.use(authProtectedRoutes.routes())
app.use(uploadRoutes.routes())
app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(sessionRoutes.routes())
@@ -0,0 +1,59 @@
import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { scryptSync, randomBytes } from 'node:crypto'
const APP_HOME = join(homedir(), '.hermes-web-ui')
const CREDENTIALS_FILE = join(APP_HOME, '.credentials')
export interface Credentials {
username: string
password_hash: string
salt: string
created_at: number
}
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
function hashPassword(password: string, salt: string): string {
return scryptSync(password, salt, 64, SCRYPT_OPTIONS).toString('hex')
}
export async function getCredentials(): Promise<Credentials | null> {
try {
const data = await readFile(CREDENTIALS_FILE, 'utf-8')
return JSON.parse(data)
} catch {
return null
}
}
export async function setCredentials(username: string, password: string): Promise<Credentials> {
const salt = randomBytes(16).toString('hex')
const password_hash = hashPassword(password, salt)
const cred: Credentials = { username, password_hash, salt, created_at: Date.now() }
await mkdir(APP_HOME, { recursive: true })
await writeFile(CREDENTIALS_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 })
return cred
}
export async function deleteCredentials(): Promise<void> {
try {
await unlink(CREDENTIALS_FILE)
} catch {
// File may not exist
}
}
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
const cred = await getCredentials()
if (!cred) return false
if (cred.username !== username) return false
const computed = hashPassword(password, cred.salt)
return computed === cred.password_hash
}
export function credentialsFileExists(): boolean {
return existsSync(CREDENTIALS_FILE)
}