fix(admin): scope remembered list state to the signed-in admin and recover from bad restored queries
- Store list queries under summer-admin.list:<admin id>:<controller>; me() and setCurrentUser set the owner, which removes other admins' keys and the unscoped keys of older builds; nothing is read or written without one - clearUser removes every stored query, so logout, session expiry and an unrecoverable 401 (expireSession, now used by main.ts) all clear them - A list request built from a restored query that fails for any reason (422, 500, network) forgets the query and loads the plain list once - A failing URL query keeps the error and offers a reset button (backend::lang.list.reset_list, en and pl) - Document remembered list state in docs/backend/lists-and-filters.md - Rebuild modules/boardwalk/dist
This commit is contained in:
@@ -4,7 +4,7 @@ import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
@@ -22,13 +22,9 @@ async function boot(): Promise<void> {
|
||||
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
|
||||
}
|
||||
const router = createAdminRouter()
|
||||
onUnauthorized(() => {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } })
|
||||
}
|
||||
})
|
||||
// A session that cannot be recovered ends here: the profile and every
|
||||
// stored list query are cleared before the login screen shows (T-16.1-03).
|
||||
onUnauthorized(() => expireSession(router))
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Per-list query memory (D-01). The list query (search, sort, dir, page,
|
||||
// per_page and filter[<name>]) lives in the URL; every query a list loads is
|
||||
// also kept in localStorage under LIST_STATE_PREFIX plus the controller id,
|
||||
// one key per list, so a list opened without list params (navigation,
|
||||
// breadcrumb, a form's back, cancel, save or delete) can restore it. Only
|
||||
// these list query parameters are stored, never a token or record data, and
|
||||
// logout removes every key. Disabled or full storage is a no-op: the URL
|
||||
// still carries the state.
|
||||
// also kept in localStorage, one key per signed-in admin and list:
|
||||
// LIST_STATE_PREFIX + "<admin id>:" + controller id. A list opened without
|
||||
// list params (navigation, breadcrumb, a form's back, cancel, save or delete)
|
||||
// can then restore it. Only these list query parameters are stored, never a
|
||||
// token or record data. Search terms can hold personal data, so stored
|
||||
// queries never outlive the session or cross admins (T-16.1-03): useAuth sets
|
||||
// the owner when the profile loads, which removes every list key of any other
|
||||
// admin and the unscoped keys of older builds, and clears the owner on every
|
||||
// session end (logout, expiry, a 401 the refresh cannot recover), which
|
||||
// removes every list key. Without an owner nothing is read or written.
|
||||
// Disabled or full storage is a no-op: the URL still carries the state.
|
||||
import type { ListQuery } from '../api/types'
|
||||
import { listQueryKey, parseListQuery, toListQuery } from '../app/listQuery'
|
||||
|
||||
@@ -13,6 +18,9 @@ export const LIST_STATE_PREFIX = 'summer-admin.list:'
|
||||
|
||||
const EMPTY_KEY = listQueryKey({})
|
||||
|
||||
// The signed-in admin's id as a string, or null when nobody is signed in.
|
||||
let owner: string | null = null
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
@@ -21,8 +29,13 @@ function storage(): Storage | null {
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string {
|
||||
return LIST_STATE_PREFIX + controllerId
|
||||
/** The key prefix of one admin's lists. */
|
||||
export function listStateOwnerPrefix(adminId: number | string): string {
|
||||
return `${LIST_STATE_PREFIX}${adminId}:`
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string | null {
|
||||
return owner === null ? null : listStateOwnerPrefix(owner) + controllerId
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
@@ -33,13 +46,60 @@ function remove(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every list key for which drop answers true. */
|
||||
function removeListKeys(drop: (key: string) => boolean): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX) && drop(key)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the admin whose list queries are read and written. A signed-in admin
|
||||
* id removes every list key that is not theirs: another admin's queries and
|
||||
* the unscoped `summer-admin.list:<controller>` keys of older builds. Null
|
||||
* (signed out) removes every list key.
|
||||
*/
|
||||
export function setListStateOwner(adminId: number | string | null): void {
|
||||
const next = adminId === null || String(adminId) === '' ? null : String(adminId)
|
||||
owner = next
|
||||
if (next === null) {
|
||||
removeListKeys(() => true)
|
||||
return
|
||||
}
|
||||
const prefix = listStateOwnerPrefix(next)
|
||||
removeListKeys((key) => !key.startsWith(prefix))
|
||||
}
|
||||
|
||||
/** The id whose list queries are kept, or null when nobody is signed in. */
|
||||
export function listStateOwner(): string | null {
|
||||
return owner
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored query of a list. Stored values pass through parseListQuery like
|
||||
* a hand-edited URL; anything that is not an object of strings is removed and
|
||||
* reads as the empty query.
|
||||
* reads as the empty query. Without an owner it is always the empty query.
|
||||
*/
|
||||
export function readListState(controllerId: string): ListQuery {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return {}
|
||||
}
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage()?.getItem(key) ?? null
|
||||
@@ -69,9 +129,15 @@ export function readListState(controllerId: string): ListQuery {
|
||||
return parseListQuery(value as Record<string, string>)
|
||||
}
|
||||
|
||||
/** Stores a list's query; the empty query removes the key instead. */
|
||||
/**
|
||||
* Stores a list's query for the owner; the empty query removes the key
|
||||
* instead. Without an owner nothing is stored.
|
||||
*/
|
||||
export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return
|
||||
}
|
||||
if (listQueryKey(query) === EMPTY_KEY) {
|
||||
remove(key)
|
||||
return
|
||||
@@ -83,24 +149,7 @@ export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every stored list query (logout). */
|
||||
/** Removes every stored list query of every admin, keeping the owner. */
|
||||
export function clearListStates(): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
removeListKeys(() => true)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { clearListStates } from './listState'
|
||||
import { setListStateOwner } from './listState'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
@@ -48,10 +48,15 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
return true
|
||||
}
|
||||
|
||||
/** Loads the signed-in admin; null when the session is missing or expired. */
|
||||
/**
|
||||
* Loads the signed-in admin; null when the session is missing or expired.
|
||||
* The stored list queries follow the answer: a profile keeps only that
|
||||
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
|
||||
*/
|
||||
export async function me(): Promise<AdminProfile | null> {
|
||||
const { data, response } = await api.GET('/auth/me')
|
||||
user.value = response.ok && data ? data.data : null
|
||||
setListStateOwner(user.value?.id ?? null)
|
||||
return user.value
|
||||
}
|
||||
|
||||
@@ -73,12 +78,35 @@ export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_na
|
||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||
export function setCurrentUser(profile: AdminProfile): void {
|
||||
user.value = profile
|
||||
setListStateOwner(profile.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the session on this device: the refresh timer, the profile and
|
||||
* every stored list query (search terms can hold personal data and never
|
||||
* outlive the session, T-16.1-03).
|
||||
*/
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
setListStateOwner(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session ended on its own (expiry, or a 401 the refresh could not
|
||||
* recover): clears the local session, stored list queries included, and
|
||||
* sends the admin to the login screen with the current path as redirect.
|
||||
*/
|
||||
export function expireSession(router: {
|
||||
currentRoute: { value: Pick<RouteLocationNormalizedLoaded, 'name' | 'fullPath'> }
|
||||
push: Router['push']
|
||||
}): void {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +123,6 @@ export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
// Network failure: the session still ends on this device.
|
||||
} finally {
|
||||
clearUser()
|
||||
clearListStates()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
}
|
||||
@@ -110,6 +137,7 @@ export function useAuth() {
|
||||
expiresIn: readonly(expiresIn),
|
||||
login,
|
||||
logout,
|
||||
expireSession,
|
||||
me,
|
||||
setCurrentUser,
|
||||
clearUser,
|
||||
|
||||
@@ -28,6 +28,10 @@ import { showToast } from '../state/useToasts'
|
||||
// kept per list in browser storage (listState.ts, D-01): entered without list
|
||||
// params, the list restores the stored query into the URL before its first
|
||||
// list request; a URL with list params wins and overwrites the stored query.
|
||||
// A restored query the admin did not ask for never breaks the list: if its
|
||||
// load fails for any reason, the stored query is forgotten and the plain list
|
||||
// loads once instead (WR-02). A failed URL query keeps the error, with a reset
|
||||
// back to the plain list next to it.
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
@@ -124,21 +128,47 @@ function replaceQuery(next: ListQuery): void {
|
||||
|
||||
const EMPTY_QUERY_KEY = listQueryKey({})
|
||||
|
||||
let generation = 0
|
||||
const hasQuery = computed(() => listQueryKey(query.value) !== EMPTY_QUERY_KEY)
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
let generation = 0
|
||||
// Key of the query the watch restored from storage and has not loaded yet.
|
||||
let restoredKey: string | null = null
|
||||
|
||||
/** Drops every list param: the stored query is forgotten, the plain list loads. */
|
||||
function resetQuery(): void {
|
||||
replaceQuery({})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the rows of the URL query. restored marks the first load of a query
|
||||
* restored from storage: if it fails for any reason (422, 500, network), the
|
||||
* stored query is forgotten and the plain list loads. The plain list is never
|
||||
* restored again, so this happens at most once per entry.
|
||||
*/
|
||||
async function loadList(restored = false): Promise<void> {
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
listFailed.value = false
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
let result: Awaited<ReturnType<typeof fetchList>> | null = null
|
||||
try {
|
||||
result = await fetchList()
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
rows.value = result.data?.data ?? []
|
||||
meta.value = result.data?.meta ?? null
|
||||
listFailed.value = !result.data
|
||||
if (!result?.data && restored) {
|
||||
// Keeps loading until the plain list answers; no error flashes.
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
rows.value = result?.data?.data ?? []
|
||||
meta.value = result?.data?.meta ?? null
|
||||
listFailed.value = !result?.data
|
||||
loading.value = false
|
||||
if (result.response?.status === 422) {
|
||||
if (result?.response?.status === 422) {
|
||||
// A stale stored filter or sort never sticks.
|
||||
writeListState(controllerId, {})
|
||||
return
|
||||
@@ -150,6 +180,10 @@ async function loadList(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchList() {
|
||||
return api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||
schema.value = result.data?.data ?? null
|
||||
@@ -170,15 +204,19 @@ watch(
|
||||
// restore the stored query into the URL and let this watch load it.
|
||||
if (key === EMPTY_QUERY_KEY) {
|
||||
const stored = readListState(controllerId)
|
||||
if (listQueryKey(stored) !== EMPTY_QUERY_KEY) {
|
||||
const storedKey = listQueryKey(stored)
|
||||
if (storedKey !== EMPTY_QUERY_KEY) {
|
||||
restoredKey = storedKey
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
return
|
||||
}
|
||||
}
|
||||
const restored = restoredKey !== null && restoredKey === key
|
||||
restoredKey = null
|
||||
writeListState(controllerId, query.value)
|
||||
selected.value = []
|
||||
searchText.value = query.value.search ?? ''
|
||||
void loadList()
|
||||
void loadList(restored)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -417,9 +455,16 @@ async function onAction(name: string): Promise<void> {
|
||||
:source="path"
|
||||
@update="onFilter"
|
||||
/>
|
||||
<p v-if="failed && !loading" role="alert" class="border-t border-border px-5 py-4 text-danger">
|
||||
{{ t('backend::lang.list.load_failed') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failed && !loading"
|
||||
data-list-failed
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4"
|
||||
>
|
||||
<p role="alert" class="text-danger">{{ t('backend::lang.list.load_failed') }}</p>
|
||||
<Button v-if="listFailed && hasQuery" data-action="reset-list" @click="resetQuery">
|
||||
{{ t('backend::lang.list.reset_list') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="schema?.columns ?? []"
|
||||
|
||||
Reference in New Issue
Block a user