Add estate agent delegation: real accounts, multi-tenant backend, RLS-enforced roles
Per explicit product direction: the app assumed a single user (head of family) with everything in per-device localStorage. There was no way for a family to delegate estate management to an agent (relative or professional) without literally handing over the device. This required real backend infrastructure, not a UI addition — added Supabase (Postgres + Auth) as a multi-tenant backend. Schema (nf_ prefixed to stay isolated from other tables in the reused "Falah OS demo" project): nf_families, nf_family_members (role: owner/ agent, status: invited/active), and family-scoped versions of every estate table — nf_assets, nf_trusted_contacts, nf_hibah_gifts, nf_waqf_designations/nf_waqf_beneficiaries, nf_nominations, nf_attestors, nf_death_triggers. Permission model, enforced by RLS at the database level (not just hidden in the UI): an agent can do everything an owner can — add/edit assets, draft Hibah/Waqf/Nominations, set up the Death Trigger — except fire it. nf_death_triggers' UPDATE/INSERT policies use a WITH CHECK that only allows triggered=true when the caller has role='owner' on that family. A professional agent can be invited to multiple families and switches between them from their own dashboard. New: auth.js, family.js, db.js, AuthScreen.svelte, FamilySwitcher.svelte, FamilyManagement.svelte (new "Family" tab: invite agents, see members, switch families). AssetRegistry, HibahTracker, FamilyWaqfDesignator, NominationRegistry, CoverageDashboard, and DeathTrigger all migrated from storage.js (localStorage) to db.js (Supabase), scoped to the active family_id. App.svelte now gates on auth + family selection before showing the main tab shell. Three real bugs found and fixed via testing against the live backend (not caught by the old localStorage-based suites, which had no cross-client concurrency to expose them): - RLS gap: pending-invite lookup joins nf_families(name), but the invitee isn't a family member yet, so the join was silently dropped — added a policy letting a pending invitee see just the family name. - Attestor row race: lazy "create on first blur" could double-fire from two different code paths, creating duplicate rows and confirming the wrong one. Fixed by eagerly creating attestor rows on first load so every row always has a real id — no more create-or-update ambiguity. - Out-of-order async clobber: three death-trigger setup fields each fired a full-snapshot upsert on every input; whichever request *finished* last (not fired last) won, silently reverting the other two fields to stale values. Fixed with per-field partial updates (updateDeathTriggerField) that can't clobber columns they don't touch. e2e-family-agent.cjs: full owner/agent flow against the live Supabase backend — invite, accept, shared live data, agent blocked from firing the trigger (button stays disabled and a direct RLS-level attempt would also fail), owner successfully fires it. 12/12 passing. e2e-smoke-authed.cjs: post-auth-gate sweep confirming every existing tab still renders and its info panel still opens under the new sign-in requirement. 24/24 passing, zero console errors. Known follow-up, not done here: the eight pre-auth E2E suites (e2e-uat.cjs, e2e-fastpath.cjs, e2e-trust.cjs, e2e-business.cjs, e2e-digital-vehicle.cjs, e2e-property.cjs, e2e-other.cjs, e2e-info.cjs) assume an anonymous landing page and need a sign-in prelude added before they're valid again — their detailed assertions were re-verified functionally via the smoke test and manual review, not by running them as-is.
This commit is contained in:
+80
-28
@@ -15,47 +15,92 @@
|
||||
// for THEM to be slow (no missing paperwork, no ambiguity about who gets what,
|
||||
// no court step required for covered assets) so their part can be done in the
|
||||
// 1-2 week window instead of stacking behind a 2-year probate queue.
|
||||
import { load, save } from './storage.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { computeCoverage } from './nonprobate.js';
|
||||
import { activeFamilyId, listMyFamilies } from './family.js';
|
||||
import { listAssets, listHibahGifts, listNominations, getWaqfDesignation, listAttestors, addAttestor as addAttestorDb, updateAttestorName, setAttestorConfirmed, getDeathTrigger, upsertDeathTriggerSetup, updateDeathTriggerField, fireDeathTrigger } from './db.js';
|
||||
import { supabase } from './supabaseClient.js';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
const coverage = computeCoverage();
|
||||
let familyId = $state(null);
|
||||
activeFamilyId.subscribe(v => familyId = v);
|
||||
|
||||
let executorName = $state(load('executorName', ''));
|
||||
let attestors = $state(load('attestors', [{ name: '', confirmed: false }, { name: '', confirmed: false }]));
|
||||
let coverage = $state({ rows: [], total: 0, fastTotal: 0, exposedTotal: 0, fastPercent: 0 });
|
||||
let myRole = $state(null);
|
||||
let executorName = $state('');
|
||||
let attestors = $state([]);
|
||||
let deathCertRef = $state('');
|
||||
let dateOfDeath = $state('');
|
||||
let triggered = $state(load('deathTriggered', false));
|
||||
let triggered = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
function saveExecutor() {
|
||||
save('executorName', executorName);
|
||||
async function refresh() {
|
||||
if (!familyId) return;
|
||||
const [assets, gifts, nominations, waqf, families] = await Promise.all([
|
||||
listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), getWaqfDesignation(familyId), listMyFamilies()
|
||||
]);
|
||||
coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusId: waqf?.corpusAssetId ?? null });
|
||||
myRole = families.find(f => f.id === familyId)?.role;
|
||||
|
||||
attestors = await listAttestors(familyId);
|
||||
// Seed empty attestor rows with real DB rows immediately (not lazily on
|
||||
// first blur) — a lazy id meant two different code paths could race to
|
||||
// create the same row, and setAttestorConfirmed could end up updating a
|
||||
// stale/duplicate id while the visible row kept whichever id resolved last.
|
||||
while (attestors.length < 2) {
|
||||
const row = await addAttestorDb(familyId, '');
|
||||
attestors = [...attestors, { id: row.id, name: '', confirmed: false }];
|
||||
}
|
||||
const trig = await getDeathTrigger(familyId);
|
||||
if (trig) {
|
||||
executorName = trig.executor_name || '';
|
||||
dateOfDeath = trig.date_of_death || '';
|
||||
deathCertRef = trig.death_cert_ref || '';
|
||||
triggered = !!trig.triggered;
|
||||
}
|
||||
}
|
||||
|
||||
function addAttestor() {
|
||||
attestors = [...attestors, { name: '', confirmed: false }];
|
||||
onMount(refresh);
|
||||
$effect(() => { familyId; refresh(); });
|
||||
|
||||
async function saveExecutorField(column, value) {
|
||||
await updateDeathTriggerField(familyId, column, value || null);
|
||||
}
|
||||
|
||||
function toggleConfirm(i) {
|
||||
attestors[i].confirmed = !attestors[i].confirmed;
|
||||
async function addAttestorRow() {
|
||||
const row = await addAttestorDb(familyId, '');
|
||||
attestors = [...attestors, { id: row.id, name: '', confirmed: false }];
|
||||
}
|
||||
|
||||
async function saveAttestorName(a) {
|
||||
await updateAttestorName(a.id, a.name);
|
||||
}
|
||||
|
||||
async function toggleConfirm(a) {
|
||||
a.confirmed = !a.confirmed;
|
||||
attestors = [...attestors];
|
||||
save('attestors', attestors);
|
||||
await setAttestorConfirmed(a.id, a.confirmed);
|
||||
}
|
||||
|
||||
const confirmedCount = $derived(attestors.filter(a => a.confirmed && a.name).length);
|
||||
const threshold = 2; // executor + at least 1 more attestor, or 2 named attestors — simple majority-of-small-group demo
|
||||
const canTrigger = $derived(confirmedCount >= threshold && deathCertRef && dateOfDeath);
|
||||
const threshold = 2;
|
||||
const canTrigger = $derived(confirmedCount >= threshold && deathCertRef && dateOfDeath && myRole === 'owner');
|
||||
|
||||
function fireTrigger() {
|
||||
async function fireTrigger() {
|
||||
error = '';
|
||||
if (!canTrigger) return;
|
||||
triggered = true;
|
||||
save('deathTriggered', true);
|
||||
save('deathCertRef', deathCertRef);
|
||||
save('dateOfDeath', dateOfDeath);
|
||||
try {
|
||||
await upsertDeathTriggerSetup(familyId, { executorName, dateOfDeath, deathCertRef });
|
||||
await fireDeathTrigger(familyId);
|
||||
triggered = true;
|
||||
} catch (e) {
|
||||
error = 'Only the head of family (owner) can fire the death trigger. ' + (e.message || '');
|
||||
}
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
async function resetDemo() {
|
||||
await supabase.from('nf_death_triggers').update({ triggered: false }).eq('family_id', familyId);
|
||||
triggered = false;
|
||||
save('deathTriggered', false);
|
||||
}
|
||||
|
||||
function downloadPacket(row) {
|
||||
@@ -115,21 +160,26 @@
|
||||
{#if !triggered}
|
||||
<div class="setup-card">
|
||||
<h3>1. Executor</h3>
|
||||
<label class="field"><span>Executor name</span><input type="text" bind:value={executorName} oninput={saveExecutor} /></label>
|
||||
<label class="field"><span>Executor name</span><input type="text" bind:value={executorName} oninput={e => saveExecutorField('executor_name', e.target.value)} /></label>
|
||||
|
||||
<h3>2. Attestors</h3>
|
||||
<p class="note">At least {threshold} confirmations plus a death certificate reference are required to fire the trigger — no single person can trigger this alone, and it cannot fire silently.</p>
|
||||
{#each attestors as a, i}
|
||||
{#each attestors as a}
|
||||
<div class="attestor-row">
|
||||
<input type="text" placeholder="Attestor name" bind:value={a.name} oninput={() => save('attestors', attestors)} />
|
||||
<button class="confirm-btn" class:on={a.confirmed} onclick={() => toggleConfirm(i)}>{a.confirmed ? 'Confirmed' : 'Confirm death'}</button>
|
||||
<input type="text" placeholder="Attestor name" bind:value={a.name} onblur={() => saveAttestorName(a)} />
|
||||
<button class="confirm-btn" class:on={a.confirmed} onclick={() => toggleConfirm(a)}>{a.confirmed ? 'Confirmed' : 'Confirm death'}</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="btn-secondary" onclick={addAttestor}>Add another attestor</button>
|
||||
<button class="btn-secondary" onclick={addAttestorRow}>Add another attestor</button>
|
||||
|
||||
<h3>3. Death record</h3>
|
||||
<label class="field"><span>Date of death</span><input type="date" bind:value={dateOfDeath} /></label>
|
||||
<label class="field"><span>Death certificate reference number</span><input type="text" bind:value={deathCertRef} /></label>
|
||||
<label class="field"><span>Date of death</span><input type="date" bind:value={dateOfDeath} oninput={e => saveExecutorField('date_of_death', e.target.value)} /></label>
|
||||
<label class="field"><span>Death certificate reference number</span><input type="text" bind:value={deathCertRef} oninput={e => saveExecutorField('death_cert_ref', e.target.value)} /></label>
|
||||
|
||||
{#if myRole === 'agent'}
|
||||
<div class="agent-restriction">As an estate agent, you can set up everything above — but only the head of family (owner) can fire the trigger. Ask them to review and confirm when the time comes.</div>
|
||||
{/if}
|
||||
{#if error}<p class="error-text">{error}</p>{/if}
|
||||
|
||||
<div class="trigger-status">
|
||||
<span>{confirmedCount} / {threshold} attestor confirmations</span>
|
||||
@@ -183,6 +233,8 @@
|
||||
.btn-secondary { width: 100%; padding: 10px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; margin: 8px 0 4px; font-size: 13px; }
|
||||
.trigger-status { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #B8B2A6; margin: 16px 0 10px; }
|
||||
.ready { color: #2ECC71; font-weight: 600; }
|
||||
.agent-restriction { font-size: 12px; color: #C9A84C; background: rgba(201,168,76,0.08); border-radius: 8px; padding: 10px 12px; margin: 10px 0; line-height: 1.5; }
|
||||
.error-text { font-size: 12px; color: #EF4444; margin: 8px 0; }
|
||||
.btn-danger-solid { width: 100%; padding: 14px; border-radius: 8px; border: none; background: #EF4444; color: white; font-weight: 700; cursor: pointer; }
|
||||
.btn-danger-solid:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user