feat: add token auth, login page, skill toggle, and route restructure

- Add token-based authentication with auto-generated token stored in server/data/.token
- Add login page with URL token auto-fill support
- Add route guards requiring auth for all pages except login
- Restructure routes: / for login, /chat for conversations
- Add skill enable/disable toggle via config.yaml skills.disabled
- Unify logo to /logo.png across sidebar, login, messages, and empty state
- Hide sidebar on login page, prevent flash with router.isReady()
- Fix session export JSON parse error when CLI returns non-JSON output
- Display token in CLI on server start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-14 21:48:53 +08:00
parent be4624b8b4
commit 1f45254dd0
19 changed files with 464 additions and 12 deletions
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { setApiKey, hasApiKey } from "@/api/client";
const { t } = useI18n();
const router = useRouter();
// Read token saved by main.ts (before router strips URL params)
const urlToken = (window as any).__LOGIN_TOKEN__ || "";
const token = ref(urlToken);
const loading = ref(false);
const errorMsg = ref("");
// If already has a key, try to go to main page
if (hasApiKey()) {
router.replace("/chat");
}
async function handleLogin() {
const key = token.value.trim();
if (!key) {
errorMsg.value = t("login.tokenRequired");
return;
}
loading.value = true;
errorMsg.value = "";
try {
// Validate token by calling an auth-required endpoint
const res = await fetch("/api/sessions", {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 401) {
errorMsg.value = t("login.invalidToken");
loading.value = false;
return;
}
setApiKey(key);
router.replace("/chat");
} catch {
errorMsg.value = t("login.connectionFailed");
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-view">
<div class="login-card">
<div class="login-logo">
<img src="/logo.png" alt="Hermes" width="80" height="80" />
</div>
<h1 class="login-title">Hermes Web UI</h1>
<p class="login-desc">{{ t("login.description") }}</p>
<form class="login-form" @submit.prevent="handleLogin">
<input
v-model="token"
type="password"
class="login-input"
:placeholder="t('login.placeholder')"
autofocus
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button type="submit" class="login-btn" :disabled="loading">
{{ loading ? "..." : t("login.submit") }}
</button>
</form>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.login-view {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: $bg-primary;
}
.login-card {
width: 480px;
padding: 56px 56px;
border: 1px solid $border-color;
border-radius: $radius-lg;
background: $bg-card;
text-align: center;
}
.login-logo {
margin-bottom: 24px;
}
.login-title {
font-size: 26px;
font-weight: 600;
color: $text-primary;
margin: 0 0 10px;
}
.login-desc {
font-size: 14px;
color: $text-muted;
margin: 0 0 40px;
line-height: 1.6;
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-input {
width: 100%;
padding: 14px 16px;
border: 1px solid $border-color;
border-radius: $radius-sm;
font-size: 15px;
color: $text-primary;
background: $bg-input;
outline: none;
transition: border-color $transition-fast;
box-sizing: border-box;
font-family: $font-code;
&::placeholder {
color: $text-muted;
}
&:focus {
border-color: $accent-primary;
}
}
.login-error {
font-size: 13px;
color: $error;
text-align: left;
}
.login-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: $radius-sm;
background: $text-primary;
color: #fff;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity $transition-fast;
&:hover {
opacity: 0.85;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>