1280 lines
47 KiB
JavaScript
1280 lines
47 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { usePage } from '@inertiajs/react'
|
|
import SettingsLayout from '../../Layouts/SettingsLayout'
|
|
import TextInput from '../../components/ui/TextInput'
|
|
import Textarea from '../../components/ui/Textarea'
|
|
import Button from '../../components/ui/Button'
|
|
import Toggle from '../../components/ui/Toggle'
|
|
import Select from '../../components/ui/Select'
|
|
import Modal from '../../components/ui/Modal'
|
|
import { RadioGroup } from '../../components/ui/Radio'
|
|
|
|
const SETTINGS_SECTIONS = [
|
|
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
|
|
{ key: 'account', label: 'Account', icon: 'fa-solid fa-id-badge', description: 'Username and email address.' },
|
|
{ key: 'personal', label: 'Personal', icon: 'fa-solid fa-address-card', description: 'Optional personal information.' },
|
|
{ key: 'notifications', label: 'Notifications', icon: 'fa-solid fa-bell', description: 'Manage notification preferences.' },
|
|
{ key: 'security', label: 'Security', icon: 'fa-solid fa-shield-halved', description: 'Password and account security.' },
|
|
{ key: 'danger', label: 'Danger Zone', icon: 'fa-solid fa-triangle-exclamation', description: 'Destructive account actions.' },
|
|
]
|
|
|
|
const MONTHS = [
|
|
{ value: '1', label: 'January' },
|
|
{ value: '2', label: 'February' },
|
|
{ value: '3', label: 'March' },
|
|
{ value: '4', label: 'April' },
|
|
{ value: '5', label: 'May' },
|
|
{ value: '6', label: 'June' },
|
|
{ value: '7', label: 'July' },
|
|
{ value: '8', label: 'August' },
|
|
{ value: '9', label: 'September' },
|
|
{ value: '10', label: 'October' },
|
|
{ value: '11', label: 'November' },
|
|
{ value: '12', label: 'December' },
|
|
]
|
|
|
|
const GENDER_OPTIONS = [
|
|
{ value: 'm', label: 'Male' },
|
|
{ value: 'f', label: 'Female' },
|
|
{ value: 'x', label: 'Prefer not to say' },
|
|
]
|
|
|
|
const AVATAR_POSITION_OPTIONS = [
|
|
{ value: 'top-left', label: 'Top Left' },
|
|
{ value: 'top', label: 'Top' },
|
|
{ value: 'top-right', label: 'Top Right' },
|
|
{ value: 'left', label: 'Left' },
|
|
{ value: 'center', label: 'Center' },
|
|
{ value: 'right', label: 'Right' },
|
|
{ value: 'bottom-left', label: 'Bottom Left' },
|
|
{ value: 'bottom', label: 'Bottom' },
|
|
{ value: 'bottom-right', label: 'Bottom Right' },
|
|
]
|
|
|
|
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/
|
|
|
|
function getCsrfToken() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
function toIsoDate(day, month, year) {
|
|
if (!day || !month || !year) return ''
|
|
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
}
|
|
|
|
function fromIsoDate(iso) {
|
|
if (!iso || typeof iso !== 'string' || !iso.includes('-')) {
|
|
return { day: '', month: '', year: '' }
|
|
}
|
|
|
|
const [year, month, day] = iso.split('-')
|
|
return {
|
|
day: String(parseInt(day || '', 10) || ''),
|
|
month: String(parseInt(month || '', 10) || ''),
|
|
year: String(year || ''),
|
|
}
|
|
}
|
|
|
|
function equalsObject(a, b) {
|
|
return JSON.stringify(a) === JSON.stringify(b)
|
|
}
|
|
|
|
function positionToObjectPosition(position) {
|
|
const map = {
|
|
'top-left': '0% 0%',
|
|
top: '50% 0%',
|
|
'top-right': '100% 0%',
|
|
left: '0% 50%',
|
|
center: '50% 50%',
|
|
right: '100% 50%',
|
|
'bottom-left': '0% 100%',
|
|
bottom: '50% 100%',
|
|
'bottom-right': '100% 100%',
|
|
}
|
|
|
|
return map[position] || '50% 50%'
|
|
}
|
|
|
|
function SectionCard({ title, description, children, actionSlot }) {
|
|
return (
|
|
<section className="rounded-xl border border-white/5 bg-white/[0.03] p-6">
|
|
<header className="flex flex-col gap-3 border-b border-white/5 pb-4 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-white">{title}</h2>
|
|
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
|
|
</div>
|
|
{actionSlot ? <div>{actionSlot}</div> : null}
|
|
</header>
|
|
<div className="pt-5">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function ProfileEdit() {
|
|
const { props } = usePage()
|
|
const {
|
|
user,
|
|
avatarUrl: initialAvatarUrl,
|
|
birthDay,
|
|
birthMonth,
|
|
birthYear,
|
|
countries = [],
|
|
usernameCooldownDays = 30,
|
|
usernameCooldownRemainingDays = 0,
|
|
usernameCooldownActive = false,
|
|
} = props
|
|
|
|
const fallbackDate = toIsoDate(
|
|
birthDay ? parseInt(String(birthDay), 10) : '',
|
|
birthMonth ? parseInt(String(birthMonth), 10) : '',
|
|
birthYear ? String(birthYear) : '',
|
|
)
|
|
|
|
const [activeSection, setActiveSection] = useState('profile')
|
|
|
|
const [profileForm, setProfileForm] = useState({
|
|
display_name: user?.name || '',
|
|
website: user?.homepage || '',
|
|
bio: user?.about_me || '',
|
|
signature: user?.signature || '',
|
|
description: user?.description || '',
|
|
})
|
|
const [accountForm, setAccountForm] = useState({
|
|
username: user?.username || '',
|
|
email: user?.email || '',
|
|
})
|
|
const [usernameAvailability, setUsernameAvailability] = useState({
|
|
status: 'idle',
|
|
message: '',
|
|
})
|
|
const [showEmailChangeModal, setShowEmailChangeModal] = useState(false)
|
|
const [emailChangeStep, setEmailChangeStep] = useState('request')
|
|
const [emailChangeLoading, setEmailChangeLoading] = useState(false)
|
|
const [emailChangeForm, setEmailChangeForm] = useState({
|
|
new_email: '',
|
|
code: '',
|
|
})
|
|
const [emailChangeError, setEmailChangeError] = useState('')
|
|
const [emailChangeInfo, setEmailChangeInfo] = useState('')
|
|
const [personalForm, setPersonalForm] = useState(() => {
|
|
const fromUser = fromIsoDate(user?.birthday || '')
|
|
const fromProps = {
|
|
day: birthDay ? String(parseInt(String(birthDay), 10) || '') : '',
|
|
month: birthMonth ? String(parseInt(String(birthMonth), 10) || '') : '',
|
|
year: birthYear ? String(birthYear) : '',
|
|
}
|
|
|
|
return {
|
|
day: fromUser.day || fromProps.day || '',
|
|
month: fromUser.month || fromProps.month || '',
|
|
year: fromUser.year || fromProps.year || '',
|
|
gender: String(user?.gender || '').toLowerCase() || '',
|
|
country: user?.country_code || '',
|
|
}
|
|
})
|
|
const [notificationForm, setNotificationForm] = useState({
|
|
email_notifications: !!user?.email_notifications,
|
|
upload_notifications: !!user?.upload_notifications,
|
|
follower_notifications: !!user?.follower_notifications,
|
|
comment_notifications: !!user?.comment_notifications,
|
|
newsletter: !!user?.newsletter,
|
|
})
|
|
const [securityForm, setSecurityForm] = useState({
|
|
current_password: '',
|
|
new_password: '',
|
|
new_password_confirmation: '',
|
|
})
|
|
|
|
const [savingSection, setSavingSection] = useState('')
|
|
const [savedMessage, setSavedMessage] = useState({ section: '', text: '' })
|
|
const [errorsBySection, setErrorsBySection] = useState({
|
|
profile: {},
|
|
account: {},
|
|
personal: {},
|
|
notifications: {},
|
|
security: {},
|
|
})
|
|
|
|
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
|
const [avatarFile, setAvatarFile] = useState(null)
|
|
const [avatarPosition, setAvatarPosition] = useState('center')
|
|
const [dragActive, setDragActive] = useState(false)
|
|
const [removeAvatar, setRemoveAvatar] = useState(false)
|
|
const avatarInputRef = useRef(null)
|
|
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
const [deletePassword, setDeletePassword] = useState('')
|
|
const [deleteError, setDeleteError] = useState('')
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
const initialRef = useRef({
|
|
profileForm,
|
|
accountForm,
|
|
personalForm,
|
|
notificationForm,
|
|
avatarUrl: initialAvatarUrl || '',
|
|
})
|
|
|
|
const dirtyMap = useMemo(() => {
|
|
return {
|
|
profile:
|
|
!equalsObject(profileForm, initialRef.current.profileForm) ||
|
|
!!avatarFile ||
|
|
(avatarFile && avatarPosition !== 'center') ||
|
|
removeAvatar ||
|
|
avatarUrl !== initialRef.current.avatarUrl,
|
|
account: !equalsObject(accountForm, initialRef.current.accountForm),
|
|
personal: !equalsObject(personalForm, initialRef.current.personalForm),
|
|
notifications: !equalsObject(notificationForm, initialRef.current.notificationForm),
|
|
security:
|
|
!!securityForm.current_password ||
|
|
!!securityForm.new_password ||
|
|
!!securityForm.new_password_confirmation,
|
|
danger: false,
|
|
}
|
|
}, [profileForm, accountForm, personalForm, notificationForm, securityForm, avatarFile, avatarPosition, removeAvatar, avatarUrl])
|
|
|
|
const hasUnsavedChanges = useMemo(
|
|
() => Object.entries(dirtyMap).some(([key, dirty]) => key !== 'danger' && dirty),
|
|
[dirtyMap],
|
|
)
|
|
|
|
useEffect(() => {
|
|
const beforeUnload = (event) => {
|
|
if (!hasUnsavedChanges) return
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
|
|
window.addEventListener('beforeunload', beforeUnload)
|
|
return () => window.removeEventListener('beforeunload', beforeUnload)
|
|
}, [hasUnsavedChanges])
|
|
|
|
useEffect(() => {
|
|
if (usernameCooldownActive) {
|
|
setUsernameAvailability({ status: 'idle', message: '' })
|
|
return
|
|
}
|
|
|
|
const candidate = String(accountForm.username || '').trim().toLowerCase()
|
|
const current = String(initialRef.current.accountForm.username || '').trim().toLowerCase()
|
|
|
|
if (!candidate || candidate === current) {
|
|
setUsernameAvailability({ status: 'idle', message: '' })
|
|
return
|
|
}
|
|
|
|
if (!USERNAME_REGEX.test(candidate)) {
|
|
setUsernameAvailability({ status: 'invalid', message: 'Use 3-20 letters, numbers, or underscores.' })
|
|
return
|
|
}
|
|
|
|
setUsernameAvailability({ status: 'checking', message: 'Checking availability...' })
|
|
|
|
const controller = new AbortController()
|
|
const timeout = window.setTimeout(async () => {
|
|
try {
|
|
const response = await fetch(`/api/username/check?username=${encodeURIComponent(candidate)}`, {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
signal: controller.signal,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
const msg = payload?.errors?.username?.[0] || 'Username is not available.'
|
|
setUsernameAvailability({ status: 'invalid', message: msg })
|
|
return
|
|
}
|
|
|
|
if (payload.available) {
|
|
setUsernameAvailability({ status: 'available', message: 'Username is available.' })
|
|
return
|
|
}
|
|
|
|
setUsernameAvailability({ status: 'taken', message: 'Username is already taken.' })
|
|
} catch (error) {
|
|
if (error?.name !== 'AbortError') {
|
|
setUsernameAvailability({ status: 'invalid', message: 'Unable to check username right now.' })
|
|
}
|
|
}
|
|
}, 300)
|
|
|
|
return () => {
|
|
controller.abort()
|
|
window.clearTimeout(timeout)
|
|
}
|
|
}, [accountForm.username, usernameCooldownActive])
|
|
|
|
const openEmailChangeModal = () => {
|
|
setShowEmailChangeModal(true)
|
|
setEmailChangeStep('request')
|
|
setEmailChangeError('')
|
|
setEmailChangeInfo('')
|
|
setEmailChangeForm({ new_email: '', code: '' })
|
|
}
|
|
|
|
const closeEmailChangeModal = () => {
|
|
if (emailChangeLoading) return
|
|
setShowEmailChangeModal(false)
|
|
setEmailChangeStep('request')
|
|
setEmailChangeError('')
|
|
setEmailChangeInfo('')
|
|
setEmailChangeForm({ new_email: '', code: '' })
|
|
}
|
|
|
|
const countryOptions = (countries || []).map((c) => ({
|
|
value: c.country_code || c.code || c.id || '',
|
|
label: c.country_name || c.name || '',
|
|
}))
|
|
|
|
const yearOptions = useMemo(() => {
|
|
const current = new Date().getFullYear()
|
|
return Array.from({ length: 100 }, (_, index) => {
|
|
const year = String(current - index)
|
|
return { value: year, label: year }
|
|
})
|
|
}, [])
|
|
|
|
const dayOptions = useMemo(
|
|
() => Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) })),
|
|
[],
|
|
)
|
|
|
|
const clearSectionStatus = (section) => {
|
|
setSavedMessage((prev) => (prev.section === section ? { section: '', text: '' } : prev))
|
|
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
|
|
}
|
|
|
|
const switchSection = (nextSection) => {
|
|
if (activeSection === nextSection) return
|
|
if (dirtyMap[activeSection]) {
|
|
const shouldContinue = window.confirm('You have unsaved changes in this section. Leave without saving?')
|
|
if (!shouldContinue) return
|
|
}
|
|
setActiveSection(nextSection)
|
|
}
|
|
|
|
const handleAvatarSelect = (file) => {
|
|
if (!file) return
|
|
if (!file.type.startsWith('image/')) return
|
|
setAvatarFile(file)
|
|
setRemoveAvatar(false)
|
|
setAvatarUrl(URL.createObjectURL(file))
|
|
clearSectionStatus('profile')
|
|
}
|
|
|
|
const dragHandler = (event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const handleDrop = (event) => {
|
|
dragHandler(event)
|
|
setDragActive(false)
|
|
const file = event.dataTransfer?.files?.[0]
|
|
if (file) handleAvatarSelect(file)
|
|
}
|
|
|
|
const updateSectionErrors = (section, errors = {}) => {
|
|
setErrorsBySection((prev) => ({ ...prev, [section]: errors }))
|
|
}
|
|
|
|
const saveProfileSection = async (event) => {
|
|
event.preventDefault()
|
|
setSavingSection('profile')
|
|
clearSectionStatus('profile')
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('display_name', profileForm.display_name || '')
|
|
formData.append('website', profileForm.website || '')
|
|
formData.append('bio', profileForm.bio || '')
|
|
formData.append('signature', profileForm.signature || '')
|
|
formData.append('description', profileForm.description || '')
|
|
formData.append('remove_avatar', removeAvatar ? '1' : '0')
|
|
formData.append('avatar_position', avatarPosition)
|
|
if (avatarFile) {
|
|
formData.append('avatar', avatarFile)
|
|
}
|
|
|
|
const response = await fetch('/settings/profile/update', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: formData,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
|
|
return
|
|
}
|
|
|
|
const nextAvatarUrl = payload.avatarUrl || avatarUrl || initialAvatarUrl
|
|
initialRef.current.profileForm = { ...profileForm }
|
|
initialRef.current.avatarUrl = nextAvatarUrl || ''
|
|
setAvatarUrl(nextAvatarUrl || '')
|
|
setAvatarFile(null)
|
|
setAvatarPosition('center')
|
|
setRemoveAvatar(false)
|
|
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
|
|
} catch (error) {
|
|
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
|
|
} finally {
|
|
setSavingSection('')
|
|
}
|
|
}
|
|
|
|
const saveAccountSection = async (event) => {
|
|
event.preventDefault()
|
|
|
|
if (usernameCooldownActive && accountForm.username !== initialRef.current.accountForm.username) {
|
|
updateSectionErrors('account', {
|
|
username: [`Username can be changed again in ${usernameCooldownRemainingDays} days.`],
|
|
})
|
|
return
|
|
}
|
|
|
|
setSavingSection('account')
|
|
clearSectionStatus('account')
|
|
|
|
try {
|
|
const response = await fetch('/settings/account/username', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify({ username: accountForm.username }),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
|
|
return
|
|
}
|
|
|
|
initialRef.current.accountForm = { ...accountForm }
|
|
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
|
|
} catch (error) {
|
|
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
|
|
} finally {
|
|
setSavingSection('')
|
|
}
|
|
}
|
|
|
|
const requestEmailChange = async () => {
|
|
setEmailChangeLoading(true)
|
|
setEmailChangeError('')
|
|
setEmailChangeInfo('')
|
|
|
|
try {
|
|
const response = await fetch('/settings/email/request', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify({ new_email: emailChangeForm.new_email }),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
|
|
return
|
|
}
|
|
|
|
setEmailChangeStep('verify')
|
|
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
|
|
} catch (error) {
|
|
setEmailChangeError('Request failed. Please try again.')
|
|
} finally {
|
|
setEmailChangeLoading(false)
|
|
}
|
|
}
|
|
|
|
const verifyEmailChange = async () => {
|
|
setEmailChangeLoading(true)
|
|
setEmailChangeError('')
|
|
setEmailChangeInfo('')
|
|
|
|
try {
|
|
const response = await fetch('/settings/email/verify', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify({ code: emailChangeForm.code }),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
|
|
return
|
|
}
|
|
|
|
const nextEmail = payload.email || emailChangeForm.new_email
|
|
setAccountForm((prev) => ({ ...prev, email: nextEmail }))
|
|
initialRef.current.accountForm = { ...initialRef.current.accountForm, email: nextEmail }
|
|
setShowEmailChangeModal(false)
|
|
setEmailChangeStep('request')
|
|
setEmailChangeForm({ new_email: '', code: '' })
|
|
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
|
|
} catch (error) {
|
|
setEmailChangeError('Request failed. Please try again.')
|
|
} finally {
|
|
setEmailChangeLoading(false)
|
|
}
|
|
}
|
|
|
|
const savePersonalSection = async (event) => {
|
|
event.preventDefault()
|
|
setSavingSection('personal')
|
|
clearSectionStatus('personal')
|
|
|
|
try {
|
|
const response = await fetch('/settings/personal/update', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify({
|
|
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
|
|
gender: personalForm.gender || null,
|
|
country: personalForm.country || null,
|
|
}),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
|
|
return
|
|
}
|
|
|
|
initialRef.current.personalForm = { ...personalForm }
|
|
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
|
|
} catch (error) {
|
|
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
|
|
} finally {
|
|
setSavingSection('')
|
|
}
|
|
}
|
|
|
|
const saveNotificationsSection = async (event) => {
|
|
event.preventDefault()
|
|
setSavingSection('notifications')
|
|
clearSectionStatus('notifications')
|
|
|
|
try {
|
|
const response = await fetch('/settings/notifications/update', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(notificationForm),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
|
|
return
|
|
}
|
|
|
|
initialRef.current.notificationForm = { ...notificationForm }
|
|
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
|
|
} catch (error) {
|
|
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
|
|
} finally {
|
|
setSavingSection('')
|
|
}
|
|
}
|
|
|
|
const saveSecuritySection = async (event) => {
|
|
event.preventDefault()
|
|
setSavingSection('security')
|
|
clearSectionStatus('security')
|
|
|
|
try {
|
|
const response = await fetch('/settings/security/password', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(securityForm),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
|
|
return
|
|
}
|
|
|
|
setSecurityForm({
|
|
current_password: '',
|
|
new_password: '',
|
|
new_password_confirmation: '',
|
|
})
|
|
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
|
|
} catch (error) {
|
|
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
|
|
} finally {
|
|
setSavingSection('')
|
|
}
|
|
}
|
|
|
|
const handleDeleteAccount = async () => {
|
|
setDeleting(true)
|
|
setDeleteError('')
|
|
|
|
try {
|
|
const response = await fetch('/profile', {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify({ password: deletePassword }),
|
|
})
|
|
|
|
if (response.ok || response.status === 302) {
|
|
window.location.href = '/'
|
|
return
|
|
}
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
setDeleteError(payload.errors?.password?.[0] || payload.message || 'Deletion failed.')
|
|
} catch (error) {
|
|
setDeleteError('Request failed.')
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const sectionSaved = savedMessage.section === activeSection ? savedMessage.text : ''
|
|
|
|
return (
|
|
<SettingsLayout
|
|
title="Settings"
|
|
sections={SETTINGS_SECTIONS}
|
|
activeSection={activeSection}
|
|
onSectionChange={switchSection}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-2 rounded-xl border border-white/5 bg-white/[0.02] p-4 md:flex-row md:items-center md:justify-between">
|
|
<p className="text-sm text-slate-300">
|
|
Configure your account by section. Each card saves independently.
|
|
</p>
|
|
{dirtyMap[activeSection] ? (
|
|
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-medium text-amber-300">
|
|
Unsaved changes
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{activeSection === 'profile' ? (
|
|
<form className="space-y-4" onSubmit={saveProfileSection}>
|
|
<SectionCard
|
|
title="Profile"
|
|
description="Manage your public identity and profile presentation."
|
|
actionSlot={
|
|
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'profile'}>
|
|
Save Profile
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="grid gap-6 lg:grid-cols-[260px,1fr]">
|
|
<div className="space-y-3">
|
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
|
<div
|
|
className="mx-auto overflow-hidden rounded-full border border-white/15 bg-white/5"
|
|
style={{ width: 144, height: 144, minWidth: 144, minHeight: 144 }}
|
|
>
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt="Avatar preview"
|
|
className="block h-full w-full object-cover object-center"
|
|
style={{ aspectRatio: '1 / 1', objectPosition: positionToObjectPosition(avatarPosition) }}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-slate-500">No avatar</div>
|
|
)}
|
|
</div>
|
|
<p className="mt-3 text-center text-xs text-slate-400">Recommended size: 256 x 256</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
|
<Select
|
|
label="Avatar crop position"
|
|
value={avatarPosition}
|
|
onChange={(e) => {
|
|
setAvatarPosition(e.target.value)
|
|
clearSectionStatus('profile')
|
|
}}
|
|
options={AVATAR_POSITION_OPTIONS}
|
|
hint="Applies when saving a newly selected avatar"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`rounded-xl border-2 border-dashed p-4 text-center transition ${
|
|
dragActive ? 'border-accent/60 bg-accent/10' : 'border-white/15 bg-white/[0.02]'
|
|
}`}
|
|
onDragEnter={(e) => {
|
|
dragHandler(e)
|
|
setDragActive(true)
|
|
}}
|
|
onDragLeave={(e) => {
|
|
dragHandler(e)
|
|
setDragActive(false)
|
|
}}
|
|
onDragOver={dragHandler}
|
|
onDrop={handleDrop}
|
|
>
|
|
<p className="text-sm text-white">Drag and drop avatar here</p>
|
|
<p className="mt-1 text-xs text-slate-400">JPG, PNG, WEBP up to 2 MB</p>
|
|
<input
|
|
ref={avatarInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
|
|
/>
|
|
<div className="mt-3 flex items-center justify-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="xs"
|
|
variant="secondary"
|
|
onClick={() => avatarInputRef.current?.click()}
|
|
>
|
|
Upload avatar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="xs"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setAvatarFile(null)
|
|
setRemoveAvatar(true)
|
|
setAvatarUrl('')
|
|
}}
|
|
>
|
|
Remove avatar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{errorsBySection.profile._general ? (
|
|
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{errorsBySection.profile._general[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
{sectionSaved ? (
|
|
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{sectionSaved}
|
|
</div>
|
|
) : null}
|
|
|
|
<TextInput
|
|
label="Display Name"
|
|
value={profileForm.display_name}
|
|
onChange={(e) => {
|
|
setProfileForm((prev) => ({ ...prev, display_name: e.target.value }))
|
|
clearSectionStatus('profile')
|
|
}}
|
|
error={errorsBySection.profile.display_name?.[0]}
|
|
maxLength={60}
|
|
required
|
|
/>
|
|
|
|
<TextInput
|
|
label="Website"
|
|
value={profileForm.website}
|
|
onChange={(e) => {
|
|
setProfileForm((prev) => ({ ...prev, website: e.target.value }))
|
|
clearSectionStatus('profile')
|
|
}}
|
|
placeholder="https://"
|
|
error={errorsBySection.profile.website?.[0]}
|
|
/>
|
|
|
|
<Textarea
|
|
label="Bio"
|
|
value={profileForm.bio}
|
|
onChange={(e) => {
|
|
setProfileForm((prev) => ({ ...prev, bio: e.target.value }))
|
|
clearSectionStatus('profile')
|
|
}}
|
|
rows={4}
|
|
maxLength={200}
|
|
error={errorsBySection.profile.bio?.[0]}
|
|
hint={`${(profileForm.bio || '').length}/200`}
|
|
/>
|
|
|
|
<Textarea
|
|
label="Signature"
|
|
value={profileForm.signature}
|
|
onChange={(e) => {
|
|
setProfileForm((prev) => ({ ...prev, signature: e.target.value }))
|
|
clearSectionStatus('profile')
|
|
}}
|
|
rows={3}
|
|
error={errorsBySection.profile.signature?.[0]}
|
|
/>
|
|
|
|
<Textarea
|
|
label="Description"
|
|
value={profileForm.description}
|
|
onChange={(e) => {
|
|
setProfileForm((prev) => ({ ...prev, description: e.target.value }))
|
|
clearSectionStatus('profile')
|
|
}}
|
|
rows={3}
|
|
error={errorsBySection.profile.description?.[0]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
</form>
|
|
) : null}
|
|
|
|
{activeSection === 'account' ? (
|
|
<form className="space-y-4" onSubmit={saveAccountSection}>
|
|
<SectionCard
|
|
title="Account"
|
|
description="Update your core account identity details."
|
|
actionSlot={
|
|
<Button
|
|
type="submit"
|
|
variant="accent"
|
|
size="sm"
|
|
loading={savingSection === 'account'}
|
|
disabled={usernameCooldownActive}
|
|
>
|
|
Save Username
|
|
</Button>
|
|
}
|
|
>
|
|
{errorsBySection.account._general ? (
|
|
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{errorsBySection.account._general[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
{sectionSaved ? (
|
|
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{sectionSaved}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextInput
|
|
label="Username"
|
|
value={accountForm.username}
|
|
onChange={(e) => {
|
|
setAccountForm((prev) => ({ ...prev, username: e.target.value }))
|
|
clearSectionStatus('account')
|
|
}}
|
|
disabled={usernameCooldownActive}
|
|
error={errorsBySection.account.username?.[0]}
|
|
hint={usernameCooldownActive ? `Username can be changed again in ${usernameCooldownRemainingDays} days.` : 'Allowed: letters, numbers, underscores (3-20).'}
|
|
required
|
|
/>
|
|
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
|
<p className="text-xs uppercase tracking-wide text-slate-400">Current Email</p>
|
|
<p className="mt-1 text-sm font-medium text-white">{accountForm.email || 'No email set'}</p>
|
|
<div className="mt-3">
|
|
<Button type="button" variant="secondary" size="sm" onClick={openEmailChangeModal}>
|
|
Change Email
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{usernameAvailability.status !== 'idle' ? (
|
|
<p
|
|
className={`mt-4 rounded-lg border px-3 py-2 text-xs ${
|
|
usernameAvailability.status === 'available'
|
|
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300'
|
|
: usernameAvailability.status === 'checking'
|
|
? 'border-sky-400/30 bg-sky-500/10 text-sky-300'
|
|
: 'border-red-400/30 bg-red-500/10 text-red-300'
|
|
}`}
|
|
>
|
|
{usernameAvailability.message}
|
|
</p>
|
|
) : null}
|
|
|
|
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
|
|
You can change your username once every {usernameCooldownDays} days.
|
|
</p>
|
|
</SectionCard>
|
|
</form>
|
|
) : null}
|
|
|
|
{activeSection === 'personal' ? (
|
|
<form className="space-y-4" onSubmit={savePersonalSection}>
|
|
<SectionCard
|
|
title="Personal Details"
|
|
description="Optional information shown only when you decide to provide it."
|
|
actionSlot={
|
|
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'personal'}>
|
|
Save Personal Details
|
|
</Button>
|
|
}
|
|
>
|
|
{errorsBySection.personal._general ? (
|
|
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{errorsBySection.personal._general[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
{sectionSaved ? (
|
|
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{sectionSaved}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-white/85">Birthday</label>
|
|
<div className="grid max-w-lg grid-cols-3 gap-3">
|
|
<Select
|
|
placeholder="Day"
|
|
value={personalForm.day}
|
|
onChange={(e) => {
|
|
setPersonalForm((prev) => ({ ...prev, day: e.target.value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
options={dayOptions}
|
|
/>
|
|
<Select
|
|
placeholder="Month"
|
|
value={personalForm.month}
|
|
onChange={(e) => {
|
|
setPersonalForm((prev) => ({ ...prev, month: e.target.value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
options={MONTHS}
|
|
/>
|
|
<Select
|
|
placeholder="Year"
|
|
value={personalForm.year}
|
|
onChange={(e) => {
|
|
setPersonalForm((prev) => ({ ...prev, year: e.target.value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
options={yearOptions}
|
|
/>
|
|
</div>
|
|
{errorsBySection.personal.birthday?.[0] ? (
|
|
<p className="mt-1 text-xs text-red-300">{errorsBySection.personal.birthday[0]}</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<RadioGroup
|
|
label="Gender"
|
|
name="gender"
|
|
options={GENDER_OPTIONS}
|
|
value={personalForm.gender}
|
|
onChange={(value) => {
|
|
setPersonalForm((prev) => ({ ...prev, gender: value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
direction="horizontal"
|
|
error={errorsBySection.personal.gender?.[0]}
|
|
/>
|
|
|
|
{countryOptions.length > 0 ? (
|
|
<Select
|
|
label="Country"
|
|
value={personalForm.country}
|
|
onChange={(e) => {
|
|
setPersonalForm((prev) => ({ ...prev, country: e.target.value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
options={countryOptions}
|
|
placeholder="Select country"
|
|
error={errorsBySection.personal.country?.[0]}
|
|
/>
|
|
) : (
|
|
<TextInput
|
|
label="Country"
|
|
value={personalForm.country}
|
|
onChange={(e) => {
|
|
setPersonalForm((prev) => ({ ...prev, country: e.target.value }))
|
|
clearSectionStatus('personal')
|
|
}}
|
|
placeholder="Country code (e.g. US, DE, TR)"
|
|
error={errorsBySection.personal.country?.[0]}
|
|
/>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
</form>
|
|
) : null}
|
|
|
|
{activeSection === 'notifications' ? (
|
|
<form className="space-y-4" onSubmit={saveNotificationsSection}>
|
|
<SectionCard
|
|
title="Notifications"
|
|
description="Choose how and when Skinbase should notify you."
|
|
actionSlot={
|
|
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'notifications'}>
|
|
Save Notification Settings
|
|
</Button>
|
|
}
|
|
>
|
|
{errorsBySection.notifications._general ? (
|
|
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{errorsBySection.notifications._general[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
{sectionSaved ? (
|
|
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{sectionSaved}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-3">
|
|
{[
|
|
['email_notifications', 'Email notifications', 'General email alerts for account activity.'],
|
|
['upload_notifications', 'Upload notifications', 'Notify me when followed creators upload.'],
|
|
['follower_notifications', 'Follower notifications', 'Notify me when someone follows me.'],
|
|
['comment_notifications', 'Comment notifications', 'Notify me about comments on my content.'],
|
|
['newsletter', 'Newsletter', 'Receive occasional community and product updates.'],
|
|
].map(([field, label, hint]) => (
|
|
<div key={field} className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
|
|
<div>
|
|
<p className="text-sm font-medium text-white/90">{label}</p>
|
|
<p className="text-xs text-slate-400">{hint}</p>
|
|
</div>
|
|
<Toggle
|
|
checked={!!notificationForm[field]}
|
|
onChange={(e) => {
|
|
setNotificationForm((prev) => ({ ...prev, [field]: e.target.checked }))
|
|
clearSectionStatus('notifications')
|
|
}}
|
|
variant="accent"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
</form>
|
|
) : null}
|
|
|
|
{activeSection === 'security' ? (
|
|
<form className="space-y-4" onSubmit={saveSecuritySection}>
|
|
<SectionCard
|
|
title="Security"
|
|
description="Update password. Additional security controls can be added here later."
|
|
actionSlot={
|
|
<Button type="submit" variant="accent" size="sm" loading={savingSection === 'security'}>
|
|
Update Password
|
|
</Button>
|
|
}
|
|
>
|
|
{errorsBySection.security._general ? (
|
|
<div className="mb-4 rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{errorsBySection.security._general[0]}
|
|
</div>
|
|
) : null}
|
|
|
|
{sectionSaved ? (
|
|
<div className="mb-4 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{sectionSaved}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid max-w-2xl gap-4">
|
|
<TextInput
|
|
label="Current Password"
|
|
type="password"
|
|
value={securityForm.current_password}
|
|
onChange={(e) => {
|
|
setSecurityForm((prev) => ({ ...prev, current_password: e.target.value }))
|
|
clearSectionStatus('security')
|
|
}}
|
|
error={errorsBySection.security.current_password?.[0]}
|
|
autoComplete="current-password"
|
|
/>
|
|
<TextInput
|
|
label="New Password"
|
|
type="password"
|
|
value={securityForm.new_password}
|
|
onChange={(e) => {
|
|
setSecurityForm((prev) => ({ ...prev, new_password: e.target.value }))
|
|
clearSectionStatus('security')
|
|
}}
|
|
error={errorsBySection.security.new_password?.[0]}
|
|
hint="Minimum 8 characters"
|
|
autoComplete="new-password"
|
|
/>
|
|
<TextInput
|
|
label="Confirm Password"
|
|
type="password"
|
|
value={securityForm.new_password_confirmation}
|
|
onChange={(e) => {
|
|
setSecurityForm((prev) => ({ ...prev, new_password_confirmation: e.target.value }))
|
|
clearSectionStatus('security')
|
|
}}
|
|
error={errorsBySection.security.new_password_confirmation?.[0]}
|
|
autoComplete="new-password"
|
|
/>
|
|
|
|
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
|
|
Future security controls: Two-factor authentication, active sessions, and login history.
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
</form>
|
|
) : null}
|
|
|
|
{activeSection === 'danger' ? (
|
|
<SectionCard
|
|
title="Danger Zone"
|
|
description="This action cannot be undone."
|
|
actionSlot={
|
|
<Button variant="danger" size="sm" onClick={() => setShowDeleteModal(true)}>
|
|
Delete Account
|
|
</Button>
|
|
}
|
|
>
|
|
<p className="text-sm text-slate-300">
|
|
Deleting your account permanently removes your artworks, comments, and profile data.
|
|
</p>
|
|
</SectionCard>
|
|
) : null}
|
|
</div>
|
|
|
|
<Modal
|
|
open={showEmailChangeModal}
|
|
onClose={closeEmailChangeModal}
|
|
title="Change Email"
|
|
size="sm"
|
|
footer={
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={closeEmailChangeModal} disabled={emailChangeLoading}>
|
|
Cancel
|
|
</Button>
|
|
{emailChangeStep === 'request' ? (
|
|
<Button
|
|
type="button"
|
|
variant="accent"
|
|
size="sm"
|
|
loading={emailChangeLoading}
|
|
onClick={requestEmailChange}
|
|
>
|
|
Send Code
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="accent"
|
|
size="sm"
|
|
loading={emailChangeLoading}
|
|
onClick={verifyEmailChange}
|
|
>
|
|
Verify and Update
|
|
</Button>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
{emailChangeError ? (
|
|
<div className="rounded-lg border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
{emailChangeError}
|
|
</div>
|
|
) : null}
|
|
|
|
{emailChangeInfo ? (
|
|
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
|
{emailChangeInfo}
|
|
</div>
|
|
) : null}
|
|
|
|
{emailChangeStep === 'request' ? (
|
|
<TextInput
|
|
label="Enter new email address"
|
|
type="email"
|
|
value={emailChangeForm.new_email}
|
|
onChange={(e) => setEmailChangeForm((prev) => ({ ...prev, new_email: e.target.value }))}
|
|
autoComplete="email"
|
|
required
|
|
/>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-slate-300">Enter the 6-digit verification code sent to {emailChangeForm.new_email}.</p>
|
|
<TextInput
|
|
label="Verification Code"
|
|
value={emailChangeForm.code}
|
|
onChange={(e) => setEmailChangeForm((prev) => ({ ...prev, code: e.target.value }))}
|
|
inputMode="numeric"
|
|
maxLength={6}
|
|
required
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={showDeleteModal}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
title="Delete Account"
|
|
size="sm"
|
|
footer={
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteModal(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="danger" size="sm" loading={deleting} onClick={handleDeleteAccount}>
|
|
Permanently Delete
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-slate-300">Confirm your password to permanently delete your account.</p>
|
|
<TextInput
|
|
label="Password"
|
|
type="password"
|
|
value={deletePassword}
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
|
error={deleteError}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
</SettingsLayout>
|
|
)
|
|
}
|