前端接入登录鉴权与会话状态
This commit is contained in:
11 files changed
+324
-59
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import { request } from './http'
|
||||
|
||||
export interface AuthUserGroup {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
is_system: boolean
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
status: number
|
||||
groups: AuthUserGroup[]
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
expires_at: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload): Promise<AuthUser> {
|
||||
return request<AuthUser>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -8,17 +8,38 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
let authToken: string | null = null
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (init?.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (authToken) {
|
||||
headers.set('Authorization', `Bearer ${authToken}`)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
response = await fetch(`/api${path}`, { ...init, headers })
|
||||
} catch {
|
||||
throw new ApiError(0, 'network error')
|
||||
}
|
||||
|
||||
const isAuthPath = path.startsWith('/auth/')
|
||||
if (response.status === 401 && !isAuthPath) {
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
try {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { request } from './http'
|
||||
|
||||
export interface RegisterPayload {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export function registerUser(payload: RegisterPayload): Promise<User> {
|
||||
return request<User>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const menuOpen = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
menuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onDocumentClick))
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
menuOpen.value = false
|
||||
if (route.name !== 'home') {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,21 +72,55 @@ const keyword = ref('')
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'register' }"
|
||||
class="flex h-9 items-center rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
{{ t('header.register') }}
|
||||
</RouterLink>
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<div ref="menuRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('header.userMenu')"
|
||||
:aria-expanded="menuOpen"
|
||||
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-3 text-sm transition-colors hover:bg-page"
|
||||
@click="menuOpen = !menuOpen"
|
||||
>
|
||||
<img
|
||||
v-if="auth.user?.avatar"
|
||||
:src="auth.user.avatar"
|
||||
alt=""
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
<span v-else class="h-8 w-8 shrink-0 rounded-full bg-line" />
|
||||
<span class="max-w-28 truncate text-content-1">{{ auth.displayName }}</span>
|
||||
</button>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'login' }"
|
||||
class="flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
|
||||
>
|
||||
{{ t('header.login') }}
|
||||
</RouterLink>
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-36 overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
|
||||
@click="logout"
|
||||
>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="h-9 w-9 shrink-0 rounded-full bg-line" :aria-label="t('header.avatar')" />
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
:to="{ name: 'register' }"
|
||||
class="flex h-9 items-center rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
{{ t('header.register') }}
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'login' }"
|
||||
class="flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
|
||||
>
|
||||
{{ t('header.login') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,7 +9,8 @@ const enUS: MessageSchema = {
|
||||
search: 'Search',
|
||||
register: 'Sign up',
|
||||
login: 'Log in',
|
||||
avatar: 'Avatar placeholder',
|
||||
userMenu: 'User menu',
|
||||
logout: 'Log out',
|
||||
},
|
||||
home: {
|
||||
recommend: 'Recommended',
|
||||
@@ -87,6 +88,9 @@ const enUS: MessageSchema = {
|
||||
invalid: 'The submitted information is invalid, please check and retry',
|
||||
duplicate: 'Username or email already exists',
|
||||
network: 'Network error, please try again later',
|
||||
invalidCredentials: 'Incorrect account or password',
|
||||
accountDisabled: 'Account disabled',
|
||||
sessionExpired: 'Session expired, please log in again',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ const jaJP: MessageSchema = {
|
||||
search: '検索',
|
||||
register: '新規登録',
|
||||
login: 'ログイン',
|
||||
avatar: 'アバターのプレースホルダー',
|
||||
userMenu: 'ユーザーメニュー',
|
||||
logout: 'ログアウト',
|
||||
},
|
||||
home: {
|
||||
recommend: 'おすすめ',
|
||||
@@ -87,6 +88,9 @@ const jaJP: MessageSchema = {
|
||||
invalid: '入力内容が無効です。確認してもう一度お試しください',
|
||||
duplicate: 'ユーザー名またはメールアドレスは既に存在します',
|
||||
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
invalidCredentials: 'アカウントまたはパスワードが正しくありません',
|
||||
accountDisabled: 'アカウントが無効化されています',
|
||||
sessionExpired: 'ログインの有効期限が切れました。もう一度ログインしてください',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ const zhCN = {
|
||||
search: '搜索',
|
||||
register: '注册',
|
||||
login: '登录',
|
||||
avatar: '头像占位',
|
||||
userMenu: '用户菜单',
|
||||
logout: '退出登录',
|
||||
},
|
||||
home: {
|
||||
recommend: '推荐',
|
||||
@@ -85,6 +86,9 @@ const zhCN = {
|
||||
invalid: '提交的信息无效,请检查后重试',
|
||||
duplicate: '用户名或邮箱已存在',
|
||||
network: '网络异常,请稍后重试',
|
||||
invalidCredentials: '账号或密码错误',
|
||||
accountDisabled: '账号已被禁用',
|
||||
sessionExpired: '登录已过期,请重新登录',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+16
-1
@@ -6,11 +6,26 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import { setUnauthorizedHandler } from './api/http'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.restore()
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
const wasAuthenticated = auth.isAuthenticated
|
||||
auth.logout()
|
||||
const current = router.currentRoute.value
|
||||
if (wasAuthenticated && current.name !== 'login') {
|
||||
router.push({ name: 'login', query: { redirect: current.fullPath, expired: '1' } })
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,102 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
login as loginRequest,
|
||||
register as registerRequest,
|
||||
type AuthUser,
|
||||
type LoginResponse,
|
||||
type RegisterPayload,
|
||||
} from '@/api/auth'
|
||||
import { setAuthToken } from '@/api/http'
|
||||
|
||||
const TOKEN_KEY = 'rill-token'
|
||||
const USER_KEY = 'rill-user'
|
||||
const EXPIRES_KEY = 'rill-token-expires'
|
||||
|
||||
function readStoredUser(): AuthUser | null {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const user = ref<AuthUser | null>(readStoredUser())
|
||||
const expiresAt = ref<string | null>(localStorage.getItem(EXPIRES_KEY))
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!expiresAt.value) {
|
||||
return true
|
||||
}
|
||||
const time = Date.parse(expiresAt.value)
|
||||
return Number.isNaN(time) || time <= Date.now()
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value && user.value) && !isExpired.value)
|
||||
const displayName = computed(() => user.value?.nickname || user.value?.username || '')
|
||||
const isAdmin = computed(() =>
|
||||
Boolean(user.value?.groups?.some((group) => group.name === 'admin')),
|
||||
)
|
||||
|
||||
function clear() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
expiresAt.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(EXPIRES_KEY)
|
||||
setAuthToken(null)
|
||||
}
|
||||
|
||||
function applySession(data: LoginResponse) {
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
expiresAt.value = data.expires_at
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||
localStorage.setItem(EXPIRES_KEY, data.expires_at)
|
||||
setAuthToken(data.token)
|
||||
}
|
||||
|
||||
async function login(account: string, password: string): Promise<AuthUser> {
|
||||
const data = await loginRequest({ account, password })
|
||||
applySession(data)
|
||||
return data.user
|
||||
}
|
||||
|
||||
async function register(payload: RegisterPayload): Promise<AuthUser> {
|
||||
return registerRequest(payload)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clear()
|
||||
}
|
||||
|
||||
function restore(): boolean {
|
||||
if (token.value && user.value && !isExpired.value) {
|
||||
setAuthToken(token.value)
|
||||
return true
|
||||
}
|
||||
clear()
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
expiresAt,
|
||||
isAuthenticated,
|
||||
displayName,
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
restore,
|
||||
}
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/api/http'
|
||||
import AuthShell from '@/components/auth/AuthShell.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface LoginForm {
|
||||
account: string
|
||||
@@ -14,6 +16,8 @@ interface LoginForm {
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(
|
||||
@@ -24,7 +28,7 @@ const validationSchema = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const { defineField, handleSubmit, errors } = useForm<LoginForm>({
|
||||
const { defineField, handleSubmit, errors, isSubmitting } = useForm<LoginForm>({
|
||||
validationSchema,
|
||||
initialValues: {
|
||||
account: '',
|
||||
@@ -35,11 +39,27 @@ const { defineField, handleSubmit, errors } = useForm<LoginForm>({
|
||||
const [account, accountProps] = defineField('account')
|
||||
const [password, passwordProps] = defineField('password')
|
||||
|
||||
const registered = computed(() => route.query.registered === '1')
|
||||
const pendingNotice = ref(false)
|
||||
const sessionExpired = computed(() => route.query.expired === '1')
|
||||
const justRegistered = computed(() => route.query.registered === '1')
|
||||
const submitError = ref('')
|
||||
|
||||
const onSubmit = handleSubmit(() => {
|
||||
pendingNotice.value = true
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
submitError.value = ''
|
||||
try {
|
||||
await auth.login(values.account, values.password)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
|
||||
await router.push(redirect ?? { name: 'home' })
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status === 0) {
|
||||
submitError.value = t('auth.errors.network')
|
||||
} else if (error.status === 401) {
|
||||
submitError.value = t('auth.errors.invalidCredentials')
|
||||
} else if (error.status === 403) {
|
||||
submitError.value = t('auth.errors.accountDisabled')
|
||||
} else {
|
||||
submitError.value = t('auth.errors.network')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -49,7 +69,14 @@ const onSubmit = handleSubmit(() => {
|
||||
<p class="mt-1 text-sm text-content-3">{{ t('auth.loginSubtitle') }}</p>
|
||||
|
||||
<p
|
||||
v-if="registered"
|
||||
v-if="sessionExpired"
|
||||
class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary"
|
||||
>
|
||||
{{ t('auth.errors.sessionExpired') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="justRegistered"
|
||||
class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary"
|
||||
>
|
||||
{{ t('auth.registerSuccess') }}
|
||||
@@ -94,15 +121,19 @@ const onSubmit = handleSubmit(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="pendingNotice" class="rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
{{ t('auth.loginPending') }}
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
|
||||
:disabled="isSubmitting"
|
||||
class="h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ t('auth.login') }}
|
||||
{{ isSubmitting ? t('common.submitting') : t('auth.login') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { registerUser } from '@/api/users'
|
||||
import AuthShell from '@/components/auth/AuthShell.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface RegisterForm {
|
||||
username: string
|
||||
@@ -18,6 +18,7 @@ interface RegisterForm {
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(
|
||||
@@ -67,7 +68,7 @@ const submitError = ref('')
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
submitError.value = ''
|
||||
try {
|
||||
await registerUser({
|
||||
await auth.register({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
|
||||
Reference in New Issue
Block a user