Stored as a one-way hash on the server. Once this dialog closes,
the password can't be retrieved — you'd have to issue a new one
via Reset pw.
Next steps
Share the credentials{' '}
with {created.name || 'the new user'} through a trusted channel —
Telegram, Signal, or in person. The system does not email them
automatically.
Tell them to sign in at{' '}
{window.location.origin}{' '}
with their email and the temporary password above.
Right after signing in, they should open{' '}
Settings → Account → Change password{' '}
and pick their own.
If they lose the temporary password before changing it,
come back here and click{' '}
Reset pw{' '}
on their row to generate a new one.
)}
);
}
// Cryptographically random password for the reset flow. Excludes ambiguous
// glyphs (0/O, 1/l/I) to reduce mistakes when the admin transcribes the
// password to Telegram or reads it out loud. crypto.getRandomValues is the
// only randomness source — Math.random is not safe for credentials.
function generateTempPassword(length = 16) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
const arr = new Uint32Array(length);
crypto.getRandomValues(arr);
let result = '';
for (let i = 0; i < length; i++) result += chars[arr[i] % chars.length];
return result;
}
// Two-step reset: confirm-with-context, then issue a freshly-generated
// password and surface it to the admin with copy-to-clipboard. Same UX
// shape as InviteStaffModal so the Team page feels coherent.
function ResetPasswordModal({ member, onClose, onDone }) {
const [mode, setMode] = React.useState('confirm'); // 'confirm' | 'success'
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState(null);
const [newPw, setNewPw] = React.useState('');
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!member) return;
setMode('confirm');
setSubmitting(false);
setError(null);
setNewPw('');
setCopied(false);
}, [member?.id]);
React.useEffect(() => {
if (!member) return;
function onKey(e) { if (e.key === 'Escape' && !submitting) handleClose(); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [member, submitting, mode]);
if (!member) return null;
function handleClose() {
if (mode === 'success' && onDone) onDone();
onClose();
}
async function confirm() {
setSubmitting(true);
setError(null);
const pw = generateTempPassword();
try {
await api.patch(`/api/v2/admin/users/${member.id}`, { password: pw });
setNewPw(pw);
setMode('success');
} catch (err) {
setError(err?.message || 'Reset failed');
} finally {
setSubmitting(false);
}
}
async function copyPassword() {
if (!newPw) return;
try {
await navigator.clipboard.writeText(newPw);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch (e) { /* clipboard blocked — code stays selectable */ }
}
return (
A new temporary password will be generated and shown next.
The user's existing password stops working immediately. Their
currently-active sessions are not revoked by this —
an admin token they've already minted keeps working until expiry
unless you also bump token_version.
{error &&
{error}
}
)}
{mode === 'success' && (
— Password reset
User
{member.email}
New temporary password
{newPw}
The previous password no longer works. Once this dialog closes,
this temporary password can't be retrieved — you'd have to
reset again.
Next steps
Share the new password{' '}
with {member.name || 'the user'} through a trusted channel —
Telegram, Signal, or in person.
Ask them to sign in at{' '}
{window.location.origin}{' '}
with this password and immediately set their own via{' '}
Settings → Account → Change password.
)}
);
}
// Single-step destructive confirm with the user's context (name, email, role)
// and an explicit warning about what happens. Uses .btn.danger so it reads as
// red/warn instead of looking like the same neutral confirm as Cancel.
function RemoveUserModal({ member, onClose, onRemoved }) {
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => {
if (!member) return;
setSubmitting(false);
setError(null);
}, [member?.id]);
React.useEffect(() => {
if (!member) return;
function onKey(e) { if (e.key === 'Escape' && !submitting) onClose(); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [member, submitting, onClose]);
if (!member) return null;
async function confirm() {
setSubmitting(true);
setError(null);
try {
await api.del(`/api/v2/admin/users/${member.id}`);
if (onRemoved) onRemoved();
onClose();
} catch (err) {
setError(err?.message || 'Remove failed');
setSubmitting(false);
}
}
return (
);
}
function SettingsView({ user, onSignOut }) {
const isAdmin = user?.role === 'admin';
// Only show the tabs the user can actually use. Team + Integrations are
// admin-only — the panes themselves render an "Admin only" empty state if
// a non-admin gets here through deep-linking, but pre-filtering keeps the
// sidebar from being a tease.
const allTabs = [
{ id: 'account', label: 'Account', ico: '◉', admin: false },
{ id: 'team', label: 'Team', ico: '◍', admin: true },
{ id: 'integrations', label: 'Integrations', ico: '◈', admin: true },
{ id: 'tommy', label: 'Tommy AI', ico: '◩', admin: false },
{ id: 'notifications', label: 'Notifications', ico: '⌖', admin: false },
];
const tabs = allTabs.filter(t => !t.admin || isAdmin);
const [tab, setTab] = React.useState('account');
const navRef = React.useRef(null);
// If a non-admin somehow lands on a hidden tab (stale localStorage, etc),
// bounce them back to Account.
React.useEffect(() => {
if (!tabs.some(t => t.id === tab)) setTab('account');
}, [tabs, tab]);
// On phones the .settings-nav is a horizontal scroller; tabs past the
// viewport edge are tappable but visually clipped (the user sees "TOM…").
// When the active tab changes, scroll it into view so the selection
// is always anchored regardless of which end of the strip it sits at.
React.useEffect(() => {
if (!navRef.current) return;
const active = navRef.current.querySelector('.settings-tab.active');
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
}
}, [tab]);
return (