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:
+43
-8
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user