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:
@@ -1,12 +1,18 @@
|
||||
<script>
|
||||
import { load, save, estateTotal } from './storage.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { activeFamilyId } from './family.js';
|
||||
import { currentUser } from './auth.js';
|
||||
import { listAssets, addAsset, updateAsset, removeAsset, listTrustedContacts, addTrustedContact, removeTrustedContact, estateTotal } from './db.js';
|
||||
import Disclaimer from './Disclaimer.svelte';
|
||||
import InfoPanel from './InfoPanel.svelte';
|
||||
|
||||
const TYPES = ['Property', 'Cash / Bank', 'Vehicle', 'Business interest', 'Digital assets', 'Jewelry / valuables', 'Other'];
|
||||
|
||||
let assets = $state(load('assets', []));
|
||||
let trustedContacts = $state(load('trustedContacts', []));
|
||||
let familyId = $state(null);
|
||||
activeFamilyId.subscribe(v => familyId = v);
|
||||
|
||||
let assets = $state([]);
|
||||
let trustedContacts = $state([]);
|
||||
|
||||
let form = $state(emptyForm());
|
||||
let editingId = $state(null);
|
||||
@@ -16,20 +22,25 @@
|
||||
return { type: TYPES[0], description: '', value: '', location: '', ownershipShare: 100 };
|
||||
}
|
||||
|
||||
function persist() {
|
||||
save('assets', assets);
|
||||
async function refresh() {
|
||||
if (!familyId) return;
|
||||
assets = await listAssets(familyId);
|
||||
trustedContacts = await listTrustedContacts(familyId);
|
||||
}
|
||||
|
||||
function addOrUpdate() {
|
||||
onMount(refresh);
|
||||
$effect(() => { familyId; refresh(); });
|
||||
|
||||
async function addOrUpdate() {
|
||||
if (!form.description || !form.value) return;
|
||||
if (editingId) {
|
||||
assets = assets.map(a => a.id === editingId ? { ...form, id: editingId, value: Number(form.value) } : a);
|
||||
await updateAsset(editingId, form);
|
||||
editingId = null;
|
||||
} else {
|
||||
assets = [...assets, { ...form, id: crypto.randomUUID(), value: Number(form.value) }];
|
||||
await addAsset(familyId, currentUser()?.id, form);
|
||||
}
|
||||
form = emptyForm();
|
||||
persist();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function edit(a) {
|
||||
@@ -37,22 +48,22 @@
|
||||
form = { ...a, value: String(a.value) };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
assets = assets.filter(a => a.id !== id);
|
||||
async function remove(id) {
|
||||
await removeAsset(id);
|
||||
if (editingId === id) { editingId = null; form = emptyForm(); }
|
||||
persist();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
async function addContact() {
|
||||
if (!contactForm.name) return;
|
||||
trustedContacts = [...trustedContacts, { ...contactForm, id: crypto.randomUUID() }];
|
||||
await addTrustedContact(familyId, contactForm.name, contactForm.method);
|
||||
contactForm = { name: '', method: '' };
|
||||
save('trustedContacts', trustedContacts);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function removeContact(id) {
|
||||
trustedContacts = trustedContacts.filter(c => c.id !== id);
|
||||
save('trustedContacts', trustedContacts);
|
||||
async function removeContact(id) {
|
||||
await removeTrustedContact(id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function exportSummary() {
|
||||
|
||||
Reference in New Issue
Block a user