Prompt for upload re-auth on demand

This commit is contained in:
cyph3rasi
2026-03-07 18:29:18 -08:00
parent 28eb46d653
commit 76ca21e14f
4 changed files with 328 additions and 77 deletions
+105 -17
View File
@@ -3,8 +3,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import AutoTextarea from '@/components/AutoTextarea'; import AutoTextarea from '@/components/AutoTextarea';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { useToast } from '@/lib/contexts/ToastContext'; import { useToast } from '@/lib/contexts/ToastContext';
import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext';
import { refreshStorageSession } from '@/lib/storage/client';
export default function AdminPage() { export default function AdminPage() {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -27,6 +29,11 @@ export default function AdminPage() {
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false);
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null); const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
const [pendingBannerFile, setPendingBannerFile] = useState<File | null>(null);
const [bannerPassword, setBannerPassword] = useState('');
const [bannerPromptError, setBannerPromptError] = useState('');
const [isRefreshingBannerSession, setIsRefreshingBannerSession] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [logoUploadError, setLogoUploadError] = useState<string | null>(null); const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
@@ -92,11 +99,7 @@ export default function AdminPage() {
} }
}; };
const handleBannerUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const uploadBannerFile = async (file: File, allowPrompt = true) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setBannerUploadError(null); setBannerUploadError(null);
setIsUploadingBanner(true); setIsUploadingBanner(true);
@@ -110,6 +113,13 @@ export default function AdminPage() {
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.url) { if (!res.ok || !data.url) {
if (res.status === 401 && allowPrompt) {
setPendingBannerFile(file);
setBannerPromptError('');
setShowBannerSessionPrompt(true);
return;
}
throw new Error(data.error || 'Upload failed'); throw new Error(data.error || 'Upload failed');
} }
@@ -119,6 +129,10 @@ export default function AdminPage() {
}; };
setNodeSettings(nextSettings); setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings); await handleSaveSettings(nextSettings);
setPendingBannerFile(null);
setShowBannerSessionPrompt(false);
setBannerPassword('');
setBannerPromptError('');
} catch (error) { } catch (error) {
console.error('Banner upload failed', error); console.error('Banner upload failed', error);
setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
@@ -127,6 +141,47 @@ export default function AdminPage() {
} }
}; };
const handleBannerUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
await uploadBannerFile(file);
};
const handleBannerSessionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pendingBannerFile) {
setShowBannerSessionPrompt(false);
return;
}
if (!bannerPassword.trim()) {
setBannerPromptError('Please enter your password');
return;
}
setIsRefreshingBannerSession(true);
setBannerPromptError('');
try {
await refreshStorageSession(bannerPassword.trim());
await uploadBannerFile(pendingBannerFile, false);
} catch (error) {
setBannerPromptError(error instanceof Error ? error.message : 'Failed to confirm password');
} finally {
setIsRefreshingBannerSession(false);
}
};
const handleBannerSessionCancel = () => {
setShowBannerSessionPrompt(false);
setPendingBannerFile(null);
setBannerPassword('');
setBannerPromptError('');
};
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ''; event.target.value = '';
@@ -382,15 +437,34 @@ export default function AdminPage() {
)} )}
</div> </div>
{nodeSettings.bannerUrl && ( {nodeSettings.bannerUrl && (
<div style={{ marginTop: '8px', height: '120px', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--border)', position: 'relative' }}> <div style={{ marginTop: '12px' }}>
<div style={{ <div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
position: 'absolute', inset: 0, Sidebar preview
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat` </div>
}} /> <div
<div style={{ className="card"
position: 'absolute', inset: 0, style={{
background: 'linear-gradient(to bottom, transparent, var(--background-secondary))' padding: 0,
}} /> overflow: 'hidden',
maxWidth: '360px',
}}
>
<div
style={{
height: '140px',
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat`,
borderBottom: '1px solid var(--border)',
}}
/>
<div style={{ padding: '16px' }}>
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>
Welcome to {nodeSettings.name || 'Your Node'}
</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
{nodeSettings.description || 'A brief tagline for your node.'}
</p>
</div>
</div>
</div> </div>
)} )}
</div> </div>
@@ -530,9 +604,23 @@ export default function AdminPage() {
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}> <button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
{savingSettings ? 'Saving...' : 'Save Settings'} {savingSettings ? 'Saving...' : 'Save Settings'}
</button> </button>
</div> </div>
</div> </div>
)} )}
<StorageSessionPrompt
open={showBannerSessionPrompt}
isSubmitting={isRefreshingBannerSession}
password={bannerPassword}
error={bannerPromptError}
description="Please confirm your password to continue uploading this banner to your storage."
onPasswordChange={(nextPassword) => {
setBannerPassword(nextPassword);
setBannerPromptError('');
}}
onSubmit={handleBannerSessionSubmit}
onCancel={handleBannerSessionCancel}
/>
</> </>
); );
} }
+97
View File
@@ -0,0 +1,97 @@
'use client';
interface StorageSessionPromptProps {
open: boolean;
isSubmitting: boolean;
password: string;
error: string;
title?: string;
description?: string;
onPasswordChange: (password: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onCancel: () => void;
}
export function StorageSessionPrompt({
open,
isSubmitting,
password,
error,
title = 'Confirm your password',
description = 'Please confirm your password to continue uploading to your storage.',
onPasswordChange,
onSubmit,
onCancel,
}: StorageSessionPromptProps) {
if (!open) {
return null;
}
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
padding: '20px',
}}
onClick={onCancel}
>
<div
className="card"
style={{ width: '100%', maxWidth: '420px', padding: '20px' }}
onClick={(event) => event.stopPropagation()}
>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
{title}
</h3>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px', lineHeight: 1.5 }}>
{description}
</p>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: '12px' }}>
<label
htmlFor="storage-session-password"
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 500,
}}
>
Password
</label>
<input
id="storage-session-password"
type="password"
className="input"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
autoFocus
/>
</div>
{error && (
<div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>
{error}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Confirming...' : 'Continue'}
</button>
</div>
</form>
</div>
</div>
);
}
+113 -60
View File
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { refreshStorageSession } from '@/lib/storage/client';
interface UserStorageImageUploadProps { interface UserStorageImageUploadProps {
label: string; label: string;
@@ -25,6 +27,11 @@ export function UserStorageImageUpload({
}: UserStorageImageUploadProps) { }: UserStorageImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [showSessionPrompt, setShowSessionPrompt] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [promptError, setPromptError] = useState('');
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
const resetFileInput = () => { const resetFileInput = () => {
if (inputRef.current) { if (inputRef.current) {
@@ -32,7 +39,7 @@ export function UserStorageImageUpload({
} }
}; };
const uploadFile = async (file: File) => { const uploadFile = async (file: File, allowPrompt = true) => {
setIsUploading(true); setIsUploading(true);
try { try {
@@ -48,8 +55,11 @@ export function UserStorageImageUpload({
if (!res.ok || !data.url) { if (!res.ok || !data.url) {
const message = data.error || 'Upload failed'; const message = data.error || 'Upload failed';
if (res.status === 401) { if (res.status === 401 && allowPrompt) {
throw new Error(message || 'Upload session expired. Please sign in again.'); setPendingFile(file);
setPromptError('');
setShowSessionPrompt(true);
return;
} }
throw new Error(message); throw new Error(message);
@@ -57,6 +67,10 @@ export function UserStorageImageUpload({
onChange(data.media?.url || data.url); onChange(data.media?.url || data.url);
onError?.(''); onError?.('');
setPendingFile(null);
setShowSessionPrompt(false);
setPassword('');
setPromptError('');
} catch (error) { } catch (error) {
onError?.(error instanceof Error ? error.message : 'Upload failed'); onError?.(error instanceof Error ? error.message : 'Upload failed');
} finally { } finally {
@@ -71,70 +85,109 @@ export function UserStorageImageUpload({
await uploadFile(file); await uploadFile(file);
}; };
const handlePromptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pendingFile) {
setShowSessionPrompt(false);
return;
}
if (!password.trim()) {
setPromptError('Please enter your password');
return;
}
setIsRefreshingSession(true);
setPromptError('');
try {
await refreshStorageSession(password.trim());
await uploadFile(pendingFile, false);
} catch (error) {
setPromptError(error instanceof Error ? error.message : 'Failed to confirm password');
} finally {
setIsRefreshingSession(false);
}
};
const handlePromptCancel = () => {
setShowSessionPrompt(false);
setPendingFile(null);
setPassword('');
setPromptError('');
resetFileInput();
};
return ( return (
<div> <>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> <div>
{label} <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
</label> {label}
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}>
{isUploading ? 'Uploading...' : 'Choose File'}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
style={{ display: 'none' }}
/>
</label> </label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
{value && ( <label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}>
<div {isUploading ? 'Uploading...' : 'Choose File'}
style={{ <input
width: `${previewWidth}px`, ref={inputRef}
height: `${previewHeight}px`, type="file"
borderRadius: previewBorderRadius, accept="image/*"
overflow: 'hidden', onChange={handleFileChange}
border: '1px solid var(--border)', disabled={isUploading}
background: 'var(--background-tertiary)', style={{ display: 'none' }}
}}
>
<img
src={value}
alt={`${label} preview`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
</div> </label>
)}
{value && ( {value && (
<button <div
type="button" style={{
onClick={() => onChange('')} width: `${previewWidth}px`,
className="btn btn-ghost btn-sm" height: `${previewHeight}px`,
style={{ color: 'var(--error)' }} borderRadius: previewBorderRadius,
> overflow: 'hidden',
Remove border: '1px solid var(--border)',
</button> background: 'var(--background-tertiary)',
}}
>
<img
src={value}
alt={`${label} preview`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
)}
{value && (
<button
type="button"
onClick={() => onChange('')}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
{helperText && (
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
{helperText}
</p>
)} )}
</div> </div>
{helperText && ( <StorageSessionPrompt
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}> open={showSessionPrompt}
{helperText} isSubmitting={isRefreshingSession}
</p> password={password}
)} error={promptError}
onPasswordChange={(nextPassword) => {
<p setPassword(nextPassword);
style={{ setPromptError('');
fontSize: '12px',
color: 'var(--foreground-tertiary)',
marginTop: '6px',
}} }}
> onSubmit={handlePromptSubmit}
If uploads stop working after a long session, sign in again to refresh your upload access. onCancel={handlePromptCancel}
</p> />
</div> </>
); );
} }
+13
View File
@@ -0,0 +1,13 @@
export async function refreshStorageSession(password: string): Promise<void> {
const res = await fetch('/api/storage/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to confirm password');
}
}