Add user-scoped Hermes profile access
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user