Make Stuffbox the exclusive media provider and simplify encrypted messaging copy
Hop-State: A_06FPCP16ZEAQDRH1JF5JRZG Hop-Proposal: R_06FPCP0618KWV4X8SK82048 Hop-Task: T_06FPCM7MFZHQDCR4JBRHMG0 Hop-Attempt: AT_06FPCM7MFW92T7J4T312R08
This commit is contained in:
@@ -3,11 +3,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AutoTextarea from '@/components/AutoTextarea';
|
||||
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
|
||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||
import { refreshStorageSession } from '@/lib/storage/client';
|
||||
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||
|
||||
@@ -35,12 +33,8 @@ export default function AdminPage() {
|
||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
|
||||
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
||||
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
|
||||
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = 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 [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
|
||||
@@ -127,21 +121,12 @@ export default function AdminPage() {
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
setPendingBannerFile(null);
|
||||
setShowBannerSessionPrompt(false);
|
||||
setBannerPassword('');
|
||||
setBannerPromptError('');
|
||||
} catch (error) {
|
||||
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
|
||||
setPendingBannerFile(file);
|
||||
setShowBannerStorageConfiguration(true);
|
||||
return;
|
||||
}
|
||||
if (error instanceof MediaUploadError && error.status === 401 && allowPrompt) {
|
||||
setPendingBannerFile(file);
|
||||
setBannerPromptError('');
|
||||
setShowBannerSessionPrompt(true);
|
||||
return;
|
||||
}
|
||||
console.error('Banner upload failed', error);
|
||||
setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
|
||||
} finally {
|
||||
@@ -174,39 +159,6 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
@@ -621,19 +573,6 @@ export default function AdminPage() {
|
||||
</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}
|
||||
/>
|
||||
<StorageConfigurationPrompt
|
||||
open={showBannerStorageConfiguration}
|
||||
onConfigured={async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { authenticateUser, createSession } from '@/lib/auth';
|
||||
import { createStorageSession } from '@/lib/storage/session';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -31,7 +30,6 @@ export async function POST(request: Request) {
|
||||
|
||||
// Create session
|
||||
await createSession(user.id);
|
||||
await createStorageSession(user, data.password);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { destroySession, getSession } from '@/lib/auth';
|
||||
import { clearStorageSession } from '@/lib/storage/session';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logoutSchema = z.object({
|
||||
@@ -15,10 +14,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const targetUserId = data.userId ?? currentSession?.user.id;
|
||||
|
||||
if (targetUserId) {
|
||||
await clearStorageSession(targetUserId);
|
||||
}
|
||||
|
||||
await destroySession(targetUserId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
BotHandleTakenError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||
import { createStorageSession, getStorageSession } from '@/lib/storage/session';
|
||||
|
||||
// Schema for creating a bot
|
||||
const createBotSchema = z.object({
|
||||
@@ -45,7 +43,6 @@ const createBotSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
}).optional(),
|
||||
autonomousMode: z.boolean().optional(),
|
||||
ownerPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -62,37 +59,11 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const data = createBotSchema.parse(body);
|
||||
|
||||
const storageSession =
|
||||
(await getStorageSession(user.id)) ||
|
||||
(data.ownerPassword ? await createStorageSession(user, data.ownerPassword) : null);
|
||||
|
||||
// Generate bot avatar using owner's S3 storage if no avatar URL
|
||||
let botAvatarUrl: string | null | undefined = data.avatarUrl;
|
||||
if (!botAvatarUrl && storageSession) {
|
||||
try {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
|
||||
|
||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
botHandle,
|
||||
storageSession.endpoint || undefined,
|
||||
storageSession.publicBaseUrl || undefined,
|
||||
storageSession.region || 'us-east-1',
|
||||
storageSession.bucket,
|
||||
storageSession.accessKeyId,
|
||||
storageSession.secretAccessKey
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Bot API] Failed to generate bot avatar:', err);
|
||||
// Continue without avatar - user can set it later
|
||||
}
|
||||
}
|
||||
|
||||
const bot = await createBot(user.id, {
|
||||
name: data.name,
|
||||
handle: data.handle,
|
||||
bio: data.bio,
|
||||
avatarUrl: botAvatarUrl ?? undefined,
|
||||
avatarUrl: data.avatarUrl,
|
||||
headerUrl: data.headerUrl,
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { getStorageSession, createStorageSession } from '@/lib/storage/session';
|
||||
import { uploadWithStorageCredentials } from '@/lib/storage/s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getMaxMediaSize, getMediaKind } from '@/lib/media/upload-policy';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
const password = formData.get('password') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const mediaKind = getMediaKind(file.type);
|
||||
|
||||
if (mediaKind === 'unsupported') {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Upload an image, video, MP3, M4A, AAC, WAV, OGG, or FLAC file.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file size based on type
|
||||
const maxSize = getMaxMediaSize(file.type);
|
||||
if (maxSize !== null && file.size > maxSize) {
|
||||
return NextResponse.json({
|
||||
error: `File too large. Maximum size: ${mediaKind === 'image' ? '10MB' : '100MB'}`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has S3 storage configured
|
||||
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
|
||||
return NextResponse.json({
|
||||
error: 'Connect your storage before uploading media.',
|
||||
code: 'STORAGE_NOT_CONFIGURED',
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const storageSession =
|
||||
(await getStorageSession(user.id)) ||
|
||||
(password ? await createStorageSession(user, password) : null);
|
||||
|
||||
if (!storageSession) {
|
||||
return NextResponse.json({
|
||||
error: 'Upload session expired. Please sign in again.'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// Upload to user's own S3-compatible storage
|
||||
const uploadResult = await uploadWithStorageCredentials(
|
||||
buffer,
|
||||
filename,
|
||||
file.type,
|
||||
storageSession.provider,
|
||||
storageSession.endpoint,
|
||||
storageSession.publicBaseUrl,
|
||||
storageSession.region,
|
||||
storageSession.bucket,
|
||||
storageSession.accessKeyId,
|
||||
storageSession.secretAccessKey
|
||||
);
|
||||
|
||||
// Store media record with S3 URL
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url: uploadResult.url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
height: 0,
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Invalid storage password') {
|
||||
return NextResponse.json({
|
||||
error: 'Incorrect password. Please try again.'
|
||||
}, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Storage')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireAuth: vi.fn(),
|
||||
configuredStuffboxUrl: vi.fn(),
|
||||
getStuffboxConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ requireAuth: mocks.requireAuth }));
|
||||
vi.mock('@/lib/stuffbox/client', () => ({ configuredStuffboxUrl: mocks.configuredStuffboxUrl }));
|
||||
vi.mock('@/lib/stuffbox/tokens', () => ({ getStuffboxConnection: mocks.getStuffboxConnection }));
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
describe('GET /api/storage/configuration', () => {
|
||||
beforeEach(() => {
|
||||
mocks.requireAuth.mockReset();
|
||||
mocks.configuredStuffboxUrl.mockReset();
|
||||
mocks.getStuffboxConnection.mockReset();
|
||||
mocks.configuredStuffboxUrl.mockReturnValue('https://stuffbox.example');
|
||||
mocks.getStuffboxConnection.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not expose legacy S3 credentials as an active storage provider', async () => {
|
||||
mocks.requireAuth.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
storageProvider: 's3',
|
||||
storageBucket: 'legacy-bucket',
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
provider: null,
|
||||
stuffboxAvailable: true,
|
||||
stuffboxBaseUrl: null,
|
||||
stuffboxUpdatedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db, users } from '@/db';
|
||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||
import { testS3Credentials, type StorageProvider } from '@/lib/storage/s3';
|
||||
import { createStorageSession } from '@/lib/storage/session';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { configuredStuffboxUrl } from '@/lib/stuffbox/client';
|
||||
import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
|
||||
|
||||
const configurationSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
provider: z.enum(['s3', 'r2', 'b2', 'wasabi', 'contabo']),
|
||||
endpoint: z.string().trim().nullable().optional(),
|
||||
publicBaseUrl: z.string().trim().nullable().optional(),
|
||||
region: z.string().trim().min(2),
|
||||
bucket: z.string().trim().min(3),
|
||||
accessKey: z.string().min(10),
|
||||
secretKey: z.string().min(10),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const stuffbox = await getStuffboxConnection(user.id);
|
||||
return NextResponse.json({
|
||||
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
|
||||
provider: stuffbox ? 'stuffbox' : null,
|
||||
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
|
||||
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
|
||||
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
|
||||
s3Provider: user.storageProvider ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
@@ -39,57 +21,3 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const data = configurationSchema.parse(await request.json());
|
||||
|
||||
if (!user.passwordHash || !(await verifyPassword(data.password, user.passwordHash))) {
|
||||
return NextResponse.json({ error: 'Incorrect account password' }, { status: 401 });
|
||||
}
|
||||
|
||||
const storageTest = await testS3Credentials(
|
||||
data.endpoint || null,
|
||||
data.region,
|
||||
data.bucket,
|
||||
data.accessKey,
|
||||
data.secretKey
|
||||
);
|
||||
|
||||
if (!storageTest.success) {
|
||||
return NextResponse.json(
|
||||
{ error: `Storage connection failed: ${storageTest.error}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const storageProvider = data.provider as StorageProvider;
|
||||
const storageAccessKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.accessKey, data.password));
|
||||
const storageSecretKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.secretKey, data.password));
|
||||
const storageValues = {
|
||||
storageProvider,
|
||||
storageEndpoint: data.endpoint || null,
|
||||
storagePublicBaseUrl: data.publicBaseUrl || null,
|
||||
storageRegion: data.region,
|
||||
storageBucket: data.bucket,
|
||||
storageAccessKeyEncrypted,
|
||||
storageSecretKeyEncrypted,
|
||||
};
|
||||
|
||||
await db.update(users).set(storageValues).where(eq(users.id, user.id));
|
||||
await createStorageSession({ ...user, ...storageValues }, data.password);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid storage configuration', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
console.error('Storage configuration error:', error);
|
||||
return NextResponse.json({ error: 'Failed to configure storage' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth, verifyPassword } from '@/lib/auth';
|
||||
import { clearStorageSession, createStorageSession } from '@/lib/storage/session';
|
||||
|
||||
const bodySchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = await request.json();
|
||||
const data = bodySchema.parse(body);
|
||||
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(data.password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 });
|
||||
}
|
||||
|
||||
await createStorageSession(user, data.password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
console.error('Storage session error:', error);
|
||||
return NextResponse.json({ error: 'Failed to refresh upload session' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
await clearStorageSession();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -988,7 +988,7 @@ export default function ChatPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: '11px', color: 'var(--foreground-secondary)' }}>
|
||||
<LockKeyhole size={11} aria-hidden="true" />
|
||||
<span>{selectedEncryptionReady
|
||||
? 'End-to-end encryption ready'
|
||||
? 'End-to-end encrypted messaging'
|
||||
: selectedEncryptionError
|
||||
? 'Encryption unavailable'
|
||||
: 'Verifying encryption…'}</span>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function SettingsPage() {
|
||||
Media Storage
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Connect Stuffbox or an S3-compatible bucket
|
||||
Connect and manage Stuffbox
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Box, Cloud, ExternalLink, HardDrive } from 'lucide-react';
|
||||
import { Box, ExternalLink } from 'lucide-react';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||
|
||||
interface StorageStatus {
|
||||
provider: 'stuffbox' | 's3' | null;
|
||||
provider: 'stuffbox' | null;
|
||||
stuffboxAvailable: boolean;
|
||||
stuffboxBaseUrl: string | null;
|
||||
s3Provider: string | null;
|
||||
}
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
@@ -68,10 +67,9 @@ export default function StorageSettingsPage() {
|
||||
|
||||
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />}
|
||||
<Box size={22} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : status.provider === 's3' ? 'S3 storage connected' : 'No media storage connected'}</div>
|
||||
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
|
||||
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : 'Stuffbox not connected'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{status?.provider === 'stuffbox' && (
|
||||
@@ -89,10 +87,10 @@ export default function StorageSettingsPage() {
|
||||
{status?.provider !== 'stuffbox' && (
|
||||
<div className="card" style={{ padding: '20px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
{status?.provider ? 'Change storage' : 'Connect media storage'}
|
||||
Connect Stuffbox
|
||||
</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
|
||||
Choose Stuffbox for the simplest setup, or connect an S3-compatible bucket you already own.
|
||||
Connect the official Synapsis storage partner to upload portable media from your account.
|
||||
</p>
|
||||
<StorageConfigurationPrompt open onConfigured={loadStatus} onCancel={() => {}} variant="inline" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user