Filtrovaný dropdown maskuje skutečnou hodnotu a uložení ji tiše přepíše
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”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.
Root cause
Sekce “Root cause”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á.
Fix
Sekce “Fix”// 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 naoptions.find(...)/selected.label : placeholderbez 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.