150 lines
6.9 KiB
React
150 lines
6.9 KiB
React
|
|
// Account Settings modal — self-service for the currently-signed-in user.
|
||
|
|
// Change display name + password. Username and role are admin-only.
|
||
|
|
|
||
|
|
function AccountSettingsModal({ onClose }) {
|
||
|
|
const me = window.ZAMPP_DATA?.ME || {};
|
||
|
|
const [tab, setTab] = React.useState('profile');
|
||
|
|
const [displayName, setDisplayName] = React.useState(me.display_name || me.name || '');
|
||
|
|
const [savingName, setSavingName] = React.useState(false);
|
||
|
|
const [nameMsg, setNameMsg] = React.useState(null);
|
||
|
|
|
||
|
|
const [curPw, setCurPw] = React.useState('');
|
||
|
|
const [newPw, setNewPw] = React.useState('');
|
||
|
|
const [newPw2, setNewPw2] = React.useState('');
|
||
|
|
const [savingPw, setSavingPw] = React.useState(false);
|
||
|
|
const [pwMsg, setPwMsg] = React.useState(null);
|
||
|
|
const [showNew, setShowNew] = React.useState(false);
|
||
|
|
|
||
|
|
const saveName = async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setNameMsg(null);
|
||
|
|
if (!displayName.trim()) { setNameMsg({ ok: false, text: 'Display name cannot be empty' }); return; }
|
||
|
|
setSavingName(true);
|
||
|
|
try {
|
||
|
|
const r = await window.ZAMPP_API.fetch('/auth/me', {
|
||
|
|
method: 'PATCH',
|
||
|
|
body: JSON.stringify({ display_name: displayName.trim() }),
|
||
|
|
});
|
||
|
|
// refresh ME cache
|
||
|
|
if (window.ZAMPP_DATA) {
|
||
|
|
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), display_name: r.display_name, name: r.display_name };
|
||
|
|
}
|
||
|
|
setNameMsg({ ok: true, text: 'Display name updated' });
|
||
|
|
} catch (err) {
|
||
|
|
setNameMsg({ ok: false, text: err.message || 'Update failed' });
|
||
|
|
} finally { setSavingName(false); }
|
||
|
|
};
|
||
|
|
|
||
|
|
const savePw = async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setPwMsg(null);
|
||
|
|
if (newPw !== newPw2) { setPwMsg({ ok: false, text: 'New passwords do not match' }); return; }
|
||
|
|
if (newPw.length < 8) { setPwMsg({ ok: false, text: 'Password must be at least 8 characters' }); return; }
|
||
|
|
setSavingPw(true);
|
||
|
|
try {
|
||
|
|
await window.ZAMPP_API.fetch('/auth/password', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ current_password: curPw, new_password: newPw }),
|
||
|
|
});
|
||
|
|
setPwMsg({ ok: true, text: 'Password updated — all other sessions signed out' });
|
||
|
|
setCurPw(''); setNewPw(''); setNewPw2('');
|
||
|
|
} catch (err) {
|
||
|
|
setPwMsg({ ok: false, text: err.message || 'Update failed' });
|
||
|
|
} finally { setSavingPw(false); }
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="modal-backdrop" onClick={onClose}>
|
||
|
|
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
|
||
|
|
<div className="modal-head">
|
||
|
|
<div>
|
||
|
|
<div style={{ fontWeight: 600 }}>Account settings</div>
|
||
|
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
||
|
|
{me.username || '—'} · {me.role || '—'}{me.is_client ? ' · client' : ''}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ display: 'flex', gap: 4, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
||
|
|
{[
|
||
|
|
{ id: 'profile', label: 'Profile' },
|
||
|
|
{ id: 'password', label: 'Password' },
|
||
|
|
].map(t => (
|
||
|
|
<button
|
||
|
|
key={t.id}
|
||
|
|
className="btn ghost sm"
|
||
|
|
onClick={() => setTab(t.id)}
|
||
|
|
style={{
|
||
|
|
background: tab === t.id ? 'var(--accent-soft)' : 'transparent',
|
||
|
|
color: tab === t.id ? 'var(--accent-text)' : 'var(--text-2)',
|
||
|
|
border: 0, borderBottom: '2px solid ' + (tab === t.id ? 'var(--accent)' : 'transparent'),
|
||
|
|
borderRadius: 0,
|
||
|
|
}}
|
||
|
|
>{t.label}</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ padding: 16 }}>
|
||
|
|
{tab === 'profile' && (
|
||
|
|
<form onSubmit={saveName} autoComplete="off">
|
||
|
|
<div className="field">
|
||
|
|
<label className="field-label">Username</label>
|
||
|
|
<input className="field-input mono" value={me.username || ''} readOnly />
|
||
|
|
</div>
|
||
|
|
<div className="field">
|
||
|
|
<label className="field-label">Display name</label>
|
||
|
|
<input className="field-input" value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={120} />
|
||
|
|
</div>
|
||
|
|
{nameMsg && (
|
||
|
|
<div style={{ fontSize: 12, color: nameMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
|
||
|
|
{nameMsg.text}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
|
||
|
|
<button type="submit" className="btn primary sm" disabled={savingName}>{savingName ? 'Saving…' : 'Save'}</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{tab === 'password' && (
|
||
|
|
<form onSubmit={savePw} autoComplete="off">
|
||
|
|
<div className="field">
|
||
|
|
<label className="field-label">Current password</label>
|
||
|
|
<input className="field-input" type="password" value={curPw} onChange={e => setCurPw(e.target.value)} autoComplete="current-password" required />
|
||
|
|
</div>
|
||
|
|
<div className="field">
|
||
|
|
<label className="field-label">New password</label>
|
||
|
|
<div style={{ position: 'relative' }}>
|
||
|
|
<input className="field-input" type={showNew ? 'text' : 'password'} value={newPw} onChange={e => setNewPw(e.target.value)} autoComplete="new-password" required minLength={8} style={{ paddingRight: 36 }} />
|
||
|
|
<button type="button" tabIndex={-1} className="icon-btn"
|
||
|
|
aria-label={showNew ? 'Hide password' : 'Show password'}
|
||
|
|
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
|
||
|
|
onClick={() => setShowNew(s => !s)}>
|
||
|
|
<Icon name={showNew ? 'eye-off' : 'eye'} size={13} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
|
||
|
|
</div>
|
||
|
|
<div className="field">
|
||
|
|
<label className="field-label">Confirm new password</label>
|
||
|
|
<input className="field-input" type="password" value={newPw2} onChange={e => setNewPw2(e.target.value)} autoComplete="new-password" required minLength={8} />
|
||
|
|
</div>
|
||
|
|
{pwMsg && (
|
||
|
|
<div style={{ fontSize: 12, color: pwMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
|
||
|
|
{pwMsg.text}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
|
||
|
|
<button type="submit" className="btn primary sm" disabled={savingPw}>{savingPw ? 'Updating…' : 'Update password'}</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
window.AccountSettingsModal = AccountSettingsModal;
|