feat(auth): merge mobile+email logins into one account via BizGaze person-id (#2)
Provisioning now keys the Biz Connect account on bz.bizgazeUserId (the same value whether the person signs in with email or mobile) instead of the typed identifier, so both logins resolve to a single contact. Legacy rows get the person-id stamped on next login; an existing duplicate created under the same identifier is folded in via a transactional users.mergeInto() that reassigns all messages/memberships/reactions/votes/favorites/ownership to the survivor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="64" height="64" role="img" aria-label="Loading">
|
||||
<circle cx="50" cy="50" r="34" fill="none" stroke="#ffffff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||
<g><circle cx="84" cy="50" r="7" fill="#FFC708"/>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.1s" repeatCount="indefinite"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="64" height="64" role="img" aria-label="Loading">
|
||||
<circle cx="50" cy="50" r="34" fill="none" stroke="#1F3B73" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||
<g><circle cx="84" cy="50" r="7" fill="#FFC708"/>
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.1s" repeatCount="indefinite"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50" height="50" role="img" aria-label="Loading">
|
||||
<circle cx="25" cy="25" r="20" fill="none" stroke="#1F3B73" stroke-opacity="0.16" stroke-width="5"/>
|
||||
<path fill="none" stroke="#FFC708" stroke-width="5" stroke-linecap="round" d="M25 5 A20 20 0 0 1 45 25">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.8s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -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,
|
||||
|
||||
@@ -736,7 +736,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-02-batch25';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-02-batch26';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+28
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user