Add self-service profile email management
This commit is contained in:
@@ -6,6 +6,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type ProfileInfo = {
|
||||
username: string
|
||||
email?: string | null
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
@@ -66,6 +67,8 @@ const formatDate = (value?: string | null) => {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
||||
|
||||
const parseBrowser = (agent?: string | null) => {
|
||||
if (!agent) return 'Unknown'
|
||||
const value = agent.toLowerCase()
|
||||
@@ -81,6 +84,9 @@ export default function ProfilePage() {
|
||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailSaving, setEmailSaving] = useState(false)
|
||||
const [emailStatus, setEmailStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
@@ -124,6 +130,7 @@ export default function ProfilePage() {
|
||||
const user = data?.user ?? {}
|
||||
setProfile({
|
||||
username: user?.username ?? 'Unknown',
|
||||
email: user?.email ?? null,
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
@@ -133,6 +140,7 @@ export default function ProfilePage() {
|
||||
? user.password_provider
|
||||
: null,
|
||||
})
|
||||
setEmail(user?.email ?? '')
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
} catch (err) {
|
||||
@@ -200,6 +208,57 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveEmail = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const nextEmail = email.trim()
|
||||
setEmailStatus(null)
|
||||
if (nextEmail && !isValidEmail(nextEmail)) {
|
||||
setEmailStatus({ tone: 'error', message: 'Enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
setEmailSaving(true)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: nextEmail || null }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
let detail = 'Could not save your email address.'
|
||||
try {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) detail = payload.detail
|
||||
} catch {
|
||||
// Keep the plain fallback when the response is not JSON.
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
const data = await response.json()
|
||||
const savedEmail = typeof data?.email === 'string' ? data.email : ''
|
||||
setEmail(savedEmail)
|
||||
setProfile((current) => current ? { ...current, email: savedEmail || null } : current)
|
||||
setEmailStatus({
|
||||
tone: 'status',
|
||||
message: savedEmail
|
||||
? 'Contact email saved. Magent can now use it for account and issue updates.'
|
||||
: 'Contact email removed from your account.',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setEmailStatus({
|
||||
tone: 'error',
|
||||
message: err instanceof Error ? err.message : 'Could not save your email address.',
|
||||
})
|
||||
} finally {
|
||||
setEmailSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const authProvider = profile?.auth_provider ?? 'local'
|
||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||
@@ -279,6 +338,41 @@ export default function ProfilePage() {
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<div className="profile-quick-link-card profile-contact-card">
|
||||
<div>
|
||||
<h2>Contact email</h2>
|
||||
<p className="lede">
|
||||
Used for password recovery, invite messages, and updates about issues you report.
|
||||
</p>
|
||||
</div>
|
||||
<form className="profile-contact-form" onSubmit={saveEmail}>
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
{emailStatus ? (
|
||||
<div className={emailStatus.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
||||
{emailStatus.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={emailSaving || Boolean(email.trim() && !isValidEmail(email))}>
|
||||
{emailSaving ? 'Saving…' : 'Save email'}
|
||||
</button>
|
||||
{profile?.email ? (
|
||||
<button type="button" className="ghost-button" onClick={() => setEmail('')}>
|
||||
Clear field
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{canManageInvites ? (
|
||||
<div className="profile-quick-link-card">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user