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:
2026-10-09 23:53:19 +02:00
parent 5be98a0c83
commit cd25af0878
15 changed files with 594 additions and 75 deletions

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 ?? []"