Files
summercms/admin/src/state/listState.ts
Jakub Zych cd25af0878 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
2026-10-09 23:53:19 +02:00

156 lines
4.7 KiB
TypeScript

// 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, 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'
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
} catch {
return null
}
}
/** 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 {
try {
storage()?.removeItem(key)
} catch {
// Storage may be disabled; nothing was kept then.
}
}
/** 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. 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
} catch {
return {}
}
if (raw === null) {
return {}
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
remove(key)
return {}
}
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype ||
!Object.values(value).every((item) => typeof item === 'string')
) {
remove(key)
return {}
}
return parseListQuery(value as Record<string, string>)
}
/**
* 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
}
try {
storage()?.setItem(key, JSON.stringify(toListQuery(query)))
} catch {
// Storage may be full or disabled; the URL still carries the state.
}
}
/** Removes every stored list query of every admin, keeping the owner. */
export function clearListStates(): void {
removeListKeys(() => true)
}