// 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 (
{
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
setInviteOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 34, padding: '0 14px', background: 'var(--blue-600)', color: '#fff', border: 'none', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Invite member
Manage your team members and their access levels.
{/* Invite modal */}
{inviteOpen && (
Add team member
{saving ? 'Adding…' : 'Add member'}
setInviteOpen(false)} style={{ height: 38, padding: '0 16px', background: 'transparent', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', cursor: 'pointer', color: 'var(--text-body)' }}>Cancel
)}
{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 (
{/* Tab-access button */}
setEditingTabs(editingTabs === m.id ? null : m.id)}
title="Choose which tabs this member can see"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 28, padding: '0 10px', background: hiddenCount > 0 ? 'var(--amber-50)' : 'var(--slate-100)', color: hiddenCount > 0 ? 'var(--amber-700)' : 'var(--text-body)', border: 'none', borderRadius: 'var(--radius-full)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
{hiddenCount > 0 ? `${hiddenCount} hidden` : 'All tabs'}
{/* Role dropdown */}
setEditingRole(editingRole === m.id ? null : m.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 28, padding: '0 10px', background: ROLE_BG[m.role] || 'var(--slate-100)', color: ROLE_COLORS[m.role] || 'var(--text-body)', border: 'none', borderRadius: 'var(--radius-full)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer', textTransform: 'capitalize' }}>
{m.role || 'member'}
{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 */}
handleRemove(m)} title="Remove member" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, background: 'transparent', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', cursor: 'pointer', color: 'var(--text-subtle)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--red-50)'; e.currentTarget.style.color = 'var(--red-500)'; e.currentTarget.style.borderColor = 'var(--red-200)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-subtle)'; e.currentTarget.style.borderColor = 'var(--border-subtle)'; }}>
{/* Expandable tab-access checkboxes */}
{editingTabs === m.id && (
)}
);
})}
)}
);
}
/* ── 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
?
: }
logoInputRef.current && logoInputRef.current.click()} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, height: 34, padding: '0 13px', background: 'var(--slate-0)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', color: 'var(--text-body)', cursor: 'pointer' }}>
Upload logo
{logoUrl && { setLogoUrl(''); showToast('Logo removed'); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 26, padding: '0 10px', background: 'transparent', border: 'none', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', color: 'var(--red-500)', cursor: 'pointer' }}>
Remove
}
Services line (shown under the invoice header)
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.
Signature image (optional)
{signatureImageUrl &&
}
signatureInputRef.current && signatureInputRef.current.click()} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, height: 34, padding: '0 13px', background: 'var(--slate-0)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', color: 'var(--text-body)', cursor: 'pointer' }}>
Upload signature
{signatureImageUrl &&
{ setSignatureImageUrl(''); showToast('Signature removed'); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 26, padding: '0 10px', background: 'transparent', border: 'none', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', color: 'var(--red-500)', cursor: 'pointer' }}>
Remove
}
{saved2 ? 'Saved!' : 'Save changes'}
)}
{/* ── 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'}
{ if (expanded) { setIntegExpanded(null); } else { openInteg(it); } }} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 30, padding: '0 12px', background: connected ? 'transparent' : 'var(--blue-600)', color: connected ? 'var(--text-body)' : '#fff', border: connected ? '1px solid var(--border-default)' : 'none', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
{expanded ? 'Cancel' : (connected ? 'Edit' : 'Configure')}
{/* Expanded credential form */}
{expanded && (
{it.fields.map(f => (
{f.label}
setIntegDraft(d => ({ ...d, [f.id]: e.target.value }))}
style={{ ...inputStyle, fontFamily: f.type === 'password' ? 'monospace' : 'var(--font-sans)' }}
/>
))}
saveInteg(it.key)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 34, padding: '0 16px', background: 'var(--blue-600)', color: '#fff', border: 'none', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Save credentials
{connected && (
disconnectInteg(it.key)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 34, padding: '0 14px', background: 'transparent', color: 'var(--red-600)', border: '1px solid var(--red-200)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Disconnect
)}
)}
);
})}
)}
{/* ── API access ── */}
{tab === 'API access' && (
API access
Use this key to access TechyFuel OS data from external tools.
API Key
{ try { navigator.clipboard.writeText(apiKey); } catch {} }} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 36, padding: '0 14px', background: 'var(--slate-0)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', color: 'var(--text-body)', cursor: 'pointer' }}>
Copy
Keep this key secret. Do not share it publicly or commit it to code.
)}
);
}
Object.assign(window, { Settings });
})();