// Settings screen. (() => { const { Card, Badge, Avatar, Switch } = window.TechyFuelOSDesignSystem_be0222; const NAV_ITEMS = [ ['building-2', 'Agency branding'], ['users', 'Team permissions'], ['bell', 'Email notifications'], ['plug', 'Integrations'], ['key-round', 'API access'], ]; // The full set of navigation tabs an admin can grant/revoke per member. // Ids must match the ROUTES keys in index.html and the TF_NAV ids in AppShell. // 'settings' is intentionally omitted — admins always keep Settings, and we // don't let anyone hide the Super Admin tab from here. const CONTROLLABLE_TABS = [ ['dashboard', 'Dashboard'], ['tasks', 'Tasks'], ['projects', 'Projects'], ['calendar', 'Calendar'], ['crm', 'Client CRM'], ['pipeline', 'Sales pipeline'], ['portal', 'Client portal'], ['content', 'Content'], ['ads', 'Meta Ads'], ['email', 'Email'], ['finance', 'Finance'], ['reports', 'Reports'], ['docs', 'Docs & Files'], ['files', 'Files'], ['team', 'Team'], ['chat', 'Team Chat'], ['automations', 'Automations'], ['activity', 'Activity Log'], ['integrations', 'Integrations'], ['workspace', 'Workspace'], ]; // Default tabs a plain 'member' sees when no custom list is stored — must match // MEMBER_DEFAULT_TABS in index.html so the editor's initial state matches what // the member actually sees. const MEMBER_DEFAULT_VISIBLE = ['dashboard', 'tasks', 'projects', 'calendar', 'chat']; const STORAGE_KEY = 'tf_settings'; function loadSaved() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; } } // Returns whether the save actually landed — localStorage throws // QuotaExceededError when a stored logo/signature image (base64, ~33% // bigger than the original file) pushes the origin over its ~5-10MB cap, // and callers need to know so they can tell the user instead of the // change silently vanishing on next reload. function saveSettings(obj) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); window.dispatchEvent(new Event('tf-settings-saved')); return true; } catch { return false; } } // Downscales an uploaded image to a max dimension and re-encodes as PNG // (keeps transparency, unlike JPEG) before it ever touches localStorage — // a phone-camera-sized PNG can be 5-10MB, which alone blows the ~5-10MB // per-origin quota and made every logo/signature upload silently fail. function resizeImageToDataUrl(file, maxDim) { return new Promise((resolve, reject) => { if (!file.type.startsWith('image/')) { reject(new Error('Please choose an image file.')); return; } const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read that file.')); reader.onload = () => { const img = new Image(); img.onerror = () => reject(new Error('Could not read that image.')); img.onload = () => { let { width, height } = img; if (width > maxDim || height > maxDim) { const scale = maxDim / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/png')); }; img.src = reader.result; }; reader.readAsDataURL(file); }); } /* ── Notification row ─────────────────────────────────────────── */ function NotifRow({ title, desc, field, notif, setNotif }) { return (
{title}
{desc}
{ const next = { ...notif, [field]: v }; setNotif(next); const saved = loadSaved(); saveSettings({ ...saved, notifications: next }); }} />
); } /* ── Team Permissions Tab ─────────────────────────────────────── */ function TeamPermissionsTab({ team, setTeam, inputStyle, showToast, ROLE_COLORS }) { const ROLES = ['admin', 'manager', 'member', 'viewer']; const [editingRole, setEditingRole] = React.useState(null); const [editingTabs, setEditingTabs] = React.useState(null); // member id whose tab panel is open const [savingTabs, setSavingTabs] = React.useState(false); const [inviteOpen, setInviteOpen] = React.useState(false); const [inviteName, setInviteName] = React.useState(''); const [inviteEmail, setInviteEmail] = React.useState(''); const [inviteRole, setInviteRole] = React.useState('member'); const [saving, setSaving] = React.useState(false); async function handleRoleChange(member, newRole) { if (!window.API) return; const prev = team; setTeam(team.map(m => m.id === member.id ? { ...m, role: newRole } : m)); setEditingRole(null); try { await window.API.updateTeamMember(member.id, { role: newRole }); showToast('Role updated!'); } catch { setTeam(prev); showToast('Failed to update role'); } } // Which tab ids this member can currently SEE, derived the same way index.html // does: an explicit hidden_tabs list wins; otherwise the role default. function visibleTabsFor(member) { const hidden = Array.isArray(member.hidden_tabs) ? member.hidden_tabs : null; if (hidden) { return CONTROLLABLE_TABS.map(t => t[0]).filter(id => hidden.indexOf(id) === -1); } if ((member.role || 'member') === 'member') return MEMBER_DEFAULT_VISIBLE.slice(); return CONTROLLABLE_TABS.map(t => t[0]); // admin/owner/manager see all } // Toggle one tab for a member and persist the resulting hidden_tabs list. // Computes the next list from the freshest team state (not the closed-over // member) so quick successive toggles don't build on a stale hidden_tabs. async function toggleTab(member, tabId) { if (!window.API) return; const current = team.find(m => m.id === member.id) || member; const visible = new Set(visibleTabsFor(current)); if (visible.has(tabId)) visible.delete(tabId); else visible.add(tabId); const hidden = CONTROLLABLE_TABS.map(t => t[0]).filter(id => !visible.has(id)); const prev = team; setTeam(team.map(m => m.id === member.id ? { ...m, hidden_tabs: hidden } : m)); setSavingTabs(true); try { await window.API.updateTeamMember(member.id, { hidden_tabs: hidden }); } catch { setTeam(prev); showToast('Failed to update tab access'); } setSavingTabs(false); } async function handleRemove(member) { if (!window.API) return; if (!confirm(`Remove ${member.name} from the team?`)) return; try { await window.API.updateTeamMember(member.id, { status: 'inactive' }); setTeam(team.filter(m => m.id !== member.id)); showToast(`${member.name} removed`); } catch { showToast('Failed to remove member'); } } async function handleInvite() { if (!inviteName.trim() || !inviteEmail.trim()) { showToast('Name and email required'); return; } if (!window.API) return; setSaving(true); try { // Step 1 - create the team_members row. Requires Owner/Admin; a Member or // Manager gets 403 here — that is the "unauthorized" message users saw. const { data, error: addErr } = await window.API.addTeamMember({ name: inviteName.trim(), email: inviteEmail.trim(), role: inviteRole, status: 'active' }); if (addErr) { showToast(addErr.message && /unauthor/i.test(addErr.message) ? 'Only an Owner or Admin can add members.' : (addErr.message || 'Could not add member.')); setSaving(false); return; } if (data) setTeam(prev => [...prev, data]); // Step 2 - create the invite — this emails it (if a mailbox is connected) AND // always returns a copyable join link. Uses the authed API client, so // no more unauthenticated /invite 401. let msg = 'Member added.'; try { const res = await window.API.createWorkspaceInvite({ email: inviteEmail.trim(), role: inviteRole }); if (res?.email_sent) { msg = 'Member added & invite email sent!'; } else if (res?.invite_link) { try { await navigator.clipboard.writeText(res.invite_link); } catch {} msg = 'Member added! Invite link copied — share it with them.'; } } catch {} showToast(msg); setInviteOpen(false); setInviteName(''); setInviteEmail(''); setInviteRole('member'); } catch { showToast('Failed to add member'); } setSaving(false); } const ROLE_BG = { admin: 'var(--violet-50)', manager: 'var(--blue-50)', member: 'var(--green-50)', viewer: 'var(--slate-100)' }; return (

Team permissions

Manage your team members and their access levels.

{/* Invite modal */} {inviteOpen && (

Add team member

setInviteName(e.target.value)} placeholder="e.g. Ahmed Khan" />
setInviteEmail(e.target.value)} placeholder="ahmed@agency.com" type="email" />
)} {team.length === 0 ? (
No team members yet. Invite someone!
) : (
{team.map((m, i) => { const visibleSet = new Set(visibleTabsFor(m)); const hiddenCount = CONTROLLABLE_TABS.length - visibleSet.size; return (
{m.name}
{m.email}
{/* Tab-access button */} {/* Role dropdown */}
{editingRole === m.id && (
{ROLES.map(r => (
handleRoleChange(m, r)} style={{ padding: '8px 14px', fontSize: 'var(--text-sm)', cursor: 'pointer', color: ROLE_COLORS[r] || 'var(--text-body)', fontWeight: m.role === r ? 'var(--fw-bold)' : 'var(--fw-medium)', textTransform: 'capitalize', background: m.role === r ? 'var(--slate-50)' : 'transparent' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'} onMouseLeave={e => e.currentTarget.style.background = m.role === r ? 'var(--slate-50)' : 'transparent'}> {r.charAt(0).toUpperCase() + r.slice(1)}
))}
)}
{/* Remove button */}
{/* Expandable tab-access checkboxes */} {editingTabs === m.id && (
Tick the tabs {m.name} can see in the sidebar. Unticked tabs are hidden and blocked. {(m.role === 'admin' || m.role === 'owner') && ' Admins always keep Settings.'}
{CONTROLLABLE_TABS.map(([id, label]) => { const on = visibleSet.has(id); return ( ); })}
)}
); })}
)}
); } /* ── Settings ─────────────────────────────────────────────────── */ function Settings() { useLucide(); const saved = loadSaved(); // On a phone the two-column layout collapses to one column; a sticky nav card // then floats OVER the content as you scroll (the "settings menu overlaps" // report). Only make it sticky on desktop. const [isMobile, setIsMobile] = React.useState(() => (typeof window !== 'undefined' ? window.innerWidth <= 860 : false)); React.useEffect(() => { const onR = () => setIsMobile(window.innerWidth <= 860); window.addEventListener('resize', onR); return () => window.removeEventListener('resize', onR); }, []); const [tab, setTab] = React.useState('Agency branding'); const [team, setTeam] = React.useState([]); const [agencyName, setAgencyName] = React.useState(saved.agencyName || ''); const [agencyEmail,setAgencyEmail]= React.useState(saved.agencyEmail || ''); const [logoUrl, setLogoUrl] = React.useState(saved.logoUrl || ''); const [tagline, setTagline] = React.useState(saved.tagline || ''); const [agencyPhone, setAgencyPhone] = React.useState(saved.agencyPhone || ''); const [agencyWebsite,setAgencyWebsite]= React.useState(saved.agencyWebsite || ''); const [agencyAddress,setAgencyAddress]= React.useState(saved.agencyAddress || ''); const [paymentAccount, setPaymentAccount] = React.useState(saved.paymentAccount || ''); const [paymentSwift, setPaymentSwift] = React.useState(saved.paymentSwift || ''); const [paymentPayoneer,setPaymentPayoneer]= React.useState(saved.paymentPayoneer || ''); const [signatureName, setSignatureName] = React.useState(saved.signatureName || ''); const [signatureTitle, setSignatureTitle] = React.useState(saved.signatureTitle || ''); const [signatureImageUrl, setSignatureImageUrl] = React.useState(saved.signatureImageUrl || ''); const [servicesLine, setServicesLine] = React.useState(saved.servicesLine || ''); const [saved2, setSaved2] = React.useState(false); const [toast, setToast] = React.useState(''); const logoInputRef = React.useRef(null); const signatureInputRef = React.useRef(null); function showToast(msg) { setToast(msg); setTimeout(() => setToast(''), 3000); } const defaultNotif = { approvals: true, deadlines: true, ai_alerts: true, weekly: false }; const [notif, setNotif] = React.useState({ ...defaultNotif, ...(saved.notifications || {}) }); const [integCreds, setIntegCreds] = React.useState(saved.integCreds || {}); const [integExpanded, setIntegExpanded] = React.useState(null); const [integDraft, setIntegDraft] = React.useState({}); const [apiKey] = React.useState(saved.apiKey || ('tf_live_' + Math.random().toString(36).slice(2, 18))); React.useEffect(() => { if (!window.API) return; (async () => { try { const { data } = await window.API.getTeam(); if (data) setTeam(data); } catch {} })(); const sk = loadSaved(); if (!sk.apiKey) saveSettings({ ...sk, apiKey }); }, []); // Auto-save branding fields shortly after each edit — otherwise navigating // to another screen before pressing "Save changes" remounts Settings and // silently drops everything typed since the last explicit save. React.useEffect(() => { const t = setTimeout(() => { const sk = loadSaved(); const ok = saveSettings({ ...sk, agencyName, agencyEmail, logoUrl, tagline, agencyPhone, agencyWebsite, agencyAddress, paymentAccount, paymentSwift, paymentPayoneer, signatureName, signatureTitle, signatureImageUrl, servicesLine, }); if (!ok) showToast('Could not save — try a smaller logo/signature image.'); }, 500); return () => clearTimeout(t); }, [agencyName, agencyEmail, logoUrl, tagline, agencyPhone, agencyWebsite, agencyAddress, paymentAccount, paymentSwift, paymentPayoneer, signatureName, signatureTitle, signatureImageUrl, servicesLine]); function handleSaveBranding() { const sk = loadSaved(); const ok = saveSettings({ ...sk, agencyName, agencyEmail, logoUrl, tagline, agencyPhone, agencyWebsite, agencyAddress, paymentAccount, paymentSwift, paymentPayoneer, signatureName, signatureTitle, signatureImageUrl, servicesLine, }); if (ok) { setSaved2(true); showToast('Branding saved!'); setTimeout(() => setSaved2(false), 2500); } else { showToast('Could not save — try a smaller logo/signature image.'); } } async function handleLogoUpload(e) { const file = e.target.files[0]; if (!file) return; try { const dataUrl = await resizeImageToDataUrl(file, 320); setLogoUrl(dataUrl); showToast('Logo ready — click Save changes to apply'); } catch (err) { showToast(err.message || 'Could not process that image'); } finally { e.target.value = ''; } } async function handleSignatureUpload(e) { const file = e.target.files[0]; if (!file) return; try { const dataUrl = await resizeImageToDataUrl(file, 260); setSignatureImageUrl(dataUrl); showToast('Signature ready — click Save changes to apply'); } catch (err) { showToast(err.message || 'Could not process that image'); } finally { e.target.value = ''; } } function openInteg(it) { setIntegDraft({ ...(integCreds[it.key] || {}) }); setIntegExpanded(it.key); } function saveInteg(key) { const cleaned = Object.fromEntries(Object.entries(integDraft).filter(([, v]) => v.trim())); const next = { ...integCreds, [key]: Object.keys(cleaned).length > 0 ? cleaned : undefined }; if (!Object.keys(cleaned).length) delete next[key]; setIntegCreds(next); const sk = loadSaved(); saveSettings({ ...sk, integCreds: next }); setIntegExpanded(null); showToast(Object.keys(cleaned).length > 0 ? 'Integration saved!' : 'Integration disconnected'); } function disconnectInteg(key) { const next = { ...integCreds }; delete next[key]; setIntegCreds(next); const sk = loadSaved(); saveSettings({ ...sk, integCreds: next }); setIntegExpanded(null); showToast('Integration disconnected'); } const inputStyle = { width: '100%', height: 36, padding: '0 10px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--text-strong)', background: 'var(--slate-0)', boxSizing: 'border-box', outline: 'none', }; const INTEG_LIST = [ { key: 'meta', name: 'Meta Business', icon: 'megaphone', color: 'var(--blue-600)', fields: [{ id: 'accessToken', label: 'Access Token', placeholder: 'EAAxxxxxxx...', type: 'password' }, { id: 'accountId', label: 'Ad Account ID', placeholder: 'act_123456789' }] }, { key: 'google', name: 'Google Ads', icon: 'badge-dollar-sign', color: 'var(--green-600)', fields: [{ id: 'customerId', label: 'Customer ID', placeholder: '123-456-7890' }, { id: 'developerToken', label: 'Developer Token', placeholder: 'ABcDef...', type: 'password' }] }, { key: 'slack', name: 'Slack', icon: 'message-square', color: 'var(--violet-500)', fields: [{ id: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://hooks.slack.com/services/...' }] }, { key: 'stripe', name: 'Stripe', icon: 'credit-card', color: 'var(--blue-500)', fields: [{ id: 'publishableKey', label: 'Publishable Key', placeholder: 'pk_live_...' }, { id: 'secretKey', label: 'Secret Key', placeholder: 'sk_live_...', type: 'password' }] }, ]; const ROLE_COLORS = { admin: 'var(--violet-600)', manager: 'var(--blue-600)', member: 'var(--green-700)', viewer: 'var(--slate-500)' }; return (

Settings

{/* Toast notification */} {toast && (
{toast}
)}
{/* Sidebar nav */}
{NAV_ITEMS.map(([ic, label]) => { const act = tab === label; return (
setTab(label)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 'var(--radius-md)', background: act ? 'var(--blue-50)' : 'transparent', color: act ? 'var(--blue-700)' : 'var(--text-body)', fontSize: 'var(--text-sm)', fontWeight: act ? 'var(--fw-semibold)' : 'var(--fw-medium)', cursor: 'pointer', transition: 'background 0.12s' }}> {label}
); })}
{/* Content panel */}
{/* ── Agency branding ── */} {tab === 'Agency branding' && (

Agency branding

Appears across the client portal and reports.

{logoUrl ? Logo : }
{logoUrl && }
setAgencyName(e.target.value)} placeholder="Your agency name" />
setTagline(e.target.value)} placeholder="Digital Solutions & Growth Partner" />
setAgencyEmail(e.target.value)} placeholder="support@agency.com" type="email" />
setAgencyPhone(e.target.value)} placeholder="+92 300 1234567" />
setAgencyWebsite(e.target.value)} placeholder="www.techyfuel.com" />
setAgencyAddress(e.target.value)} placeholder="Office address" />
setServicesLine(e.target.value)} placeholder="Digital Marketing • Web Development • UI/UX Design • Branding • SEO" />

