Přeskočit na obsah

Filtrovaný dropdown maskuje skutečnou hodnotu a uložení ji tiše přepíše

import { Aside } from ‘@astrojs/starlight/components’;

Uživatelé hlásí „přiřazení nefunguje / nejde vybrat správná osoba”. Detail záznamu u stovek řádků ukazuje „— nepřiřazeno —”, přestože v databázi vlastník nastavený je. Když někdo u takového záznamu vybere jinou hodnotu a uloží, skutečný vlastník se tiše přepíše — bez varování a bez stopy, že tam nějaký byl.

Dvě nezávislé vady téhož vzoru:

1. Nabídka je užší než realita dat. Endpoint pro options filtroval na jedinou roli (role = "SALES"), ale pole v DB historicky nabývalo hodnot i jiných rolí (migrace, konverze, hromadné akce — v našem případě stovky řádků). Nativní <select> i custom komponenta shodně řeší hodnotu mimo options fallbackem na placeholder:

// custom select — hodnota mimo options => vypadá jako prázdná
const selected = options.find((o) => o.value === value);
return <span>{selected ? selected.label : placeholder}</span>;
// nativní <select> — <option value={aktuální}> neexistuje => zobrazí se první option
<option value="">— nepřiřazeno —</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}

Uživatel tak vidí lež („nepřiřazeno”) a formulář při uložení pošle to, co vidí — čímž reálnou hodnotu zničí.

2. Whitelist existoval jen ve výpisu. Když se nabídka rozšířila na schválenou množinu rolí, omezení žilo pouze v GET endpointu pro options. Všechny zápisové cesty (create, update, bulk-assign, …) přijaly libovolné ID — whitelist šel obejít přímým API požadavkem. A třetí vrstva téhož: create endpoint měl implicitní self-assign („bez ownerId přiřaď volajícího”), takže role mimo whitelist se stala vlastníkem prostě tím, že záznam založila běžným formulářem, který ownerId neposílá.

// 1. Aktuální hodnotu mimo options VŽDY doplnit — s pravdivým důvodem, proč v nabídce není
{owner && !options.some((u) => u.id === owner.id) && (
<option value={owner.id}>
{owner.name} ({owner.isActive === false ? "neaktivní účet" : "mimo povolené role"})
</option>
)}

Pozor na detail: k rozlišení „neaktivní” vs. „mimo povolené role” musí API detailu vracet isActive — jinak UI označí aktivního uživatele nepravdivě jako neaktivního.

// 2. Jeden serverový guard nad TÝMŽ konstantním whitelistem jako options endpoint,
// zapojený do KAŽDÉ zápisové rodiny (create, update, bulk, …)
export async function checkAssignableUser(userId: string) {
const user = await db.user.findUnique({ where: { id: userId }, select: { isActive: true, role: true } });
if (!user) return { ok: false, error: "Uživatel neexistuje." };
if (!user.isActive) return { ok: false, error: "Účet je neaktivní." };
if (!ASSIGNABLE_ROLES.includes(user.role)) return { ok: false, error: "Role není povolená." };
return { ok: true };
}
// 3. Implicitní default (self-assign) podléhá témuž pravidlu:
if (!data.ownerId) {
data.ownerId = ASSIGNABLE_ROLES.includes(auth.user.role) ? auth.user.id : null;
}

Guard se volá jen při novém explicitním přiřazení nebo změně — nezměněné historické vlastnictví se nechává být (grandfathering), jinak by opravu nešlo nasadit nad daty, která už mimo whitelist jsou.

Jak se tomu vyvarovat v jiných systémech

Sekce “Jak se tomu vyvarovat v jiných systémech”
  • Detection: dotaz SELECT DISTINCT role FROM users JOIN záznamy ON vlastník… porovnat s filtrem options endpointu — každá role v datech, která není v nabídce, je maskovaný záznam. V kódu grep na options.find(...)/selected.label : placeholder bez větve pro hodnotu mimo options.
  • Anti-pattern: validace množiny povolených hodnot pouze v endpointu, který plní <select>; „default = přihlášený uživatel” bez kontroly, zda přihlášený smí být cílem.
  • Lepší přístup: whitelist jako sdílená konstanta importovaná options endpointem i všemi zápisovými cestami; testy zápisového kontraktu (každá rodina × nepovolený cíl → 400 + DB read-back, že se nic nezměnilo); render test „aktivní hodnota mimo options zůstává zobrazená a vybraná”.

Sister bugs / související

Sekce “Sister bugs / související”
  • Stejný kořen jako „autorizace jen v UI”: skrytí tlačítka není oprávnění. Nabídka selectu je jen jiná forma skrytí tlačítka.
  • Příbuzná past: bulk akce a jednotlivá akce téže domény s různými pravidly — dvě cesty ke stejnému zápisu musí sdílet tentýž guard, jinak přísnější cesta jen maskuje díru.
Přidal aiarchitekt.cz · 13. 8. 2026 2:00
Provozuje aiarchitekt.cz