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:
@@ -0,0 +1,125 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { createFamily, listMyFamilies, listPendingInvites, acceptInvite, activeFamilyId } from './family.js';
|
||||
import { currentUser, signOut } from './auth.js';
|
||||
|
||||
let families = $state([]);
|
||||
let invites = $state([]);
|
||||
let newFamilyName = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
families = await listMyFamilies();
|
||||
invites = await listPendingInvites();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newFamilyName) return;
|
||||
try {
|
||||
await createFamily(newFamilyName);
|
||||
newFamilyName = '';
|
||||
await refresh();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
|
||||
async function handleAccept(inv) {
|
||||
try {
|
||||
await acceptInvite(inv.membershipId);
|
||||
await refresh();
|
||||
activeFamilyId.set(inv.familyId);
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
|
||||
function selectFamily(id) {
|
||||
activeFamilyId.set(id);
|
||||
}
|
||||
|
||||
const isAgentForMultiple = $derived(families.filter(f => f.role === 'agent').length > 1);
|
||||
</script>
|
||||
|
||||
<div class="switcher-screen">
|
||||
<div class="header-brand">
|
||||
<div class="brand-line brand-line-first">Nur</div>
|
||||
<div class="brand-line brand-line-second">Falah</div>
|
||||
</div>
|
||||
<span class="header-tagline">ESTATE & WAQF SUITE</span>
|
||||
<div class="header-divider"></div>
|
||||
|
||||
<div class="user-row">
|
||||
<span>Signed in as {currentUser()?.email}</span>
|
||||
<button class="signout-btn" onclick={signOut}>Sign out</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error-text">{error}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">Loading your families…</p>
|
||||
{:else}
|
||||
{#if invites.length}
|
||||
<div class="section">
|
||||
<h3>Pending invitations</h3>
|
||||
{#each invites as inv}
|
||||
<div class="invite-row">
|
||||
<span>{inv.familyName} — as <strong>{inv.role}</strong></span>
|
||||
<button class="btn-small" onclick={() => handleAccept(inv)}>Accept</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if families.length}
|
||||
<div class="section">
|
||||
<h3>{isAgentForMultiple ? 'Your assigned families' : 'Your families'}</h3>
|
||||
{#each families as f}
|
||||
<button class="family-row" onclick={() => selectFamily(f.id)}>
|
||||
<span>{f.name}</span>
|
||||
<span class="role-badge" class:owner={f.role === 'owner'}>{f.role}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<h3>Start your own family estate</h3>
|
||||
<p class="hint">If you're the head of family setting this up for the first time, create your family here. You can invite an estate agent to help manage it once it's set up.</p>
|
||||
<label class="field"><span>Family name</span><input type="text" bind:value={newFamilyName} placeholder="e.g. The Ismail Family" /></label>
|
||||
<button class="btn-primary" onclick={handleCreate}>Create family</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switcher-screen { max-width: 440px; margin: 0 auto; padding: 24px 16px; }
|
||||
.header-brand { display: flex; justify-content: center; gap: 8px; }
|
||||
.brand-line { font-family: 'DM Serif Display', serif; font-size: 30px; }
|
||||
.brand-line-first { color: #E8E4DC; }
|
||||
.brand-line-second { color: #C9A84C; }
|
||||
.header-tagline { display: block; text-align: center; font-size: 10px; letter-spacing: 2px; color: #8A8478; }
|
||||
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 24px; border-radius: 2px; }
|
||||
.user-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #8A8478; margin-bottom: 20px; }
|
||||
.signout-btn { background: none; border: none; color: #C9A84C; cursor: pointer; font-size: 12px; }
|
||||
.loading { text-align: center; color: #8A8478; font-size: 13px; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.section h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
|
||||
.hint { font-size: 11.5px; color: #8A8478; line-height: 1.5; margin-bottom: 12px; }
|
||||
.invite-row { display: flex; justify-content: space-between; align-items: center; background: rgba(201,168,76,0.08); border: 1px solid rgba(201,168,76,0.25); border-radius: 10px; padding: 12px; margin-bottom: 8px; font-size: 13px; color: #E8E4DC; }
|
||||
.btn-small { background: #C9A84C; color: #070A0D; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.family-row { width: 100%; display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.04); border: none; border-radius: 10px; padding: 14px; margin-bottom: 8px; font-size: 14px; color: #E8E4DC; cursor: pointer; text-align: left; }
|
||||
.role-badge { font-size: 10px; text-transform: uppercase; padding: 3px 8px; border-radius: 6px; background: rgba(255,255,255,0.1); color: #B8B2A6; }
|
||||
.role-badge.owner { background: rgba(201,168,76,0.15); color: #C9A84C; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.field span { font-size: 12px; color: #B8B2A6; }
|
||||
.field input { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; width: 100%; }
|
||||
.btn-primary { width: 100%; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
||||
.error-text { color: #EF4444; font-size: 12.5px; margin-bottom: 12px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user