diff --git a/app/components/AdminLoginGate.vue b/app/components/AdminLoginGate.vue index b6f2ff2..67953cc 100644 --- a/app/components/AdminLoginGate.vue +++ b/app/components/AdminLoginGate.vue @@ -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({ diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue index 849440f..5b39669 100644 --- a/app/components/AppShell.vue +++ b/app/components/AppShell.vue @@ -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(() => [ + { + label: t('profile.title'), + icon: 'i-lucide-user-round', + to: '/account/profile' + }, { label: t('nav.changePassword'), icon: 'i-lucide-key-round', diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 0f490f3..1d6d529 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -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 } diff --git a/app/pages/account/profile.vue b/app/pages/account/profile.vue new file mode 100644 index 0000000..a715b5c --- /dev/null +++ b/app/pages/account/profile.vue @@ -0,0 +1,93 @@ + + + diff --git a/app/pages/reset-password.vue b/app/pages/reset-password.vue new file mode 100644 index 0000000..6a98eb9 --- /dev/null +++ b/app/pages/reset-password.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/pages/users.vue b/app/pages/users.vue new file mode 100644 index 0000000..188c7cd --- /dev/null +++ b/app/pages/users.vue @@ -0,0 +1,162 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 55beb1f..f0681f8 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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" + } } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 2636752..6589439 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -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": "保存失败" + } } diff --git a/server/api/admin/users/[id]/clear-password.post.ts b/server/api/admin/users/[id]/clear-password.post.ts new file mode 100644 index 0000000..4d78ad9 --- /dev/null +++ b/server/api/admin/users/[id]/clear-password.post.ts @@ -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 } +}) diff --git a/server/api/admin/users/index.get.ts b/server/api/admin/users/index.get.ts new file mode 100644 index 0000000..4f432b0 --- /dev/null +++ b/server/api/admin/users/index.get.ts @@ -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() } +}) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 648add2..82d0e6f 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -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) diff --git a/server/api/auth/reset-password.post.ts b/server/api/auth/reset-password.post.ts new file mode 100644 index 0000000..6680f9d --- /dev/null +++ b/server/api/auth/reset-password.post.ts @@ -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(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 } +}) diff --git a/server/api/user/profile.patch.ts b/server/api/user/profile.patch.ts new file mode 100644 index 0000000..e4b4259 --- /dev/null +++ b/server/api/user/profile.patch.ts @@ -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 } } +}) diff --git a/server/utils/db.ts b/server/utils/db.ts index 445a90e..9487ce7 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -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