- 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user