diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index c0b12e3..5e44033 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import request, { setTokens, clearTokens, onTokenRefreshed } from '@/utils/request' +import request, { setTokens, clearTokens, onTokenRefreshed, setRememberMe, getRememberMe } from '@/utils/request' import { setAdminSession, clearAdminSession, ADMIN_ROLES } from '@/utils/permission' export interface AdminUser { @@ -35,6 +35,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const user = ref(null) const permissions = ref([]) const pendingAgreements = ref([]) + const rememberMe = ref(getRememberMe()) const isLoggedIn = computed(() => !!token.value) @@ -88,7 +89,11 @@ export const useAuthStore = defineStore('admin-auth', () => { persist() } - async function login(username: string, password: string, captcha?: { captchaKey: string; captchaX: number }): Promise { + async function login(username: string, password: string, captcha?: { captchaKey: string; captchaX: number }, remember: boolean = true): Promise { + // Save user's "remember me" preference + rememberMe.value = remember + setRememberMe(remember) + const { data } = await request.post('/admin/auth/login', { username, password, @@ -155,6 +160,7 @@ export const useAuthStore = defineStore('admin-auth', () => { user, permissions, pendingAgreements, + rememberMe, isLoggedIn, isSuperAdmin, login, diff --git a/web/admin/src/utils/request.ts b/web/admin/src/utils/request.ts index c6745b7..52d6919 100644 --- a/web/admin/src/utils/request.ts +++ b/web/admin/src/utils/request.ts @@ -24,36 +24,60 @@ declare module 'axios' { const ACCESS_TOKEN_KEY = 'admin_access_token' const REFRESH_TOKEN_KEY = 'admin_refresh_token' const EXPIRES_AT_KEY = 'admin_token_expires_at' +const REMEMBER_ME_KEY = 'admin_remember_me' const TOKEN_BUFFER_SECONDS = 60 +// Dynamic storage selection based on "remember me" preference +function getStorage(): Storage { + // Check user's "remember me" preference (defaults to true for backward compatibility) + const rememberMe = localStorage.getItem(REMEMBER_ME_KEY) + return rememberMe === 'false' ? sessionStorage : localStorage +} + +export function setRememberMe(remember: boolean): void { + // Store the preference itself in localStorage (so it persists across sessions) + localStorage.setItem(REMEMBER_ME_KEY, String(remember)) +} + +export function getRememberMe(): boolean { + const saved = localStorage.getItem(REMEMBER_ME_KEY) + return saved === null ? true : saved === 'true' +} + export function setTokens(tokens: TokenPair): void { - localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) - localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) - localStorage.setItem(EXPIRES_AT_KEY, String(tokens.expiresAt)) + const storage = getStorage() + storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) + storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + storage.setItem(EXPIRES_AT_KEY, String(tokens.expiresAt)) } export function clearTokens(): void { + // Clear from both storages to prevent leftover data localStorage.removeItem(ACCESS_TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(EXPIRES_AT_KEY) + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + sessionStorage.removeItem(EXPIRES_AT_KEY) } export function getAccessToken(): string | null { - return localStorage.getItem(ACCESS_TOKEN_KEY) + // Try localStorage first (persistent), then sessionStorage (temporary) + return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) } export function getRefreshToken(): string | null { - return localStorage.getItem(REFRESH_TOKEN_KEY) + return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) } export function getExpiresAt(): number | null { - const raw = localStorage.getItem(EXPIRES_AT_KEY) + const raw = localStorage.getItem(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY) return raw ? Number(raw) : null } export function shouldRefresh(): boolean { const expiresAt = getExpiresAt() - if (expiresAt === null) return true + if (expiresAt === null) return false // No token → no need to refresh return Date.now() / 1000 + TOKEN_BUFFER_SECONDS >= expiresAt } diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index 28162b7..8f2f05c 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -21,6 +21,7 @@ const { siteName } = useSiteName() const form = reactive({ username: '', password: '', + remember: true, }) // 2FA state @@ -107,7 +108,7 @@ async function handleLogin() { loading.value = true try { - const res = await authStore.login(form.username, form.password, { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }) + const res = await authStore.login(form.username, form.password, { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }, form.remember) // Check if 2FA is required if (res?.totp_required) { provisionalToken.value = res.provisional_token @@ -238,6 +239,12 @@ async function handle2FAVerify() {
+ + + + 记住登录状态(7天免登录) + + { const user = ref(null) const permissions = ref([]) const pendingAgreements = ref([]) + const rememberMe = ref(getRememberMe()) const isLoggedIn = computed(() => !!token.value) @@ -116,7 +117,11 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { persist() } - async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }): Promise { + async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }, remember: boolean = true): Promise { + // Save user's "remember me" preference + rememberMe.value = remember + setRememberMe(remember) + const { data } = await request.post('/tenant/auth/login', { account, password, @@ -208,6 +213,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { user, permissions, pendingAgreements, + rememberMe, isLoggedIn, isOwner, login, diff --git a/web/tenant/src/utils/request.ts b/web/tenant/src/utils/request.ts index 1a313cd..d56b413 100644 --- a/web/tenant/src/utils/request.ts +++ b/web/tenant/src/utils/request.ts @@ -24,36 +24,60 @@ declare module 'axios' { const ACCESS_TOKEN_KEY = 'tenant_access_token' const REFRESH_TOKEN_KEY = 'tenant_refresh_token' const EXPIRES_AT_KEY = 'tenant_token_expires_at' +const REMEMBER_ME_KEY = 'tenant_remember_me' const TOKEN_BUFFER_SECONDS = 60 +// Dynamic storage selection based on "remember me" preference +function getStorage(): Storage { + // Check user's "remember me" preference (defaults to true for backward compatibility) + const rememberMe = localStorage.getItem(REMEMBER_ME_KEY) + return rememberMe === 'false' ? sessionStorage : localStorage +} + +export function setRememberMe(remember: boolean): void { + // Store the preference itself in localStorage (so it persists across sessions) + localStorage.setItem(REMEMBER_ME_KEY, String(remember)) +} + +export function getRememberMe(): boolean { + const saved = localStorage.getItem(REMEMBER_ME_KEY) + return saved === null ? true : saved === 'true' +} + export function setTokens(tokens: TokenPair): void { - localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) - localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) - localStorage.setItem(EXPIRES_AT_KEY, String(tokens.expiresAt)) + const storage = getStorage() + storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) + storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + storage.setItem(EXPIRES_AT_KEY, String(tokens.expiresAt)) } export function clearTokens(): void { + // Clear from both storages to prevent leftover data localStorage.removeItem(ACCESS_TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(EXPIRES_AT_KEY) + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + sessionStorage.removeItem(EXPIRES_AT_KEY) } export function getAccessToken(): string | null { - return localStorage.getItem(ACCESS_TOKEN_KEY) + // Try localStorage first (persistent), then sessionStorage (temporary) + return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) } export function getRefreshToken(): string | null { - return localStorage.getItem(REFRESH_TOKEN_KEY) + return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) } export function getExpiresAt(): number | null { - const raw = localStorage.getItem(EXPIRES_AT_KEY) + const raw = localStorage.getItem(EXPIRES_AT_KEY) || sessionStorage.getItem(EXPIRES_AT_KEY) return raw ? Number(raw) : null } export function shouldRefresh(): boolean { const expiresAt = getExpiresAt() - if (expiresAt === null) return true + if (expiresAt === null) return false // No token → no need to refresh return Date.now() / 1000 + TOKEN_BUFFER_SECONDS >= expiresAt } diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index 501c554..bb97c2f 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -145,7 +145,7 @@ async function handleAdminLogin() { const captchaPayload = useTurnstile.value ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } - const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload) + const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload, adminForm.remember) if (res?.totp_required) { provisionalToken.value = res.provisional_token show2FA.value = true @@ -189,7 +189,7 @@ async function handleRamLogin() { const captchaPayload = useTurnstile.value ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } - const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload) + const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload, ramForm.remember) if (res?.totp_required) { provisionalToken.value = res.provisional_token show2FA.value = true @@ -375,7 +375,7 @@ async function handleOAuthLogin(provider: string) { type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/30 transition-colors cursor-pointer" /> - 记住登录 + 记住登录状态(7天免登录) - 记住登录 + 记住登录状态(7天免登录)