feat: store node logo/favicon in postgres instead of S3

- Add logoData and faviconData columns to nodes table
- Create /api/admin/node/upload endpoint for direct DB storage
- Create /api/node/logo and /api/node/favicon endpoints to serve images
- Update admin page to use new upload endpoint
- Remove dependency on admin's personal S3 storage for node assets
This commit is contained in:
Christomatt
2026-02-01 16:29:39 +01:00
parent 0cea2e9e1f
commit 90dfd62434
17 changed files with 913 additions and 90 deletions
+367 -2
View File
@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Shield, Lock, Check, AlertCircle } from 'lucide-react';
import { Shield, Lock, Check, AlertCircle, Mail, Trash2 } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
export default function SecuritySettingsPage() {
@@ -13,7 +13,21 @@ export default function SecuritySettingsPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const { isIdentityUnlocked, signUserAction } = useAuth();
const { isIdentityUnlocked, signUserAction, user } = useAuth();
// Email change states
const [newEmail, setNewEmail] = useState('');
const [emailPassword, setEmailPassword] = useState('');
const [isChangingEmail, setIsChangingEmail] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
// Delete account states
const [deletePassword, setDeletePassword] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -72,6 +86,97 @@ export default function SecuritySettingsPage() {
}
};
const handleEmailChange = async (e: React.FormEvent) => {
e.preventDefault();
setEmailError(null);
setEmailSuccess(null);
if (!newEmail || !emailPassword) {
setEmailError('Please enter your new email and current password');
return;
}
if (!isIdentityUnlocked) {
setEmailError('Your session has expired. Please log in again.');
return;
}
setIsChangingEmail(true);
try {
const signedPayload = await signUserAction('change_email', {
newEmail,
currentPassword: emailPassword
});
const res = await fetch('/api/account/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedPayload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to change email');
}
setEmailSuccess('Email updated successfully');
setNewEmail('');
setEmailPassword('');
} catch (error) {
setEmailError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setIsChangingEmail(false);
}
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteError(null);
if (deleteConfirm !== 'DELETE') {
setDeleteError('Please type DELETE to confirm account deletion');
return;
}
if (!deletePassword) {
setDeleteError('Please enter your password to confirm');
return;
}
if (!isIdentityUnlocked) {
setDeleteError('Your session has expired. Please log in again.');
return;
}
setIsDeleting(true);
try {
const signedPayload = await signUserAction('delete_account', {
password: deletePassword
});
const res = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedPayload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to delete account');
}
// Account deleted - redirect to home
window.location.href = '/';
} catch (error) {
setDeleteError(error instanceof Error ? error.message : 'An error occurred');
setIsDeleting(false);
}
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
@@ -220,6 +325,266 @@ export default function SecuritySettingsPage() {
</button>
</form>
</div>
{/* Change Email Section */}
<div className="card" style={{ padding: '24px', marginTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Mail size={20} />
</div>
<div>
<h2 style={{ fontSize: '18px', fontWeight: 600 }}>Change Email</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Update your email address
</p>
</div>
</div>
<form onSubmit={handleEmailChange}>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Current Email
</label>
<input
type="email"
className="input"
value={user?.email || ''}
disabled
style={{ opacity: 0.6 }}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
New Email
</label>
<input
type="email"
className="input"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="Enter new email address"
required
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Current Password (for verification)
</label>
<input
type="password"
className="input"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder="Enter your current password"
required
/>
</div>
{emailError && (
<div style={{
padding: '12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '8px',
color: 'var(--error)',
fontSize: '14px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<AlertCircle size={16} />
{emailError}
</div>
)}
{emailSuccess && (
<div style={{
padding: '12px',
background: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.2)',
borderRadius: '8px',
color: 'var(--success)',
fontSize: '14px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<Check size={16} />
{emailSuccess}
</div>
)}
<button
type="submit"
className="btn btn-primary"
disabled={isChangingEmail || !newEmail || !emailPassword}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}
>
{isChangingEmail ? (
'Updating...'
) : (
<>
<Mail size={16} /> Update Email
</>
)}
</button>
</form>
</div>
{/* Delete Account Section */}
<div className="card" style={{ padding: '24px', marginTop: '24px', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--error)',
}}>
<Trash2 size={20} />
</div>
<div>
<h2 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--error)' }}>Delete Account</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Permanently delete your account and all data
</p>
</div>
</div>
{!showDeleteConfirm ? (
<div>
<div style={{
padding: '16px',
background: 'rgba(239, 68, 68, 0.05)',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '14px',
color: 'var(--foreground-secondary)',
lineHeight: 1.5,
}}>
<strong style={{ color: 'var(--error)' }}>Warning:</strong> This action cannot be undone.
Your account, posts, and all associated data will be permanently deleted.
Your handle will be released and may be claimed by someone else.
</div>
<button
onClick={() => setShowDeleteConfirm(true)}
className="btn"
style={{
width: '100%',
background: 'rgba(239, 68, 68, 0.1)',
color: 'var(--error)',
border: '1px solid rgba(239, 68, 68, 0.3)',
}}
>
I want to delete my account
</button>
</div>
) : (
<form onSubmit={handleDeleteAccount}>
<div style={{
padding: '16px',
background: 'rgba(239, 68, 68, 0.1)',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '14px',
color: 'var(--foreground-secondary)',
lineHeight: 1.5,
}}>
<strong style={{ color: 'var(--error)' }}>This is permanent.</strong> Type <code style={{ background: 'var(--background)', padding: '2px 6px', borderRadius: '4px' }}>DELETE</code> below to confirm.
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Type DELETE to confirm
</label>
<input
type="text"
className="input"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder="DELETE"
required
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', display: 'block', marginBottom: '6px' }}>
Your Password (for verification)
</label>
<input
type="password"
className="input"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
{deleteError && (
<div style={{
padding: '12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '8px',
color: 'var(--error)',
fontSize: '14px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<AlertCircle size={16} />
{deleteError}
</div>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirm('');
setDeletePassword('');
setDeleteError(null);
}}
className="btn btn-ghost"
style={{ flex: 1 }}
disabled={isDeleting}
>
Cancel
</button>
<button
type="submit"
className="btn"
disabled={isDeleting || deleteConfirm !== 'DELETE' || !deletePassword}
style={{
flex: 1,
background: 'var(--error)',
color: '#fff',
}}
>
{isDeleting ? 'Deleting...' : 'Delete My Account'}
</button>
</div>
</form>
)}
</div>
</div>
);
}