feat(auth): 内置账号密码登录,Docker 部署可通过环境变量覆盖
- 后端读取 VCM_USERNAME/VCM_PASSWORD(默认 admin/admin123),新增 /api/login、/api/logout,Bearer token 中间件保护全部 /api/*(登录/登出除外) - 前端登录接真实接口,token 存 localStorage(记住我)/ sessionStorage;401 自动清除会话跳登录页 - 修复 App.vue 初始路由未就绪导致登录页误触发数据加载;退出登录先清会话再跳转 - docker-compose 增加 VCM_USERNAME/VCM_PASSWORD 环境变量(可用宿主机 .env 覆盖)
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppTopbar from './components/AppTopbar.vue'
|
||||
import MobileTabbar from './components/MobileTabbar.vue'
|
||||
import SettingDrawer from './components/SettingDrawer.vue'
|
||||
import { useStore } from './store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { state, refreshAll, scanNow } = useStore()
|
||||
const settingsOpen = ref(false)
|
||||
const isLogin = () => route.path === '/login'
|
||||
@@ -22,9 +23,11 @@ function onFocusOut() {
|
||||
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
|
||||
}, 120)
|
||||
}
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('focusout', onFocusOut)
|
||||
// 等待初始路由导航完成后再判断页面,避免登录页上误触发数据加载(此时 route.path 仍是 /)
|
||||
await router.isReady()
|
||||
if (!isLogin()) refreshAll()
|
||||
})
|
||||
|
||||
@@ -48,10 +51,6 @@ const fmtScanBytes = computed(() => {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!isLogin()) refreshAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+71
-13
@@ -7,6 +7,31 @@ import * as mock from '../mock/data.js'
|
||||
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
|
||||
// —— 登录会话:token 存 localStorage(记住我)或 sessionStorage(关浏览器失效) ——
|
||||
const TOKEN_KEY = 'vcm-token'
|
||||
const USER_KEY = 'vcm-user'
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
/** 统一请求封装:自动附带 Bearer token;401 时清除会话并跳转登录页 */
|
||||
async function authFetch(url, options = {}) {
|
||||
const token = getToken()
|
||||
const r = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...(options.headers || {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
})
|
||||
if (r.status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
if (!location.pathname.startsWith('/login')) location.href = '/login'
|
||||
throw new Error('未登录或会话已失效')
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// —— mock 内存态(模拟服务端持久化) ——
|
||||
const mockState = reactive({
|
||||
versionThreshold: 15,
|
||||
@@ -43,12 +68,45 @@ function computeStatus(p, threshold) {
|
||||
}
|
||||
|
||||
const api = {
|
||||
/** 登录:校验后端内置账号(Docker 部署由 VCM_USERNAME/VCM_PASSWORD 决定);remember 决定 token 存 localStorage 还是 sessionStorage */
|
||||
async login(username, password, remember = true) {
|
||||
if (USE_MOCK) {
|
||||
await delay(400)
|
||||
if (username !== 'admin' || password !== 'admin123') return { ok: false, message: '用户名或密码错误' }
|
||||
const token = `mock-${Date.now()}`
|
||||
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ name: username, email: `${username}@verdaccio.local` }))
|
||||
return { ok: true, user: { name: username } }
|
||||
}
|
||||
// 登录接口公开:错误密码的 401 是业务响应,不能用 authFetch(会误判为会话失效并抛异常)
|
||||
const r = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok || !data.ok) return { ok: false, message: data.message || '登录失败' }
|
||||
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, data.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ name: data.user?.name || username }))
|
||||
return { ok: true, user: data.user }
|
||||
},
|
||||
|
||||
/** 退出登录:通知后端销毁会话并清除本地 token */
|
||||
async logout() {
|
||||
try {
|
||||
if (!USE_MOCK) await authFetch('/api/logout', { method: 'POST' })
|
||||
} catch { /* 忽略网络错误,本地清除即可 */ }
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
|
||||
async getSummary() {
|
||||
if (USE_MOCK) {
|
||||
await delay()
|
||||
return mock.summary()
|
||||
}
|
||||
const r = await fetch('/api/packages')
|
||||
const r = await authFetch('/api/packages')
|
||||
const { packages } = await r.json()
|
||||
const valid = packages.filter(p => p.status !== 'broken')
|
||||
const warnCount = valid.filter(p => p.status === 'warn').length
|
||||
@@ -69,7 +127,7 @@ const api = {
|
||||
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
|
||||
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
|
||||
}
|
||||
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
|
||||
const r = await authFetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
|
||||
const data = await r.json()
|
||||
return {
|
||||
packages: data.packages || [],
|
||||
@@ -88,7 +146,7 @@ const api = {
|
||||
const p = clone(mockState.packages).find(x => x.name === name) || null
|
||||
return { ok: !!p, pkg: p }
|
||||
}
|
||||
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`)
|
||||
const r = await authFetch(`/api/packages/${encodeURIComponent(name)}`)
|
||||
if (!r.ok) return { ok: false, pkg: null }
|
||||
return r.json()
|
||||
},
|
||||
@@ -99,7 +157,7 @@ const api = {
|
||||
await delay(300)
|
||||
return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
|
||||
}
|
||||
const r = await fetch('/api/scan', { method: 'POST' })
|
||||
const r = await authFetch('/api/scan', { method: 'POST' })
|
||||
return r.json()
|
||||
},
|
||||
|
||||
@@ -108,7 +166,7 @@ const api = {
|
||||
if (USE_MOCK) {
|
||||
return { scanning: false, done: true, packages: 33, bytes: 0 }
|
||||
}
|
||||
const r = await fetch('/api/scan/progress')
|
||||
const r = await authFetch('/api/scan/progress')
|
||||
return r.json()
|
||||
},
|
||||
|
||||
@@ -121,7 +179,7 @@ const api = {
|
||||
exceptions: clone(mockState.exceptions),
|
||||
}
|
||||
}
|
||||
const r = await fetch('/api/thresholds')
|
||||
const r = await authFetch('/api/thresholds')
|
||||
return r.json()
|
||||
},
|
||||
|
||||
@@ -133,7 +191,7 @@ const api = {
|
||||
mockState.exceptions = clone(payload.exceptions || [])
|
||||
return { ok: true }
|
||||
}
|
||||
const r = await fetch('/api/thresholds', {
|
||||
const r = await authFetch('/api/thresholds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -146,7 +204,7 @@ const api = {
|
||||
await delay(80)
|
||||
return [...mockState.ignored]
|
||||
}
|
||||
const r = await fetch('/api/ignored')
|
||||
const r = await authFetch('/api/ignored')
|
||||
return r.json()
|
||||
},
|
||||
|
||||
@@ -164,7 +222,7 @@ const api = {
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
const r = await fetch('/api/ignore', {
|
||||
const r = await authFetch('/api/ignore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pkgName }),
|
||||
@@ -189,7 +247,7 @@ const api = {
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
const r = await fetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
|
||||
const r = await authFetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
|
||||
return r.json()
|
||||
},
|
||||
|
||||
@@ -221,7 +279,7 @@ const api = {
|
||||
mockState.logs.unshift(log)
|
||||
return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions }
|
||||
}
|
||||
const r = await fetch('/api/clean', {
|
||||
const r = await authFetch('/api/clean', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pkgName, keep }),
|
||||
@@ -242,7 +300,7 @@ const api = {
|
||||
p.status = computeStatus(p, mockState.versionThreshold)
|
||||
return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` }
|
||||
}
|
||||
const r = await fetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
|
||||
const r = await authFetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return r.json()
|
||||
@@ -253,7 +311,7 @@ const api = {
|
||||
await delay(80)
|
||||
return clone(mockState.logs)
|
||||
}
|
||||
const r = await fetch('/api/logs')
|
||||
const r = await authFetch('/api/logs')
|
||||
return r.json()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { useStore } from '../store'
|
||||
import api from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -25,7 +26,7 @@ function go(path) {
|
||||
if (route.path !== path) router.push(path)
|
||||
}
|
||||
|
||||
function pick(action) {
|
||||
async function pick(action) {
|
||||
if (action === 'theme') {
|
||||
// 主题切换即时生效,保持菜单打开以便反复对比
|
||||
toggle()
|
||||
@@ -35,7 +36,7 @@ function pick(action) {
|
||||
if (action === 'rescan') emit('rescan')
|
||||
else if (action === 'thresholds') router.push('/thresholds')
|
||||
else if (action === 'logout') {
|
||||
localStorage.removeItem('vcm-user')
|
||||
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页(否则守卫会因 token 仍在而重定向回首页)
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import api from '../api'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -18,8 +19,8 @@ function goThresholds() {
|
||||
router.push('/thresholds')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('vcm-user')
|
||||
async function logout() {
|
||||
await api.logout() // 先销毁后端会话并清本地 token,再跳登录页
|
||||
close()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const logged = localStorage.getItem('vcm-user') !== null
|
||||
const logged = localStorage.getItem('vcm-token') !== null || sessionStorage.getItem('vcm-token') !== null
|
||||
if (!to.meta.public && !logged) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import api from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -12,22 +13,20 @@ const password = ref('')
|
||||
const remember = ref(true)
|
||||
const loading = ref(false)
|
||||
|
||||
function login() {
|
||||
async function login() {
|
||||
if (!username.value.trim() || !password.value) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// 演示账号:任意非空即可登录(真实环境对接后端鉴权)
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('vcm-user', JSON.stringify({
|
||||
name: username.value.trim(),
|
||||
email: `${username.value.trim()}@verdaccio.local`,
|
||||
}))
|
||||
loading.value = false
|
||||
ElMessage.success('欢迎回来 👋')
|
||||
router.push(route.query.redirect || '/')
|
||||
}, 500)
|
||||
const res = await api.login(username.value.trim(), password.value, remember.value)
|
||||
loading.value = false
|
||||
if (!res.ok) {
|
||||
ElMessage.error(res.message || '用户名或密码错误')
|
||||
return
|
||||
}
|
||||
ElMessage.success('欢迎回来 👋')
|
||||
router.push(route.query.redirect || '/')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -61,7 +60,7 @@ function login() {
|
||||
<button class="login-btn" :disabled="loading" @click="login">
|
||||
{{ loading ? '登录中…' : '🚀 登 录' }}
|
||||
</button>
|
||||
<div class="login-tip">演示环境:任意用户名 / 密码即可登录</div>
|
||||
<div class="login-tip">默认账号 admin · 密码 admin123(Docker 部署可通过环境变量修改)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user