refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness
DEPLOY_ENV=production now marks the primary instance globally - used for Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour of the runtime API response (isProduction field in /api/auth/config).
This commit is contained in:
@ -190,8 +190,8 @@ msg_info "Creating .env file for each instance"
|
|||||||
for d in "${APP_DIR}" "${DEV_DIR}"; do
|
for d in "${APP_DIR}" "${DEV_DIR}"; do
|
||||||
SECRET="$(openssl rand -hex 32)"
|
SECRET="$(openssl rand -hex 32)"
|
||||||
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
|
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
|
||||||
# Only the production instance owns the shared Caddy (one Caddy per container).
|
# Only the production instance owns Caddy and shows "Production" in the UI.
|
||||||
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env"
|
[[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
|
||||||
done
|
done
|
||||||
msg_ok ".env files created (main + dev)"
|
msg_ok ".env files created (main + dev)"
|
||||||
|
|
||||||
|
|||||||
20
server.ts
20
server.ts
@ -15,10 +15,10 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
|
||||||
const JWT_EXPIRY = '24h';
|
const JWT_EXPIRY = '24h';
|
||||||
|
|
||||||
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true
|
// DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config,
|
||||||
// owns it (pushes config, seeds routes, accepts route edits). The other instance
|
// seeds routes, accepts route edits) and shows "Production" in the UI. The dev
|
||||||
// must never push — POST /load replaces the entire config and would clobber it.
|
// instance must never push to Caddy — POST /load replaces the entire config.
|
||||||
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true';
|
const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production';
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -153,7 +153,7 @@ function importCaddyfileRoutes(userId?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pushCaddyConfig(): Promise<void> {
|
async function pushCaddyConfig(): Promise<void> {
|
||||||
if (!IS_CADDY_MANAGER) return;
|
if (!IS_PRODUCTION) return;
|
||||||
if (getSetting('caddy_enabled') !== 'true') return;
|
if (getSetting('caddy_enabled') !== 'true') return;
|
||||||
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
|
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
|
||||||
const body = buildCaddyfile();
|
const body = buildCaddyfile();
|
||||||
@ -180,7 +180,7 @@ async function startServer() {
|
|||||||
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
|
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
|
if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
|
||||||
importCaddyfileRoutes();
|
importCaddyfileRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,7 +277,7 @@ async function startServer() {
|
|||||||
effectiveRedirectUri,
|
effectiveRedirectUri,
|
||||||
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
||||||
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
||||||
caddyManaged: IS_CADDY_MANAGER,
|
isProduction: IS_PRODUCTION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1205,7 +1205,7 @@ async function startServer() {
|
|||||||
|
|
||||||
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
|
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
|
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
|
||||||
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string;
|
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string;
|
||||||
};
|
};
|
||||||
@ -1225,7 +1225,7 @@ async function startServer() {
|
|||||||
|
|
||||||
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
|
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||||
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
|
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
|
||||||
@ -1245,7 +1245,7 @@ async function startServer() {
|
|||||||
|
|
||||||
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||||
const existing = getCaddyRouteById(id);
|
const existing = getCaddyRouteById(id);
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export default function App() {
|
|||||||
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
||||||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
||||||
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
||||||
|
const [isProduction, setIsProduction] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@ -143,7 +144,7 @@ export default function App() {
|
|||||||
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
if (logsRes.ok) setLogs(await logsRes.json());
|
||||||
if (linksRes.ok) setLinks(await linksRes.json());
|
if (linksRes.ok) setLinks(await linksRes.json());
|
||||||
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); }
|
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[App] Failed to load data:', err);
|
console.error('[App] Failed to load data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -484,6 +485,7 @@ export default function App() {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
isProduction={isProduction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col md:flex-row">
|
<div className="flex-1 flex flex-col md:flex-row">
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface HeaderProps {
|
|||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
isProduction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
@ -22,6 +23,7 @@ export default function Header({
|
|||||||
theme,
|
theme,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
isProduction,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const [showMailInbox, setShowMailInbox] = useState(false);
|
const [showMailInbox, setShowMailInbox] = useState(false);
|
||||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||||
@ -63,8 +65,8 @@ export default function Header({
|
|||||||
|
|
||||||
{/* System Indicator */}
|
{/* System Indicator */}
|
||||||
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
|
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
|
||||||
<span className={`w-2 h-2 rounded-full animate-pulse ${(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} />
|
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
||||||
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span>
|
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail Inbox */}
|
{/* Mail Inbox */}
|
||||||
|
|||||||
@ -193,7 +193,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
|
|
||||||
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
||||||
const [caddyManaged, setCaddyManaged] = useState(true);
|
const [caddyManaged, setCaddyManaged] = useState(true);
|
||||||
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
|
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019');
|
||||||
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
|
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
|
||||||
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
|
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
|
||||||
const [addingRoute, setAddingRoute] = useState(false);
|
const [addingRoute, setAddingRoute] = useState(false);
|
||||||
@ -225,7 +225,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
|
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
|
||||||
setCaddyManaged(d.caddyManaged !== false);
|
setCaddyManaged(d.isProduction !== false);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@ -265,7 +265,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
setSemaphoreApiToken('');
|
setSemaphoreApiToken('');
|
||||||
setSemaphoreProjectId(data.semaphore_project_id || '');
|
setSemaphoreProjectId(data.semaphore_project_id || '');
|
||||||
setCaddyEnabled(data.caddy_enabled === 'true');
|
setCaddyEnabled(data.caddy_enabled === 'true');
|
||||||
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
|
setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error loading settings.');
|
setError('Network error loading settings.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -350,7 +350,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const templates = await res.json() as any[];
|
const templates = await res.json() as any[];
|
||||||
setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
|
setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
|
||||||
} catch {
|
} catch {
|
||||||
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
|
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -371,7 +371,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const res = await authFetch('/api/caddy/routes');
|
const res = await authFetch('/api/caddy/routes');
|
||||||
if (res.ok) setCaddyRoutes(await res.json());
|
if (res.ok) setCaddyRoutes(await res.json());
|
||||||
} catch {}
|
} catch {}
|
||||||
// Status check runs separately — purely informational, never blocks the list
|
// Status check runs separately - purely informational, never blocks the list
|
||||||
authFetch('/api/caddy/status')
|
authFetch('/api/caddy/status')
|
||||||
.then(res => res.ok ? res.json() : null)
|
.then(res => res.ok ? res.json() : null)
|
||||||
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
|
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
|
||||||
@ -570,7 +570,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section tabs — switch between Integrations and System to keep the page light */}
|
{/* Section tabs - switch between Integrations and System to keep the page light */}
|
||||||
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
|
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
|
||||||
{([
|
{([
|
||||||
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
|
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
|
||||||
@ -748,7 +748,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
|
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
|
||||||
<Input
|
<Input
|
||||||
value={checkmkApiUser}
|
value={checkmkApiUser}
|
||||||
onChange={setCheckmkApiUser}
|
onChange={setCheckmkApiUser}
|
||||||
@ -944,19 +944,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
|
|
||||||
{caddyManaged && (
|
{caddyManaged && (
|
||||||
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||||
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
|
<FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
|
||||||
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
|
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://172.0.0.1:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
|
||||||
{/* Route list */}
|
{/* Route list */}
|
||||||
{caddyEnabled && (
|
{caddyEnabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Proxy Routes</Label>
|
<Label>Proxy Routes</Label>
|
||||||
<Hint>Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified.</Hint>
|
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
|
||||||
|
|
||||||
{caddyStatus === 'unavailable' && (
|
{caddyStatus === 'unavailable' && (
|
||||||
<p className="text-[11px] font-mono text-amber-400 mb-2">
|
<p className="text-[11px] font-mono text-amber-400 mb-2">
|
||||||
Caddy Admin API not reachable — routes will be applied when Caddy starts.
|
Caddy Admin API not reachable - routes will be applied when Caddy starts.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1003,7 +1003,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Input value={editRedirect} onChange={setEditRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
|
<Input value={editRedirect} onChange={setEditRedirect} placeholder="root redirect" monospace />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -1071,7 +1071,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
{addingRoute ? 'Adding…' : 'Add'}
|
{addingRoute ? 'Adding…' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
|
<Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /site1/" monospace />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1095,7 +1095,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xl font-bold text-white font-mono leading-none">
|
<p className="text-xl font-bold text-white font-mono leading-none">
|
||||||
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'}
|
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-slate-500 font-mono mt-1">
|
<p className="text-[10px] text-slate-500 font-mono mt-1">
|
||||||
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
||||||
@ -1172,7 +1172,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
|
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-amber-300 leading-relaxed">
|
<p className="text-[11px] text-amber-300 leading-relaxed">
|
||||||
<strong>Import overwrites the entire database</strong> — this cannot be undone.
|
<strong>Import overwrites the entire database</strong> - this cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
|
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
|
||||||
|
|||||||
Reference in New Issue
Block a user