Files
nur-falah-prevention/src/lib/AuthScreen.svelte
wmj a0c70e1411 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.
2026-08-13 21:07:23 +08:00

84 lines
4.0 KiB
Svelte

<script>
import { signIn, signUp } from './auth.js';
let mode = $state('signin'); // signin | signup
let email = $state('');
let password = $state('');
let fullName = $state('');
let error = $state('');
let loading = $state(false);
let signupDone = $state(false);
async function submit() {
error = '';
loading = true;
try {
if (mode === 'signup') {
await signUp(email, password, fullName);
signupDone = true;
} else {
await signIn(email, password);
}
} catch (e) {
error = e.message || 'Something went wrong';
} finally {
loading = false;
}
}
</script>
<div class="auth-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 &amp; WAQF SUITE</span>
<div class="header-divider"></div>
{#if signupDone}
<div class="notice">Account created. Check your email to confirm, then sign in.</div>
<button class="btn-secondary" onclick={() => { signupDone = false; mode = 'signin'; }}>Back to sign in</button>
{:else}
<div class="mode-switch">
<button class="mode-btn" class:active={mode === 'signin'} onclick={() => mode = 'signin'}>Sign in</button>
<button class="mode-btn" class:active={mode === 'signup'} onclick={() => mode = 'signup'}>Create account</button>
</div>
{#if mode === 'signup'}
<label class="field"><span>Full name</span><input type="text" bind:value={fullName} /></label>
{/if}
<label class="field"><span>Email</span><input type="email" bind:value={email} /></label>
<label class="field"><span>Password</span><input type="password" bind:value={password} /></label>
{#if error}<p class="error-text">{error}</p>{/if}
<button class="btn-primary" disabled={loading || !email || !password} onclick={submit}>
{loading ? 'Please wait…' : mode === 'signup' ? 'Create account' : 'Sign in'}
</button>
<p class="note">Head of family or an assigned estate agent — sign in the same way. What you see next depends on your role.</p>
{/if}
</div>
<style>
.auth-screen { max-width: 400px; margin: 60px auto; padding: 24px; text-align: center; }
.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 { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 30px; border-radius: 2px; }
.mode-switch { display: flex; gap: 8px; margin-bottom: 20px; }
.mode-btn { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.05); color: #8A8478; cursor: pointer; }
.mode-btn.active { background: rgba(201,168,76,0.15); color: #C9A84C; font-weight: 600; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; text-align: left; }
.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: 12px; color: #E8E4DC; font-size: 15px; }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 700; cursor: pointer; background: #C9A84C; color: #070A0D; margin-top: 6px; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; cursor: pointer; margin-top: 12px; }
.error-text { color: #EF4444; font-size: 12.5px; margin-bottom: 10px; }
.notice { background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3); border-radius: 10px; padding: 14px; font-size: 13px; color: #E8E4DC; }
.note { font-size: 11px; color: #8A8478; margin-top: 20px; line-height: 1.5; }
</style>