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:
wmj
2026-08-13 21:07:23 +08:00
parent 4250f8da14
commit a0c70e1411
19 changed files with 1245 additions and 111 deletions
+43 -8
View File
@@ -11,12 +11,36 @@
import { exportAll, deleteAll } from './lib/storage.js';
import { lang, setLang } from './lib/i18n.js';
import InfoPanel from './lib/InfoPanel.svelte';
import { session, authLoading, signOut } from './lib/auth.js';
import { activeFamilyId, listMyFamilies } from './lib/family.js';
import AuthScreen from './lib/AuthScreen.svelte';
import FamilySwitcher from './lib/FamilySwitcher.svelte';
import FamilyManagement from './lib/FamilyManagement.svelte';
let currentLang = $state('en');
lang.subscribe(v => currentLang = v);
const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Claims (H2)', 'Settings'];
const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🔗', '⚙️'];
let currentSession = $state(null);
session.subscribe(v => currentSession = v);
let loadingAuth = $state(true);
authLoading.subscribe(v => loadingAuth = v);
let familyId = $state(null);
activeFamilyId.subscribe(v => familyId = v);
// If the stored active family isn't one this user actually belongs to
// (e.g. after switching accounts), fall back to the family picker.
let familyValid = $state(null);
$effect(() => {
if (currentSession && familyId) {
listMyFamilies().then(fams => { familyValid = fams.some(f => f.id === familyId); });
} else {
familyValid = null;
}
});
const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
function handleKeydown(e) {
@@ -43,6 +67,13 @@
<svelte:window onkeydown={handleKeydown} />
{#if loadingAuth}
<div class="loading-screen">Loading…</div>
{:else if !currentSession}
<AuthScreen />
{:else if !familyId || familyValid === false}
<FamilySwitcher />
{:else}
<div class="app">
<header>
<div class="header-brand">
@@ -72,18 +103,19 @@
{:else if activeTab === 6}<NominationRegistry />
{:else if activeTab === 7}<DeathTrigger />
{:else if activeTab === 8}<DigitalClaims />
{:else if activeTab === 9}
{:else if activeTab === 9}<FamilyManagement />
{:else if activeTab === 10}
<div class="module">
<div class="module-header">
<h2>Settings</h2>
<InfoPanel
title="Settings"
what="Where you control your data and the app's language. Everything you enter in this app stays only on this device — nothing is sent anywhere unless you export it yourself."
how="Switch between English and Bahasa Malaysia here. If you ever want a full backup of everything you've entered, export it. If you want to start completely fresh, delete everything — this cannot be undone, so only use it if you really mean it."
what="Where you control your data and the app's language. Estate data (assets, Hibah, Waqf, nominations) now lives in your family's shared account, visible to you and anyone you've assigned as an estate agent."
how="Switch between English and Bahasa Malaysia here. Sign out when you're done, especially on a shared device."
fields={[]}
/>
</div>
<p class="sub">Your data is stored locally on this device. Nothing is sent to a third party.</p>
<p class="sub">Signed in as {currentSession.user.email}.</p>
<div class="lang-switch">
<span class="lang-label">Language / Bahasa</span>
@@ -93,12 +125,14 @@
</div>
</div>
<button class="btn-secondary" onclick={doExport}>Export all data (JSON)</button>
<button class="btn-danger" onclick={doDelete}>Delete all data irreversible</button>
<button class="btn-secondary" onclick={doExport}>Export local export/H2-demo data (JSON)</button>
<button class="btn-secondary" onclick={signOut}>Sign out</button>
<button class="btn-danger" onclick={doDelete}>Delete local device data irreversible</button>
</div>
{/if}
</main>
</div>
{/if}
<style>
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
@@ -119,6 +153,7 @@
pointer-events: none; z-index: 0;
}
:global(#app) { position: relative; z-index: 2; }
.loading-screen { display: flex; align-items: center; justify-content: center; min-height: 100dvh; color: #8A8478; font-size: 14px; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }