Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
const message = useMessage()
const { t } = useI18n()
let lastNoticeAt = 0
function onAuthNotice(event: Event) {
const detail = (event as CustomEvent<{ kind?: string }>).detail || {}
const now = Date.now()
if (now - lastNoticeAt < 1200) return
lastNoticeAt = now
if (detail.kind === 'forbidden') {
message.error(t('login.accessDenied'))
return
}
message.error(t('login.sessionExpired'))
}
onMounted(() => {
window.addEventListener('hermes-auth-notice', onAuthNotice)
})
onUnmounted(() => {
window.removeEventListener('hermes-auth-notice', onAuthNotice)
})
</script>
<template>
<span style="display: none" aria-hidden="true" />
</template>
@@ -37,9 +37,19 @@ async function toggleDetail() {
}
async function handleSwitch() {
dialog.warning({
title: t('profiles.switchTo'),
content: t('profiles.switchConfirm', { name: props.profile.name }),
positiveText: t('profiles.switchTo'),
negativeText: t('common.cancel'),
onPositiveClick: performHermesSwitch,
})
}
async function performHermesSwitch() {
switching.value = true
try {
const ok = await profilesStore.switchProfile(props.profile.name)
const ok = await profilesStore.switchHermesProfile(props.profile.name)
if (ok) {
message.success(t('profiles.switchSuccess', { name: props.profile.name }))
// Reload to refresh all profile-dependent data
@@ -2,22 +2,15 @@
import { ref, onMounted } from "vue";
import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
import { changePassword, changeUsername, fetchCurrentUser, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
import type { LockedIp } from "@/api/auth";
const { t } = useI18n();
const message = useMessage();
const hasPasswordLogin = ref(false);
const username = ref<string | null>(null);
const loading = ref(false);
// Setup form
const showSetupModal = ref(false);
const setupUsername = ref("");
const setupPasswordVal = ref("");
const setupPasswordConfirm = ref("");
// Change password form
const showChangePasswordModal = ref(false);
const currentPasswordForPwd = ref("");
@@ -31,38 +24,11 @@ const newUsernameVal = ref("");
onMounted(async () => {
try {
const status = await fetchAuthStatus();
hasPasswordLogin.value = status.hasPasswordLogin;
username.value = status.username;
const user = await fetchCurrentUser();
username.value = user.username;
} catch { /* ignore */ }
});
async function handleSetup() {
if (setupPasswordVal.value !== setupPasswordConfirm.value) {
message.error(t("login.passwordMismatch"));
return;
}
if (setupPasswordVal.value.length < 6) {
message.error(t("login.passwordTooShort"));
return;
}
loading.value = true;
try {
await setupPassword(setupUsername.value, setupPasswordVal.value);
hasPasswordLogin.value = true;
username.value = setupUsername.value;
showSetupModal.value = false;
setupUsername.value = "";
setupPasswordVal.value = "";
setupPasswordConfirm.value = "";
message.success(t("login.setupSuccess"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
async function handleChangePassword() {
if (newPasswordVal.value !== newPasswordConfirm.value) {
message.error(t("login.passwordMismatch"));
@@ -107,27 +73,6 @@ async function handleChangeUsername() {
}
}
async function handleRemove() {
loading.value = true;
try {
await removePassword();
hasPasswordLogin.value = false;
username.value = null;
message.success(t("login.passwordRemoved"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
function openSetupModal() {
setupUsername.value = "";
setupPasswordVal.value = "";
setupPasswordConfirm.value = "";
showSetupModal.value = true;
}
function openChangePasswordModal() {
currentPasswordForPwd.value = "";
newPasswordVal.value = "";
@@ -187,25 +132,12 @@ onMounted(() => { loadLockedIps(); });
<div class="account-settings">
<p class="section-desc">{{ t("login.setupDescription") }}</p>
<!-- Not configured -->
<div v-if="!hasPasswordLogin" class="action-row">
<span class="action-label">{{ t("login.passwordLoginNotConfigured") }}</span>
<NButton type="primary" @click="openSetupModal">{{ t("login.setupPassword") }}</NButton>
</div>
<!-- Configured -->
<div v-else class="configured-section">
<div class="configured-section">
<div class="action-row">
<span class="action-label">{{ t("login.passwordLoginConfigured", { username }) }}</span>
<div class="action-buttons">
<NButton @click="openChangePasswordModal">{{ t("login.changePassword") }}</NButton>
<NButton @click="openChangeUsernameModal">{{ t("login.changeUsername") }}</NButton>
<NPopconfirm @positive-click="handleRemove">
<template #trigger>
<NButton type="error" ghost :loading="loading">{{ t("login.removePasswordLogin") }}</NButton>
</template>
{{ t("login.removeConfirm") }}
</NPopconfirm>
</div>
</div>
</div>
@@ -238,25 +170,6 @@ onMounted(() => { loadLockedIps(); });
<p v-else class="empty-hint">{{ t("settings.lockedIps.empty") }}</p>
</div>
<!-- Setup modal -->
<NModal v-model:show="showSetupModal" preset="dialog" :title="t('login.setupPassword')">
<NForm label-placement="top">
<NFormItem :label="t('login.username')">
<NInput v-model:value="setupUsername" :placeholder="t('login.usernamePlaceholder')" />
</NFormItem>
<NFormItem :label="t('login.newPassword')">
<NInput v-model:value="setupPasswordVal" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('login.confirmPassword')">
<NInput v-model:value="setupPasswordConfirm" type="password" show-password-on="click" :placeholder="t('login.confirmPassword')" @keyup.enter="handleSetup" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showSetupModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSetup">{{ t("common.save") }}</NButton>
</template>
</NModal>
<!-- Change password modal -->
<NModal v-model:show="showChangePasswordModal" preset="dialog" :title="t('login.changePassword')">
<NForm label-placement="top">
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSelect, NSpace, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import {
createManagedUser,
deleteManagedUser,
fetchManagedUsers,
updateManagedUser,
type ManagedUser,
type UserRole,
type UserStatus,
} from '@/api/auth'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const users = ref<ManagedUser[]>([])
const profiles = ref<string[]>([])
const showModal = ref(false)
const editingUser = ref<ManagedUser | null>(null)
const form = reactive({
username: '',
password: '',
role: 'admin' as UserRole,
status: 'active' as UserStatus,
profiles: [] as string[],
})
const roleOptions = computed(() => [
{ label: t('users.roles.admin'), value: 'admin' },
{ label: t('users.roles.superAdmin'), value: 'super_admin' },
])
const statusOptions = computed(() => [
{ label: t('users.status.active'), value: 'active' },
{ label: t('users.status.disabled'), value: 'disabled' },
])
const profileOptions = computed(() => profiles.value.map(profile => ({ label: profile, value: profile })))
function resetForm() {
editingUser.value = null
form.username = ''
form.password = ''
form.role = 'admin'
form.status = 'active'
form.profiles = []
}
async function loadUsers() {
loading.value = true
try {
const res = await fetchManagedUsers()
users.value = res.users
profiles.value = res.profiles
} catch (err: any) {
message.error(err.message || t('users.loadFailed'))
} finally {
loading.value = false
}
}
function openCreate() {
resetForm()
showModal.value = true
}
function openEdit(user: ManagedUser) {
editingUser.value = user
form.username = user.username
form.password = ''
form.role = user.role
form.status = user.status
form.profiles = [...user.profiles]
showModal.value = true
}
async function submit() {
if (form.username.trim().length < 2) {
message.error(t('login.usernameTooShort'))
return
}
if (!editingUser.value && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
if (form.password && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
saving.value = true
try {
const payload = {
username: form.username.trim(),
password: form.password || undefined,
role: form.role,
status: form.status,
profiles: form.role === 'super_admin' ? [] : form.profiles,
defaultProfile: form.profiles[0] || null,
}
const res = editingUser.value
? await updateManagedUser(editingUser.value.id, payload)
: await createManagedUser({ ...payload, password: form.password })
users.value = res.users
profiles.value = res.profiles
showModal.value = false
resetForm()
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function setStatus(user: ManagedUser, status: UserStatus) {
saving.value = true
try {
const res = await updateManagedUser(user.id, { status })
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function removeUser(user: ManagedUser) {
saving.value = true
try {
const res = await deleteManagedUser(user.id)
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.deleteFailed'))
} finally {
saving.value = false
}
}
function formatTime(value: number | null): string {
if (!value) return '-'
return new Date(value).toLocaleString()
}
const columns = computed<DataTableColumns<ManagedUser>>(() => [
{
title: t('users.username'),
key: 'username',
minWidth: 140,
},
{
title: t('users.role'),
key: 'role',
width: 130,
render: (row) => h(NTag, { size: 'small', type: row.role === 'super_admin' ? 'warning' : 'default' }, {
default: () => row.role === 'super_admin' ? t('users.roles.superAdmin') : t('users.roles.admin'),
}),
},
{
title: t('users.statusLabel'),
key: 'status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'error' }, {
default: () => row.status === 'active' ? t('users.status.active') : t('users.status.disabled'),
}),
},
{
title: t('users.profiles'),
key: 'profiles',
minWidth: 200,
render: (row) => row.role === 'super_admin'
? h('span', { class: 'muted' }, t('users.allProfiles'))
: h(NSpace, { size: 4 }, {
default: () => row.profiles.length
? row.profiles.map(profile => h(NTag, { size: 'small', bordered: false }, { default: () => profile }))
: h('span', { class: 'muted' }, t('users.noProfiles')),
}),
},
{
title: t('users.lastLogin'),
key: 'last_login_at',
minWidth: 170,
render: (row) => formatTime(row.last_login_at),
},
{
title: t('common.edit'),
key: 'actions',
width: 280,
render: (row) => h(NSpace, { size: 8 }, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('common.edit') }),
h(NButton, {
size: 'small',
type: row.status === 'active' ? 'warning' : 'primary',
ghost: true,
loading: saving.value,
onClick: () => setStatus(row, row.status === 'active' ? 'disabled' : 'active'),
}, { default: () => row.status === 'active' ? t('users.disable') : t('users.enable') }),
h(NPopconfirm, { onPositiveClick: () => removeUser(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true, loading: saving.value }, { default: () => t('common.delete') }),
default: () => t('users.deleteConfirm'),
}),
],
}),
},
])
onMounted(loadUsers)
</script>
<template>
<div class="user-management">
<div class="toolbar">
<div>
<h3 class="section-title">{{ t('users.title') }}</h3>
<p class="section-desc">{{ t('users.description') }}</p>
</div>
<NButton type="primary" @click="openCreate">{{ t('users.create') }}</NButton>
</div>
<NDataTable
:columns="columns"
:data="users"
:loading="loading"
:bordered="false"
:single-line="false"
size="small"
/>
<NModal v-model:show="showModal" preset="dialog" :title="editingUser ? t('users.edit') : t('users.create')">
<NForm label-placement="top">
<NFormItem :label="t('users.username')">
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" />
</NFormItem>
<NFormItem :label="editingUser ? t('users.newPasswordOptional') : t('login.newPassword')">
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('users.role')">
<NSelect v-model:value="form.role" :options="roleOptions" />
</NFormItem>
<NFormItem :label="t('users.statusLabel')">
<NSelect v-model:value="form.status" :options="statusOptions" />
</NFormItem>
<NFormItem v-if="form.role !== 'super_admin'" :label="t('users.profiles')">
<NSelect
v-model:value="form.profiles"
multiple
filterable
:options="profileOptions"
:placeholder="t('users.profilesPlaceholder')"
/>
</NFormItem>
</NForm>
<template #action>
<NButton @click="showModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="submit">{{ t('common.save') }}</NButton>
</template>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.user-management {
padding: 8px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.section-desc {
margin: 0;
font-size: 13px;
color: $text-muted;
}
:deep(.muted) {
color: $text-muted;
}
</style>
@@ -10,6 +10,7 @@ import LanguageSwitch from "./LanguageSwitch.vue";
import ThemeSwitch from "./ThemeSwitch.vue";
import { useSessionSearch } from '@/composables/useSessionSearch'
import { changelog } from "@/data/changelog";
import { isStoredSuperAdmin } from "@/api/client";
const { t } = useI18n();
const message = useMessage();
@@ -18,6 +19,7 @@ const router = useRouter();
const appStore = useAppStore();
const { openSessionSearch } = useSessionSearch();
const selectedKey = computed(() => route.name as string);
const isSuperAdmin = computed(() => isStoredSuperAdmin());
const logoPath = '/logo.png';
const collapsedGroups = reactive<Record<string, boolean>>({});
@@ -226,7 +228,7 @@ function openChangelog() {
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.performance' }" @click="handleNav('hermes.performance')">
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.performance' }" @click="handleNav('hermes.performance')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
@@ -251,7 +253,7 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('system')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<button v-if="isSuperAdmin" class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />