mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-27 06:20:15 +00:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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ファイル配信を修正',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 파일 서비스 수정',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 改进',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user