用户资料/重置密码/用户管理

This commit is contained in:
Yeomai committed 2026-10-18 14:29:49 +08:00
1 parent 5c1f640a2b
commit 813407de30
14 files changed
+591 -2

No files matched your search

+6
View File
@@ -70,6 +70,12 @@ async function submit() {
secret.value = ''
await resetVerification()
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else if (result.needsPasswordReset) {
await resetVerification()
await router.push({
path: '/reset-password',
query: { username: result.resetUsername ?? username.value.trim() }
})
} else {
await resetVerification()
toast.add({
+6
View File
@@ -18,6 +18,7 @@ const navItems = computed(() => {
{ label: t('nav.fileLibrary'), to: '/file-library', icon: 'i-lucide-folder-open' }
]
if (isAdmin.value) {
items.push({ label: t('nav.users'), to: '/users', icon: 'i-lucide-users' })
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
}
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
@@ -47,6 +48,11 @@ function isHostActive(to: string) {
}
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{
label: t('profile.title'),
icon: 'i-lucide-user-round',
to: '/account/profile'
},
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
+16 -1
View File
@@ -143,11 +143,19 @@ export function useAuth() {
credentials: { username: string, password: string }
| { secret: string },
verification: LoginVerificationPayload
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
): Promise<{
ok: boolean
needsMigration?: boolean
needsPasswordReset?: boolean
resetUsername?: string
error?: string
}> {
try {
const result = await $fetch<{
success: boolean
needsMigration?: boolean
requiresPasswordReset?: boolean
username?: string
user?: AuthUser
}>('/api/auth/login', {
method: 'POST',
@@ -160,6 +168,13 @@ export function useAuth() {
if (result.needsMigration) {
return { ok: true, needsMigration: true }
}
if (result.requiresPasswordReset) {
return {
ok: false,
needsPasswordReset: true,
resetUsername: result.username
}
}
if (result.user) {
user.value = result.user
}
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
const { isChecking, isAuthenticated, user, checkSession, fetchStatus, handleAuthError } = useAuth()
const toast = useToast()
const { t } = useI18n()
const username = ref('')
const saving = ref(false)
watch(user, (value) => {
if (value) username.value = value.username
}, { immediate: true })
async function save() {
if (saving.value) return
saving.value = true
try {
const data = await $fetch<{ user: { username: string } }>('/api/user/profile', {
method: 'PATCH',
credentials: 'include',
body: { username: username.value.trim() }
})
await checkSession()
toast.add({ title: t('profile.saved'), color: 'success' })
username.value = data.user.username
} catch (error: unknown) {
handleAuthError(error)
const message = (error as { data?: { message?: string } })?.data?.message
toast.add({ title: message ?? t('profile.saveFailed'), color: 'error' })
} finally {
saving.value = false
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin text-muted"
/>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="mx-auto max-w-md space-y-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('profile.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('profile.subtitle') }}
</p>
</div>
<form
class="space-y-3 rounded-xl border border-default bg-elevated p-5"
@submit.prevent="save"
>
<label class="block text-sm font-medium">
{{ t('profile.username') }}
</label>
<UInput
v-model="username"
class="w-full"
:placeholder="t('profile.username')"
/>
<p class="text-xs text-muted">
{{ t('profile.usernameHint') }}
</p>
<UButton
type="submit"
:loading="saving"
:label="t('common.save')"
/>
</form>
</div>
</AppShell>
</div>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { t } = useI18n()
const { checkSession } = useAuth()
const username = ref(String(route.query.username ?? ''))
const password = ref('')
const confirm = ref('')
const loading = ref(false)
async function submit() {
if (loading.value) return
if (password.value.length < 6) {
toast.add({ title: t('reset.tooShort'), color: 'warning' })
return
}
if (password.value !== confirm.value) {
toast.add({ title: t('reset.mismatch'), color: 'warning' })
return
}
loading.value = true
try {
await $fetch('/api/auth/reset-password', {
method: 'POST',
credentials: 'include',
body: { username: username.value.trim(), password: password.value }
})
await checkSession()
toast.add({ title: t('reset.success'), color: 'success' })
await router.replace('/')
} catch (error: unknown) {
toast.add({
title: error instanceof Error ? error.message : t('reset.failed'),
color: 'error'
})
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-4">
<form
class="w-full max-w-sm space-y-4 rounded-xl border border-default bg-elevated p-6"
@submit.prevent="submit"
>
<div>
<h1 class="text-xl font-semibold tracking-tight">
{{ t('reset.title') }}
</h1>
<p class="mt-1 text-sm text-muted">
{{ t('reset.subtitle') }}
</p>
</div>
<UInput
v-model="username"
:placeholder="t('auth.username')"
class="w-full"
/>
<UInput
v-model="password"
type="password"
:placeholder="t('reset.newPassword')"
class="w-full"
/>
<UInput
v-model="confirm"
type="password"
:placeholder="t('reset.confirmPassword')"
class="w-full"
/>
<UButton
type="submit"
block
:loading="loading"
:label="t('reset.submit')"
/>
</form>
</div>
</template>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
interface AdminUser {
id: number
username: string
role: 'admin' | 'user'
created_at: string
password_reset: number
}
const { isChecking, isAuthenticated, isAdmin, checkSession, fetchStatus, handleAuthError } = useAuth()
const toast = useToast()
const { t } = useI18n()
const users = ref<AdminUser[]>([])
const loading = ref(false)
async function loadUsers() {
if (!isAdmin.value) return
loading.value = true
try {
const data = await $fetch<{ users: AdminUser[] }>('/api/admin/users', {
credentials: 'include'
})
users.value = data.users
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('users.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
async function clearPassword(user: AdminUser) {
try {
await $fetch(`/api/admin/users/${user.id}/clear-password`, {
method: 'POST',
credentials: 'include'
})
toast.add({ title: t('users.passwordCleared', { name: user.username }), color: 'success' })
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('users.clearFailed'), color: 'error' })
}
}
function formatDate(value: string) {
try {
return new Date(value).toLocaleString()
} catch {
return value
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && isAdmin.value) {
await loadUsers()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('users.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('users.subtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadUsers"
/>
</div>
<UAlert
v-if="!isAdmin"
color="warning"
variant="subtle"
:title="t('users.adminOnly')"
/>
<ul
v-else
class="rounded-xl border border-default"
>
<li
v-for="user in users"
:key="user.id"
class="flex flex-wrap items-center gap-3 border-b border-default px-4 py-3 last:border-0"
>
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 text-sm font-medium text-highlighted">
<span class="truncate">{{ user.username }}</span>
<UBadge
:color="user.role === 'admin' ? 'primary' : 'neutral'"
variant="subtle"
size="xs"
>
{{ user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser') }}
</UBadge>
</p>
<p class="mt-0.5 text-xs text-muted">
{{ formatDate(user.created_at) }}
</p>
</div>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
v-else
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
</li>
</ul>
</AppShell>
</div>
</template>
+33
View File
@@ -31,6 +31,7 @@
"logs": "Activity log",
"settings": "Settings",
"storage": "Storage",
"users": "Users",
"menu": "Menu",
"language": "Language",
"appearance": "Appearance",
@@ -1262,4 +1263,36 @@
"loadFailed": "Failed to load settings",
"saveFailed": "Failed to save settings"
}
,
"users": {
"title": "Users",
"subtitle": "Review accounts and clear passwords; users set a new one on next login",
"adminOnly": "Admins only",
"roleAdmin": "Admin",
"roleUser": "User",
"clearPassword": "Clear password",
"resetPending": "Reset pending",
"passwordCleared": "Cleared password for {name}",
"clearFailed": "Operation failed",
"loadFailed": "Failed to load users"
},
"reset": {
"title": "Set a new password",
"subtitle": "An admin cleared your password. Set a new one to continue.",
"newPassword": "New password (min 6 characters)",
"confirmPassword": "Confirm new password",
"submit": "Set and sign in",
"tooShort": "Password must be at least 6 characters",
"mismatch": "Passwords do not match",
"success": "Password updated",
"failed": "Failed — make sure an admin has cleared your password"
},
"profile": {
"title": "Profile",
"subtitle": "Change your username",
"username": "Username",
"usernameHint": "2–32 letters, digits, underscore, dot or hyphen",
"saved": "Username updated",
"saveFailed": "Save failed"
}
}
+33
View File
@@ -31,6 +31,7 @@
"logs": "操作日志",
"settings": "设置",
"storage": "存储",
"users": "用户",
"menu": "菜单",
"language": "语言",
"appearance": "外观",
@@ -1265,4 +1266,36 @@
"loadFailed": "加载设置失败",
"saveFailed": "保存设置失败"
}
,
"users": {
"title": "用户管理",
"subtitle": "查看账号并清除密码,用户下次登录时重新设置",
"adminOnly": "仅管理员可访问此页面",
"roleAdmin": "管理员",
"roleUser": "普通用户",
"clearPassword": "清除密码",
"resetPending": "待重置密码",
"passwordCleared": "已清除 {name} 的密码",
"clearFailed": "操作失败",
"loadFailed": "读取用户列表失败"
},
"reset": {
"title": "设置新密码",
"subtitle": "管理员已清除你的密码,请设置一个新密码继续使用",
"newPassword": "新密码(至少 6 位)",
"confirmPassword": "确认新密码",
"submit": "设置并登录",
"tooShort": "密码至少 6 位",
"mismatch": "两次输入的密码不一致",
"success": "密码已更新",
"failed": "设置失败,请确认管理员已清除你的密码"
},
"profile": {
"title": "个人资料",
"subtitle": "修改你的用户名",
"username": "用户名",
"usernameHint": "2–32 位字母、数字、下划线、点或短横线",
"saved": "用户名已更新",
"saveFailed": "保存失败"
}
}
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../../../utils/access'
import { createApiError } from '../../../../utils/api-error'
import { clearUserPassword, findUserById } from '../../../../utils/db'
import { logActivity } from '../../../../utils/activity-log'
export default defineEventHandler(async (event) => {
const operator = await requireAdminAuth(event)
const id = Number(getRouterParam(event, 'id'))
const target = Number.isInteger(id) ? findUserById(id) : null
if (!target) {
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
}
if (target.id === operator.id) {
createApiError(event, 'INVALID_REQUEST', '不能清除自己的密码', 400)
}
clearUserPassword(target.id)
logActivity(event, {
action: 'settings',
key: `users/${target.id}/clear-password`,
originalName: target.username,
userId: operator.id,
source: 'admin'
})
return { success: true }
})
+7
View File
@@ -0,0 +1,7 @@
import { requireAdminAuth } from '../../../utils/access'
import { listAdminUsers } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
return { users: listAdminUsers() }
})
+7 -1
View File
@@ -9,7 +9,7 @@ import {
rowToAuthUser,
verifyPassword
} from '../../utils/auth'
import { findUserByUsername } from '../../utils/db'
import { findUserByUsername, isPasswordResetRequired } from '../../utils/db'
import { getAdminSecret } from '../../utils/env'
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
import { logActivity } from '../../utils/activity-log'
@@ -56,6 +56,12 @@ export default defineEventHandler(async (event) => {
}
const user = findUserByUsername(username)
// 管理员已清除密码:下次登录时直接进入设置新密码流程
if (user && isPasswordResetRequired(user.id)) {
return { success: false, requiresPasswordReset: true, username: user.username }
}
if (!user || !verifyPassword(password, user.password_hash)) {
logWarn('login failed: invalid credentials', { ip, username })
createApiError(event, 'UNAUTHORIZED', '用户名或密码错误', 401)
+34
View File
@@ -0,0 +1,34 @@
import { createApiError } from '../../utils/api-error'
import { createSession, hashPassword } from '../../utils/auth'
import {
findUserByUsername,
isPasswordResetRequired,
setUserPassword
} from '../../utils/db'
import { logInfo } from '../../utils/logger'
interface ResetBody {
username?: string
password?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<ResetBody>(event).catch((): ResetBody => ({}))
const username = body.username?.trim() ?? ''
const password = body.password ?? ''
if (!username || password.length < 6) {
createApiError(event, 'INVALID_REQUEST', '请输入用户名和至少 6 位的新密码', 400)
}
const user = findUserByUsername(username)
if (!user || !isPasswordResetRequired(user.id)) {
createApiError(event, 'UNAUTHORIZED', '该账号无需重置密码', 401)
}
setUserPassword(user.id, await hashPassword(password))
await createSession(event, user.id)
logInfo('password reset completed', { username })
return { success: true }
})
+23
View File
@@ -0,0 +1,23 @@
import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { findUserByUsername, updateUsername } from '../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const body = await readBody<{ username?: unknown }>(event).catch(
(): { username?: unknown } => ({})
)
const username = typeof body.username === 'string' ? body.username.trim() : ''
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
createApiError(event, 'INVALID_REQUEST', '用户名需为 2–32 位字母、数字、下划线、点或短横线', 400)
}
const existing = findUserByUsername(username)
if (existing && existing.id !== user.id) {
createApiError(event, 'CONFLICT', '用户名已被占用', 409)
}
updateUsername(user.id, username)
return { success: true, user: { ...user, username } }
})
+56
View File
@@ -439,6 +439,62 @@ export function updateUserPassword(userId: number, passwordHash: string): void {
`).run(passwordHash, userId)
}
/** 懒迁移:users 表新增「需重置密码」标记 */
function ensurePasswordResetColumn(): void {
const columns = getDb().prepare('PRAGMA table_info(users)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'password_reset')) {
getDb().exec(`
ALTER TABLE users ADD COLUMN password_reset INTEGER NOT NULL DEFAULT 0
`)
}
}
export interface AdminUserRow {
id: number
username: string
role: UserRole
created_at: string
password_reset: number
}
/** 管理员用户列表 */
export function listAdminUsers(): AdminUserRow[] {
ensurePasswordResetColumn()
return getDb().prepare(`
SELECT id, username, role, created_at, password_reset
FROM users ORDER BY id ASC
`).all() as unknown as AdminUserRow[]
}
/** 清空密码并标记需要重置 */
export function clearUserPassword(userId: number): void {
ensurePasswordResetColumn()
getDb().prepare(`
UPDATE users SET password_hash = '', password_reset = 1 WHERE id = ?
`).run(userId)
}
/** 设置新密码并解除重置标记 */
export function setUserPassword(userId: number, passwordHash: string): void {
ensurePasswordResetColumn()
getDb().prepare(`
UPDATE users SET password_hash = ?, password_reset = 0 WHERE id = ?
`).run(passwordHash, userId)
}
export function updateUsername(userId: number, username: string): void {
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username, userId)
}
export function isPasswordResetRequired(userId: number): boolean {
ensurePasswordResetColumn()
const row = getDb().prepare('SELECT password_reset FROM users WHERE id = ?')
.get(userId) as { password_reset?: number } | undefined
return Number(row?.password_reset ?? 0) === 1
}
export function ensureUserApiToken(userId: number): string {
const existing = getUserApiToken(userId)
if (existing) return existing