Invoice details

Shown on every exported invoice PDF — payment method and the signature block.

setPaymentAccount(e.target.value)} placeholder="PK00 XXXX 0000 1111 2222" />
setPaymentSwift(e.target.value)} placeholder="ABCDPKKA" />
setPaymentPayoneer(e.target.value)} placeholder="payoneer@agency.com" />
setSignatureName(e.target.value)} placeholder="Zain Ahmed" />
setSignatureTitle(e.target.value)} placeholder="CEO" />
{signatureImageUrl && Signature} {signatureImageUrl && }
)} {/* ── Team permissions ── */} {tab === 'Team permissions' && ( )} {/* ── Notifications ── */} {tab === 'Email notifications' && (

In-App Notifications

Bell icon notifications inside TechyFuel OS.

Email Notifications

Emails sent to your registered address. Changes save automatically.

)} {/* ── Integrations ── */} {tab === 'Integrations' && (

Integrations

Connect your tools by entering API credentials. Keys are stored locally in your browser.

{INTEG_LIST.map(it => { const creds = integCreds[it.key]; const connected = creds && Object.values(creds).some(v => v); const expanded = integExpanded === it.key; return (
{/* Header row */}
{it.name}
{connected ? '● Connected' : 'Not configured'}
{/* Expanded credential form */} {expanded && (
{it.fields.map(f => (
setIntegDraft(d => ({ ...d, [f.id]: e.target.value }))} style={{ ...inputStyle, fontFamily: f.type === 'password' ? 'monospace' : 'var(--font-sans)' }} />
))}
{connected && ( )}
)}
); })}
)} {/* ── API access ── */} {tab === 'API access' && (

API access

Use this key to access TechyFuel OS data from external tools.

Keep this key secret. Do not share it publicly or commit it to code.
)}
); } Object.assign(window, { Settings }); })();