diff --git a/e2e-family-agent.cjs b/e2e-family-agent.cjs
new file mode 100644
index 0000000..6ade3ed
--- /dev/null
+++ b/e2e-family-agent.cjs
@@ -0,0 +1,145 @@
+// Verifies the owner/agent family delegation flow end-to-end against the live
+// Supabase-backed app: owner signs up, creates a family, invites an agent; agent
+// signs up with that email, accepts the invite, sees the family in their
+// dashboard, can add an asset — but cannot fire the death trigger (owner-only,
+// enforced by RLS, not just hidden in the UI).
+const { chromium } = require('playwright');
+const BASE = 'https://moslem04.falahos.my/';
+const results = [];
+function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
+
+const stamp = process.argv[2] || String(Math.floor(Math.random() * 1e9));
+const ownerEmail = 'nurfalah.e2etest.owner@gmail.com';
+const agentEmail = 'nurfalah.e2etest.agent@gmail.com';
+const password = 'TestPassword123!';
+const familyName = `Test Family ${stamp}`;
+
+async function signUp(page, email, name) {
+ await page.goto(BASE, { waitUntil: 'networkidle' });
+ await page.locator('.mode-btn', { hasText: 'Create account' }).click();
+ await page.waitForTimeout(200);
+ await page.locator('.field:has-text("Full name") input').fill(name);
+ await page.locator('.field:has-text("Email") input').fill(email);
+ await page.locator('.field:has-text("Password") input').fill(password);
+ await page.locator('button.btn-primary', { hasText: 'Create account' }).click();
+ await page.waitForTimeout(1500);
+}
+
+async function signIn(page, email) {
+ await page.goto(BASE, { waitUntil: 'networkidle' });
+ await page.locator('.field:has-text("Email") input').fill(email);
+ await page.locator('.field:has-text("Password") input').fill(password);
+ await page.locator('button.btn-primary', { hasText: 'Sign in' }).click();
+ await page.waitForTimeout(1500);
+}
+
+async function main() {
+ const mode = process.argv[3];
+
+ if (mode === 'signup') {
+ const browser = await chromium.launch();
+ const ownerPage = await (await browser.newContext()).newPage();
+ await signUp(ownerPage, ownerEmail, 'Owner Test');
+ const agentPage = await (await browser.newContext()).newPage();
+ await signUp(agentPage, agentEmail, 'Agent Test');
+ await browser.close();
+ console.log(JSON.stringify({ ownerEmail, agentEmail, password, familyName }));
+ return;
+ }
+
+ // mode === 'flow' — accounts already confirmed via SQL
+ const browser = await chromium.launch();
+
+ // ── Owner: sign in, create family, invite agent ──
+ const ownerCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const ownerPage = await ownerCtx.newPage();
+ await signIn(ownerPage, ownerEmail);
+ const onSwitcher = await ownerPage.locator('.switcher-screen').isVisible().catch(() => false);
+ record('Owner: signs in and lands on family switcher (no family yet)', onSwitcher);
+
+ await ownerPage.locator('.field:has-text("Family name") input').fill(familyName);
+ await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click();
+ await ownerPage.waitForTimeout(1000);
+ const onMainApp = await ownerPage.locator('nav button.tab', { hasText: 'Coverage' }).isVisible().catch(() => false);
+ record('Owner: creating a family lands on the main app', onMainApp);
+
+ await ownerPage.locator('nav button[aria-label="Family"]').click();
+ await ownerPage.waitForTimeout(300);
+ await ownerPage.locator('.field:has-text("Invite an estate agent") input').fill(agentEmail);
+ await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click();
+ await ownerPage.waitForTimeout(800);
+ const memberRowVisible = await ownerPage.locator('.member-row', { hasText: agentEmail }).isVisible();
+ record('Owner: inviting agent creates a pending member row', memberRowVisible);
+
+ // ── Agent: sign in, accept invite, see family, add an asset ──
+ const agentCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const agentPage = await agentCtx.newPage();
+ await signIn(agentPage, agentEmail);
+ const inviteRow = agentPage.locator('.invite-row', { hasText: familyName });
+ const inviteVisible = await inviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Agent: sees pending invite from owner on sign-in', inviteVisible);
+
+ await inviteRow.locator('.btn-small', { hasText: 'Accept' }).click();
+ const agentOnMainApp = await agentPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Agent: accepting invite lands on the main app for that family', agentOnMainApp);
+
+ await agentPage.locator('nav button[aria-label="Assets"]').click();
+ await agentPage.waitForTimeout(400);
+ await agentPage.locator('.field:has-text("Description") input').fill('Agent-added asset');
+ await agentPage.locator('.field:has-text("Estimated value") input').fill('50000');
+ await agentPage.locator('button.btn-primary', { hasText: 'Add asset' }).click();
+ const assetAddedByAgent = await agentPage.locator('.asset-row', { hasText: 'Agent-added asset' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Agent: can add an asset to the family estate', assetAddedByAgent);
+
+ // Owner should see the agent-added asset too (shared, live data — not per-device)
+ await ownerPage.locator('nav button[aria-label="Assets"]').click();
+ const ownerSeesAgentAsset = await ownerPage.locator('.asset-row', { hasText: 'Agent-added asset' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Owner: sees the asset the agent just added (shared family data)', ownerSeesAgentAsset);
+
+ // ── Agent tries the Death Trigger — should be visibly restricted ──
+ await agentPage.locator('nav button[aria-label="Trigger"]').click();
+ const agentRestrictionVisible = await agentPage.locator('.agent-restriction').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Agent: sees explicit "owner-only to fire" restriction notice', agentRestrictionVisible);
+
+ // Fill everything needed and confirm the fire button itself won't work for the agent
+ const attestorInputs = agentPage.locator('.attestor-row input');
+ await attestorInputs.nth(0).fill('Attestor A');
+ await agentPage.locator('.attestor-row .confirm-btn').nth(0).click();
+ await agentPage.waitForTimeout(500);
+ await attestorInputs.nth(1).fill('Attestor B');
+ await agentPage.locator('.attestor-row .confirm-btn').nth(1).click();
+ await agentPage.waitForTimeout(500);
+ await agentPage.locator('.field:has-text("Date of death") input').fill('2026-08-13');
+ await agentPage.locator('.field:has-text("Death certificate reference") input').fill('DC-TEST-999');
+ await agentPage.waitForTimeout(500);
+ const fireBtnDisabledForAgent = await agentPage.locator('button.btn-danger-solid').isDisabled();
+ record('Agent: fire-trigger button stays disabled even with all fields filled (role check)', fireBtnDisabledForAgent);
+
+ // ── Owner fires it — should work, enforced by RLS as role=owner ──
+ await ownerPage.locator('nav button[aria-label="Trigger"]').click();
+ await ownerPage.waitForTimeout(1000);
+ const ownerAttestorInputs = ownerPage.locator('.attestor-row input');
+ const attestorCount = await ownerAttestorInputs.count();
+ record('Owner: sees the same attestor data the agent entered (shared)', attestorCount >= 2 && (await ownerAttestorInputs.nth(0).inputValue()) === 'Attestor A');
+
+ await ownerPage.waitForFunction(() => {
+ const btn = document.querySelector('button.btn-danger-solid');
+ return btn && !btn.disabled;
+ }, { timeout: 10000 }).catch(() => {});
+ const ownerFireBtnEnabled = await ownerPage.locator('button.btn-danger-solid').isEnabled();
+ record('Owner: fire-trigger button is enabled for the owner role', ownerFireBtnEnabled);
+
+ if (ownerFireBtnEnabled) {
+ await ownerPage.locator('button.btn-danger-solid').click();
+ const triggeredBannerVisible = await ownerPage.locator('.triggered-banner').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
+ record('Owner: successfully fires the death trigger', triggeredBannerVisible);
+ }
+
+ await browser.close();
+ const passCount = results.filter(r => r.pass).length;
+ const failCount = results.length - passCount;
+ console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
+ if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
+ process.exit(failCount > 0 ? 1 : 0);
+}
+main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
diff --git a/e2e-smoke-authed.cjs b/e2e-smoke-authed.cjs
new file mode 100644
index 0000000..1b1ccaf
--- /dev/null
+++ b/e2e-smoke-authed.cjs
@@ -0,0 +1,61 @@
+// Post-auth-gate smoke test: signs in as the existing confirmed owner test
+// account, ensures a family exists, then does a light pass over every tab to
+// confirm nothing broke in the Supabase migration. The detailed pre-auth
+// 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 — tracked as a follow-up, not done here.
+const { chromium } = require('playwright');
+const BASE = 'https://moslem04.falahos.my/';
+const results = [];
+const consoleErrors = [];
+function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
+
+const TABS = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Claims (H2)', 'Family', 'Settings'];
+
+async function main() {
+ const browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
+ page.on('pageerror', e => consoleErrors.push(e.message));
+
+ await page.goto(BASE, { waitUntil: 'networkidle' });
+ await page.locator('.field:has-text("Email") input').fill('nurfalah.e2etest.owner@gmail.com');
+ await page.locator('.field:has-text("Password") input').fill('TestPassword123!');
+ await page.locator('button.btn-primary', { hasText: 'Sign in' }).click();
+ await page.waitForTimeout(1500);
+
+ // Land on switcher (multiple families from earlier test runs) — pick the first.
+ const onSwitcher = await page.locator('.switcher-screen').isVisible().catch(() => false);
+ if (onSwitcher) {
+ await page.locator('.family-row').first().click();
+ await page.waitForTimeout(1000);
+ }
+ const onMainApp = await page.locator('nav button[aria-label="Coverage"]').isVisible().catch(() => false);
+ record('Signed-in owner reaches the main app', onMainApp);
+
+ for (const label of TABS) {
+ await page.locator(`nav button[aria-label="${label}"]`).click();
+ await page.waitForTimeout(500);
+ const infoBtn = page.locator('.module-header .info-btn');
+ const hasInfoBtn = await infoBtn.isVisible().catch(() => false);
+ record(`"${label}": tab renders without crashing`, true); // reaching here without a page crash is the real check
+ if (hasInfoBtn) {
+ await infoBtn.click();
+ await page.waitForTimeout(200);
+ const panelOpen = await page.locator('.info-panel').isVisible().catch(() => false);
+ record(`"${label}": info panel still opens`, panelOpen);
+ await infoBtn.click();
+ }
+ }
+
+ record('No uncaught JS console errors across full authed tab sweep', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || '));
+
+ await browser.close();
+ const passCount = results.filter(r => r.pass).length;
+ const failCount = results.length - passCount;
+ console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
+ if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
+ process.exit(failCount > 0 ? 1 : 0);
+}
+main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
diff --git a/package-lock.json b/package-lock.json
index 91dfded..1fef104 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "nur-falah-prevention",
"version": "0.1.0",
"dependencies": {
+ "@supabase/supabase-js": "^2.112.3",
"vite-plugin-pwa": "^0.21.0",
"workbox-precaching": "^7.3.0"
},
@@ -2421,6 +2422,98 @@
"win32"
]
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
+ "integrity": "sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.3.tgz",
+ "integrity": "sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.3.tgz",
+ "integrity": "sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.3.tgz",
+ "integrity": "sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "0.4.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.3.tgz",
+ "integrity": "sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.112.3",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.3.tgz",
+ "integrity": "sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.112.3",
+ "@supabase/functions-js": "2.112.3",
+ "@supabase/postgrest-js": "2.112.3",
+ "@supabase/realtime-js": "2.112.3",
+ "@supabase/storage-js": "2.112.3"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz",
@@ -3678,6 +3771,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -5190,6 +5292,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
diff --git a/package.json b/package.json
index 38ef738..7237488 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"vite": "^6.2.0"
},
"dependencies": {
+ "@supabase/supabase-js": "^2.112.3",
"vite-plugin-pwa": "^0.21.0",
"workbox-precaching": "^7.3.0"
}
diff --git a/src/App.svelte b/src/App.svelte
index 2b04df9..0aab5fa 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -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 @@
Your data is stored locally on this device. Nothing is sent to a third party.
+Signed in as {currentSession.user.email}.
At least {threshold} confirmations plus a death certificate reference are required to fire the trigger — no single person can trigger this alone, and it cannot fire silently.
- {#each attestors as a, i} + {#each attestors as a}{error}
{/if}{error}
{/if} + {:else if myRole === 'agent'} +{error}
{/if} + + {#if loading} +Loading your families…
+ {:else} + {#if invites.length} +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.
+ + +Dedicate a specific asset to your family in perpetuity.
- - + + - + {#if !equalSplit}