Browse Source

feat(auth): implement client session synchronization and refactor authentication handling

- Added `ensureClientMeSynced` function to synchronize client session with server, improving user experience by reducing unnecessary API calls.
- Updated `AppShell`, `public.vue`, and other components to utilize the new synchronization method instead of the previous refresh mechanism.
- Introduced a new API endpoint for session handling, ensuring consistent user state across client and server.
- Refactored `useAuthSession` to manage client session state more effectively, enhancing overall authentication flow.

This update streamlines the authentication process and enhances the reliability of user session management.
main
npmrun 3 weeks ago
parent
commit
a7155fbaf2
  1. 4
      app/components/AppShell.vue
  2. 27
      app/composables/useAuthSession.ts
  3. 4
      app/layouts/public.vue
  4. 6
      app/pages/@[publicSlug]/posts/[postSlug].vue
  5. 4
      app/pages/me/media/index.vue
  6. 7
      app/pages/me/media/orphans.vue
  7. 3
      app/pages/me/posts/[id].vue
  8. 3
      app/pages/me/posts/index.vue
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 14
      server/api/auth/session.get.ts
  11. 2
      server/utils/auth-api-routes.ts

4
app/components/AppShell.vue

@ -10,14 +10,14 @@ withDefaults(
) )
const route = useRoute() const route = useRoute()
const { loggedIn, user, refresh, clear, initialized } = useAuthSession() const { loggedIn, user, clear, initialized, ensureClientMeSynced } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const { allowRegister, siteName } = useGlobalConfig() const { allowRegister, siteName } = useGlobalConfig()
const logoutLoading = ref(false) const logoutLoading = ref(false)
onMounted(() => { onMounted(() => {
refresh().catch(() => {}) ensureClientMeSynced().catch(() => {})
}) })
const displayName = computed(() => { const displayName = computed(() => {

27
app/composables/useAuthSession.ts

@ -13,6 +13,10 @@ type MeResult = {
user: AuthUser; user: AuthUser;
}; };
type SessionResult = {
user: AuthUser | null;
};
export type AuthSessionState = { export type AuthSessionState = {
initialized: boolean; initialized: boolean;
pending: boolean; pending: boolean;
@ -21,6 +25,8 @@ export type AuthSessionState = {
}; };
export const AUTH_SESSION_STATE_KEY = "auth:session"; export const AUTH_SESSION_STATE_KEY = "auth:session";
/** 客户端是否已做过与 Cookie 的一次强制对齐(`ensureClientMeSynced`);登出等场景需重置 */
export const AUTH_CLIENT_ME_SYNCED_KEY = "auth:client-me-synced";
export const DEFAULT_AUTH_SESSION_STATE: AuthSessionState = { export const DEFAULT_AUTH_SESSION_STATE: AuthSessionState = {
initialized: false, initialized: false,
pending: false, pending: false,
@ -39,6 +45,7 @@ export function useAuthSession() {
const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({ const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({
...DEFAULT_AUTH_SESSION_STATE, ...DEFAULT_AUTH_SESSION_STATE,
})); }));
const clientMeSynced = useState<boolean>(AUTH_CLIENT_ME_SYNCED_KEY, () => false);
const applyUser = (user: AuthUser | null) => { const applyUser = (user: AuthUser | null) => {
state.value.user = user; state.value.user = user;
@ -47,6 +54,7 @@ export function useAuthSession() {
}; };
const clear = () => { const clear = () => {
clientMeSynced.value = false;
applyUser(null); applyUser(null);
}; };
@ -75,6 +83,24 @@ export function useAuthSession() {
} }
}; };
/**
* SPA `GET /api/auth/session`
* Cookie Cookie `/api/auth/me`
*/
const ensureClientMeSynced = async (): Promise<void> => {
if (import.meta.server || clientMeSynced.value) {
return;
}
try {
const payload = await request<ApiResponse<SessionResult>>("/api/auth/session");
const data = unwrapApiBody(payload);
applyUser(data.user);
clientMeSynced.value = true;
} catch {
/* 网络错误等:保留 SSR 状态,下次进入带壳页面可再试 */
}
};
return { return {
initialized: computed(() => state.value.initialized), initialized: computed(() => state.value.initialized),
loggedIn: computed(() => state.value.loggedIn), loggedIn: computed(() => state.value.loggedIn),
@ -82,5 +108,6 @@ export function useAuthSession() {
pending: computed(() => state.value.pending), pending: computed(() => state.value.pending),
refresh, refresh,
clear, clear,
ensureClientMeSynced,
}; };
} }

4
app/layouts/public.vue

@ -7,7 +7,7 @@ import {
import { unwrapApiBody, type ApiResponse } from '../utils/http/factory' import { unwrapApiBody, type ApiResponse } from '../utils/http/factory'
const route = useRoute() const route = useRoute()
const { loggedIn, user, refresh, clear, initialized } = useAuthSession() const { loggedIn, user, clear, initialized, ensureClientMeSynced } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const { siteName, allowRegister } = useGlobalConfig() const { siteName, allowRegister } = useGlobalConfig()
const logoutLoading = ref(false) const logoutLoading = ref(false)
@ -125,7 +125,7 @@ watch(publicLayoutMode, (m) => {
}) })
onMounted(() => { onMounted(() => {
refresh().catch(() => {}) ensureClientMeSynced().catch(() => {})
}) })
</script> </script>

6
app/pages/@[publicSlug]/posts/[postSlug].vue

@ -12,7 +12,7 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const publicSlug = computed(() => route.params.publicSlug as string) const publicSlug = computed(() => route.params.publicSlug as string)
const postSlug = computed(() => route.params.postSlug as string) const postSlug = computed(() => route.params.postSlug as string)
const { user, loggedIn, refresh: refreshAuth } = useAuthSession() const { user, loggedIn } = useAuthSession()
type Post = { type Post = {
id: number id: number
@ -63,10 +63,6 @@ usePageTitle(() => {
return [t, `@${ps}`] return [t, `@${ps}`]
}) })
onMounted(() => {
void refreshAuth(true)
})
/** 当前登录用户是否为该公开主页所有者(可编辑此文) */ /** 当前登录用户是否为该公开主页所有者(可编辑此文) */
const canEditPost = computed(() => { const canEditPost = computed(() => {
const slug = user.value?.publicSlug const slug = user.value?.publicSlug

4
app/pages/me/media/index.vue

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
usePageTitle('媒体库') usePageTitle('媒体库')
type RefContext = { type RefContext = {
@ -26,7 +24,6 @@ type MediaAssetRow = {
} }
const toast = useToast() const toast = useToast()
const { refresh: refreshAuth } = useAuthSession()
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const { fetchData, getApiErrorMessage } = useClientApi() const { fetchData, getApiErrorMessage } = useClientApi()
@ -228,7 +225,6 @@ watch([page, pageSize, appliedSearch], () => {
}) })
onMounted(() => { onMounted(() => {
void refreshAuth(true)
void load() void load()
}) })

7
app/pages/me/media/orphans.vue

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
usePageTitle('图片孤儿审查') usePageTitle('图片孤儿审查')
type OrphanItem = { type OrphanItem = {
@ -20,7 +18,6 @@ type Filter = 'all' | 'deletable' | 'cooling'
const toast = useToast() const toast = useToast()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const { refresh: refreshAuth } = useAuthSession()
const filter = ref<Filter>('all') const filter = ref<Filter>('all')
const page = ref(1) const page = ref(1)
@ -107,10 +104,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
onMounted(() => {
void refreshAuth(true)
})
function toggleSelected(id: number, checked: boolean) { function toggleSelected(id: number, checked: boolean) {
const next = new Set(selectedIds.value) const next = new Set(selectedIds.value)
if (checked) { if (checked) {

3
app/pages/me/posts/[id].vue

@ -4,7 +4,7 @@ import { normalizePostSlugCandidate } from '../../../utils/post-slug'
const route = useRoute() const route = useRoute()
const id = computed(() => route.params.id as string) const id = computed(() => route.params.id as string)
const { user, refresh: refreshAuth } = useAuthSession() const { user } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const toast = useToast() const toast = useToast()
@ -56,7 +56,6 @@ async function load(options?: { silent?: boolean }) {
} }
onMounted(() => { onMounted(() => {
void refreshAuth(true)
void load() void load()
}) })

3
app/pages/me/posts/index.vue

@ -9,7 +9,7 @@ type ViewMode = 'list' | 'card'
const posts = ref<Row[]>([]) const posts = ref<Row[]>([])
const loading = ref(true) const loading = ref(true)
const viewMode = ref<ViewMode>('card') const viewMode = ref<ViewMode>('card')
const { user, refresh: refreshAuth } = useAuthSession() const { user } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
async function load() { async function load() {
@ -23,7 +23,6 @@ async function load() {
} }
onMounted(() => { onMounted(() => {
void refreshAuth(true)
void load() void load()
}) })

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

14
server/api/auth/session.get.ts

@ -0,0 +1,14 @@
import { toPublicAuthError } from "#server/service/auth/errors";
/**
* Cookie 访 Cookie SSR `auth.getCurrent()`
* 200 + `{ user }` 401 `/api/auth/me`
*/
export default defineWrappedResponseHandler(async (event) => {
try {
const user = await event.context.auth.getCurrent();
return R.success({ user: user ?? null });
} catch (err) {
throw toPublicAuthError(err);
}
});

2
server/utils/auth-api-routes.ts

@ -7,6 +7,8 @@ const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/auth/captcha", methods: ["GET"] }, { path: "/api/auth/captcha", methods: ["GET"] },
{ path: "/api/auth/login", methods: ["POST"] }, { path: "/api/auth/login", methods: ["POST"] },
{ path: "/api/auth/register", methods: ["POST"] }, { path: "/api/auth/register", methods: ["POST"] },
/** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */
{ path: "/api/auth/session", methods: ["GET"] },
{ path: "/api/config/global", methods: ["GET"] }, { path: "/api/config/global", methods: ["GET"] },
]; ];

Loading…
Cancel
Save