Add Stuffbox account connection, encrypted token lifecycle, direct media uploads, storage settings, and S3 fallback
Hop-State: A_06FP7VQ2F5KZQAY57YEBZV8 Hop-Proposal: R_06FP7VPDD7MJH2Z0013D7SR Hop-Task: T_06FP7QPNG91NT6WBGJD5MJG Hop-Attempt: AT_06FP7QPNGAEEAEHP3Q7CNEG
This commit is contained in:
+14
-24
@@ -8,6 +8,7 @@ import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPro
|
||||
import { useToast } from '@/lib/contexts/ToastContext';
|
||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||
import { refreshStorageSession } from '@/lib/storage/client';
|
||||
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { showToast } = useToast();
|
||||
@@ -105,33 +106,11 @@ export default function AdminPage() {
|
||||
setIsUploadingBanner(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.url) {
|
||||
if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
|
||||
setPendingBannerFile(file);
|
||||
setShowBannerStorageConfiguration(true);
|
||||
return;
|
||||
}
|
||||
if (res.status === 401 && allowPrompt) {
|
||||
setPendingBannerFile(file);
|
||||
setBannerPromptError('');
|
||||
setShowBannerSessionPrompt(true);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
const media = await uploadMediaFile(file);
|
||||
|
||||
const nextSettings = {
|
||||
...nodeSettings,
|
||||
bannerUrl: data.media?.url || data.url,
|
||||
bannerUrl: media.url,
|
||||
};
|
||||
setNodeSettings(nextSettings);
|
||||
await handleSaveSettings(nextSettings);
|
||||
@@ -140,6 +119,17 @@ export default function AdminPage() {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { completeUpload, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
|
||||
|
||||
const bodySchema = z.object({ alt: z.string().max(1500).nullable().optional() });
|
||||
type Context = { params: Promise<{ uploadId: string }> };
|
||||
|
||||
export async function POST(request: Request, context: Context) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const body = bodySchema.parse(await request.json().catch(() => ({})));
|
||||
const { uploadId } = await context.params;
|
||||
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
|
||||
const asset = await completeUpload(baseUrl, accessToken, uploadId);
|
||||
const [record] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url: asset.url,
|
||||
storageProvider: 'stuffbox',
|
||||
storageAssetId: asset.id,
|
||||
altText: body.alt ?? null,
|
||||
mimeType: asset.mimeType,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}).returning();
|
||||
return NextResponse.json({ success: true, media: record, asset });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid upload completion' }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
|
||||
return NextResponse.json({ error: 'Stuffbox is disconnected', code: 'STORAGE_NOT_CONFIGURED' }, { status: 409 });
|
||||
}
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
|
||||
}
|
||||
console.error('Stuffbox upload completion error:', error);
|
||||
return NextResponse.json({ error: 'Unable to complete upload' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { createUpload, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
|
||||
|
||||
const uploadSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
mimeType: z.enum([
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime',
|
||||
]),
|
||||
size: z.number().int().positive().max(100 * 1024 * 1024),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
||||
}).superRefine((upload, context) => {
|
||||
if (upload.mimeType.startsWith('image/') && upload.size > 10 * 1024 * 1024) {
|
||||
context.addIssue({ code: 'too_big', maximum: 10 * 1024 * 1024, origin: 'number', inclusive: true, path: ['size'], message: 'Images must be 10MB or smaller' });
|
||||
}
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const input = uploadSchema.parse(await request.json());
|
||||
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
|
||||
const upload = await createUpload(baseUrl, accessToken, input);
|
||||
return NextResponse.json(upload, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid upload', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
|
||||
return NextResponse.json({
|
||||
error: 'Connect Stuffbox before uploading media.',
|
||||
code: 'STORAGE_NOT_CONFIGURED',
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
|
||||
}
|
||||
console.error('Stuffbox upload-session error:', error);
|
||||
return NextResponse.json({ error: 'Unable to create upload session' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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 { configuredStuffboxUrl } from '@/lib/stuffbox/client';
|
||||
import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
|
||||
|
||||
const configurationSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
@@ -18,6 +20,25 @@ const configurationSchema = z.object({
|
||||
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,
|
||||
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
|
||||
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
|
||||
s3Provider: user.storageProvider ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Storage status error:', error);
|
||||
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { exchangeAuthorizationCode, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
|
||||
|
||||
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
|
||||
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
|
||||
const targetOrigin = safeJson(origin);
|
||||
const fallback = success ? '/settings/storage?stuffbox=connected' : '/settings/storage?stuffbox=error';
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
|
||||
<p>${success ? 'Stuffbox connected. You can close this window.' : 'Stuffbox could not be connected.'}</p>
|
||||
<script>
|
||||
if (window.opener) { window.opener.postMessage(${payload}, ${targetOrigin}); window.close(); }
|
||||
else { window.location.replace(${safeJson(fallback)}); }
|
||||
</script>
|
||||
</body></html>`;
|
||||
return new NextResponse(html, {
|
||||
status: success ? 200 : 400,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const pending = await consumeStuffboxConnectionState();
|
||||
const code = request.nextUrl.searchParams.get('code');
|
||||
const state = request.nextUrl.searchParams.get('state');
|
||||
const denied = request.nextUrl.searchParams.get('error');
|
||||
|
||||
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.');
|
||||
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
|
||||
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.');
|
||||
}
|
||||
|
||||
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
||||
code,
|
||||
codeVerifier: pending.verifier,
|
||||
redirectUri: pending.redirectUri,
|
||||
});
|
||||
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
||||
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.');
|
||||
} catch (error) {
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return popupResponse(origin, false, error.message);
|
||||
}
|
||||
console.error('Stuffbox callback error:', error);
|
||||
return popupResponse(origin, false, 'Stuffbox could not be connected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { generatePkce } from '@/lib/stuffbox/crypto';
|
||||
import { STUFFBOX_SCOPES } from '@/lib/stuffbox/types';
|
||||
|
||||
function nodeOrigin(request: NextRequest): string {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
if (appUrl) return new URL(appUrl).origin;
|
||||
const configured = process.env.NEXT_PUBLIC_NODE_DOMAIN?.trim();
|
||||
if (!configured) return request.nextUrl.origin;
|
||||
const protocol = /^(localhost|127\.0\.0\.1)(:|$)/.test(configured) ? 'http' : 'https';
|
||||
return new URL(configured.includes('://') ? configured : `${protocol}://${configured}`).origin;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const baseUrl = configuredStuffboxUrl();
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({
|
||||
error: 'Stuffbox is not configured on this node.',
|
||||
code: 'STUFFBOX_UNAVAILABLE',
|
||||
}, { status: 503 });
|
||||
}
|
||||
|
||||
const origin = nodeOrigin(request);
|
||||
const redirectUri = `${origin}/api/storage/stuffbox/callback`;
|
||||
const node = user.nodeId
|
||||
? await db.query.nodes.findFirst({ where: { id: user.nodeId } })
|
||||
: null;
|
||||
const pkce = generatePkce();
|
||||
const connection = await createConnectionRequest(baseUrl, {
|
||||
nodeName: node?.name || new URL(origin).host,
|
||||
callbackUrl: redirectUri,
|
||||
codeChallenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scopes: STUFFBOX_SCOPES,
|
||||
});
|
||||
|
||||
await saveStuffboxConnectionState({
|
||||
userId: user.id,
|
||||
baseUrl,
|
||||
verifier: pkce.verifier,
|
||||
state: pkce.state,
|
||||
redirectUri,
|
||||
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
||||
});
|
||||
|
||||
return NextResponse.json({ authorizationUrl: connection.authorizationUrl });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
|
||||
}
|
||||
console.error('Stuffbox connection error:', error);
|
||||
return NextResponse.json({ error: 'Unable to start Stuffbox connection' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { revokeToken } from '@/lib/stuffbox/client';
|
||||
import {
|
||||
getStuffboxConnection,
|
||||
readStuffboxRefreshToken,
|
||||
removeStuffboxConnection,
|
||||
} from '@/lib/stuffbox/tokens';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const connection = await getStuffboxConnection(user.id);
|
||||
if (connection) {
|
||||
try {
|
||||
await revokeToken(connection.baseUrl, readStuffboxRefreshToken(connection));
|
||||
} catch (error) {
|
||||
console.warn('Stuffbox token revocation failed; removing local connection:', error);
|
||||
}
|
||||
await removeStuffboxConnection(user.id);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Stuffbox disconnect error:', error);
|
||||
return NextResponse.json({ error: 'Unable to disconnect Stuffbox' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Rocket, Shield, Bell, Eye, UserX } from 'lucide-react';
|
||||
import { Rocket, Shield, Bell, Eye, UserX, HardDrive } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -26,6 +26,23 @@ export default function SettingsPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
|
||||
|
||||
<Link href="/settings/storage" className="card" style={{
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
textDecoration: 'none',
|
||||
color: 'var(--foreground)',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<HardDrive size={18} />
|
||||
Media Storage
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Connect Stuffbox or an S3-compatible bucket
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
||||
<Link href="/settings/content" className="card" style={{
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Box, Cloud, HardDrive } from 'lucide-react';
|
||||
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||
|
||||
interface StorageStatus {
|
||||
provider: 'stuffbox' | 's3' | null;
|
||||
stuffboxAvailable: boolean;
|
||||
stuffboxBaseUrl: string | null;
|
||||
s3Provider: string | null;
|
||||
}
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
const [status, setStatus] = useState<StorageStatus | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [showConnect, setShowConnect] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'Unable to load storage settings');
|
||||
setStatus(data);
|
||||
setError('');
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Unable to load storage settings');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadStatus(); }, [loadStatus]);
|
||||
|
||||
const disconnectStuffbox = async () => {
|
||||
if (!window.confirm('Disconnect Stuffbox from this Synapsis account? Existing media links will keep working.')) return;
|
||||
setIsDisconnecting(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await fetch('/api/storage/stuffbox/disconnect', { method: 'POST' });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'Unable to disconnect Stuffbox');
|
||||
await loadStatus();
|
||||
} catch (disconnectError) {
|
||||
setError(disconnectError instanceof Error ? disconnectError.message : 'Unable to disconnect Stuffbox');
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<header style={{ padding: '16px', borderBottom: '1px solid var(--border)', position: 'sticky', top: 0, background: 'var(--background)', zIndex: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Link href="/settings" className="btn btn-ghost btn-sm" aria-label="Back to settings"><ArrowLeft size={18} /></Link>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Media Storage</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
||||
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
|
||||
Storage belongs to your account rather than this node. That keeps your media available if you move your account elsewhere.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox connected' : status.provider === 's3' ? 'S3 storage connected' : 'No media storage connected'}</div>
|
||||
{status?.stuffboxBaseUrl && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px', wordBreak: 'break-all' }}>{status.stuffboxBaseUrl}</div>}
|
||||
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '18px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" type="button" onClick={() => setShowConnect(true)} disabled={!status}>{status?.provider ? 'Change storage' : 'Connect storage'}</button>
|
||||
{status?.provider === 'stuffbox' && <button className="btn btn-ghost" type="button" onClick={disconnectStuffbox} disabled={isDisconnecting}>{isDisconnecting ? 'Disconnecting…' : 'Disconnect Stuffbox'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--error)', fontSize: '14px', marginTop: '14px' }}>{error}</p>}
|
||||
</main>
|
||||
<StorageConfigurationPrompt open={showConnect} onConfigured={async () => { setShowConnect(false); await loadStatus(); }} onCancel={() => setShowConnect(false)} />
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user