diff --git a/brand-assets/README-for-VSClaude.md b/brand-assets/README-for-VSClaude.md new file mode 100644 index 0000000..db13ab0 --- /dev/null +++ b/brand-assets/README-for-VSClaude.md @@ -0,0 +1,24 @@ +# Biz Connect — master assets for VS Claude + +Three masters, exactly as requested. Regenerate/wire everything from these. + +## 1. App icon → app-icon-1024.png +- 1024×1024, full-bleed solid brand-blue square (no baked-in rounded corners), C mark + centred with safe padding. Use as the single source to regenerate: + Windows `.ico` (multi-size — fixes the tiny/blurry taskbar icon), PWA icons + (192, 512, maskable), apple-touch-icon, favicon, and the Android/iOS mobile icons. + +## 2. Splash → splash-2732-dark.png (primary) · splash-2732-light.png (optional) +- 2732×2732. Dark = brand blue (use this everywhere by default). Light = white background + variant if you support a light theme. Use for the desktop/web launch and the mobile + native splash. (Native launch screens should stay static — no animation.) + +## 3. Loader → animated SVG (pick per context) +- loader-ring.svg default everyday spinner, for LIGHT backgrounds (blue track + yellow arc) +- loader-orbit.svg branded spinner (the C with a circling dot), LIGHT backgrounds +- loader-orbit-dark.svg same branded spinner for DARK/overlay backgrounds (white C) +Recommended: use loader-ring.svg as the standard "Loading…" and in-app spinner; use +loader-orbit(-dark).svg for the hero "Connecting…" moment. SVG is animated, tiny, scalable. + +## Brand +Blue #1F3B73 · Blue-dark #16294F · Yellow #FFC708 · (deeper gold #E0AC00 for "Connect" on white) diff --git a/brand-assets/app-icon-1024.png b/brand-assets/app-icon-1024.png new file mode 100644 index 0000000..6532810 Binary files /dev/null and b/brand-assets/app-icon-1024.png differ diff --git a/brand-assets/loader-orbit-dark.svg b/brand-assets/loader-orbit-dark.svg new file mode 100644 index 0000000..ac7007c --- /dev/null +++ b/brand-assets/loader-orbit-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/brand-assets/loader-orbit.svg b/brand-assets/loader-orbit.svg new file mode 100644 index 0000000..d9a1083 --- /dev/null +++ b/brand-assets/loader-orbit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/brand-assets/loader-ring.svg b/brand-assets/loader-ring.svg new file mode 100644 index 0000000..72ce6e9 --- /dev/null +++ b/brand-assets/loader-ring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand-assets/splash-2732-dark.png b/brand-assets/splash-2732-dark.png new file mode 100644 index 0000000..f91c6e5 Binary files /dev/null and b/brand-assets/splash-2732-dark.png differ diff --git a/brand-assets/splash-2732-light.png b/brand-assets/splash-2732-light.png new file mode 100644 index 0000000..6c8185b Binary files /dev/null and b/brand-assets/splash-2732-light.png differ diff --git a/server/db.js b/server/db.js index 7104bba..a267a97 100644 --- a/server/db.js +++ b/server/db.js @@ -218,6 +218,11 @@ try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } // User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.) try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ } +// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or +// their mobile number, so this — not the typed login identifier — is the stable identity key. +// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge). +try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ } +try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ } db.exec(` CREATE TABLE IF NOT EXISTS polls ( id TEXT PRIMARY KEY, diff --git a/server/public/home.html b/server/public/home.html index e18f2d6..e1c3f5a 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -736,7 +736,7 @@ - diff --git a/server/repos.js b/server/repos.js index e4a23e7..2fc92dc 100644 --- a/server/repos.js +++ b/server/repos.js @@ -24,6 +24,9 @@ const users = { byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id), byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), + // Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2). + byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined), + setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id), listByTenant: (tenantId) => db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId), inTenant: (id, tenantId) => @@ -43,6 +46,49 @@ const users = { setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id), setStatus: (id, status) => db.prepare('UPDATE users SET status=? WHERE id=?').run(status, id), remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), + // Fold one account (fromId) into another (intoId): reassign everything the merged-away user + // owns/authored to the survivor, then delete the empty row. Used when a person turns out to + // have two Biz Connect accounts — one per login identifier (email vs mobile) — for the same + // BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched. + // For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest + // (the survivor already has that membership/reaction/vote). + mergeInto: (fromId, intoId) => { + if (!fromId || !intoId || fromId === intoId) return; + const run = (sql, ...a) => db.prepare(sql).run(...a); + db.exec('BEGIN'); + try { + run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId); + run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId); + run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId); + run('DELETE FROM message_reactions WHERE user_id=?', fromId); + run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId); + run('DELETE FROM conversation_members WHERE user_id=?', fromId); + run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId); + run('DELETE FROM poll_votes WHERE user_id=?', fromId); + run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId); + run('DELETE FROM favorites WHERE user_id=?', fromId); + // A DM favourite pointing AT the merged-away user should now point at the survivor. + run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId); + run("DELETE FROM favorites WHERE target='dm:'||?", fromId); + run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId); + run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId); + run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId); + run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId); + run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId); + run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId); + run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId); + run('DELETE FROM device_tokens WHERE user_id=?', fromId); + run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId); + // Drop the merged-away account's auth so a stale token can't resurrect the empty row. + run('DELETE FROM sessions_auth WHERE user_id=?', fromId); + run('DELETE FROM refresh_tokens WHERE user_id=?', fromId); + run('DELETE FROM users WHERE id=?', fromId); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } + }, }; const authSessions = { diff --git a/server/routes.js b/server/routes.js index 9f8d3c6..23e18d5 100644 --- a/server/routes.js +++ b/server/routes.js @@ -130,16 +130,42 @@ route('POST', '/api/mfa/enable', async (req, res) => { const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; - const existing = R.users.byEmail(email); + const bizId = bz.bizgazeUserId || null; + + // Identity is keyed on the BizGaze person-id, NOT the typed identifier: signing in with a + // mobile number and with an email both return the same person-id, so both resolve to one + // Biz Connect account (#2 — no more duplicate contacts for the same person). + let existing = R.users.byBizgazeId(bizId); + // Legacy account created before the person-id was stored: fall back to the typed identifier, + // but only if it isn't already claimed by a different person, then stamp the id on below. + if (!existing) { + const byMail = R.users.byEmail(email); + if (byMail && (!byMail.bizgaze_user_id || byMail.bizgaze_user_id === bizId)) existing = byMail; + } + if (!existing) { const team = R.teams.first() || R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); + if (bizId) R.users.setBizgazeId(id, bizId); if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); return R.users.byId(id); } - // BizGaze is the source of truth: keep name + avatar + role in sync on each login. + // Retroactive merge: if this same identifier already has its OWN legacy account (a separate + // row created before person-id keying — e.g. the person used email before and is now signing + // in with their mobile), fold that duplicate's history into the canonical account. BizGaze just + // proved this identifier belongs to this person, so the merge is safe. + if (bizId) { + const dup = R.users.byEmail(email); + if (dup && dup.id !== existing.id && (!dup.bizgaze_user_id || dup.bizgaze_user_id === bizId)) { + R.users.mergeInto(dup.id, existing.id); + audit({ team_id: existing.team_id, user_id: existing.id, user_email: email, action: 'account_merged', detail: 'folded duplicate ' + dup.id }); + } + } + // BizGaze is the source of truth: keep the person-id + name + avatar + role in sync each login. + // Stamping the id links legacy rows so the person's other identifier converges here next time. + if (bizId && existing.bizgaze_user_id !== bizId) R.users.setBizgazeId(existing.id, bizId); if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl); if (existing.role !== role) R.users.setRole(existing.id, role);