chore: reorganize frontend into app and admin roots
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leads4less Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,258 @@
|
||||
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AdminBootstrapStatusResponse, SessionUser } from '@/shared/types';
|
||||
import { AdminPage } from './components/AdminPage';
|
||||
import { Alert, Button, Card, FieldLabel, Input } from '@/app/src/components/ui';
|
||||
import {
|
||||
claimAdminBootstrap,
|
||||
getAdminBootstrapStatus,
|
||||
getLocalSessionUser,
|
||||
signInWithLocalAuth,
|
||||
signOutWithLocalAuth,
|
||||
} from '@/app/src/lib/auth';
|
||||
import { hasApiConfig } from '@/app/src/lib/api';
|
||||
|
||||
export function AdminPortal() {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bootstrapToken, setBootstrapToken] = useState('');
|
||||
const [bootstrapStatus, setBootstrapStatus] = useState<AdminBootstrapStatusResponse | null>(null);
|
||||
const [bootstrapLoading, setBootstrapLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
const sessionUser = await getLocalSessionUser();
|
||||
if (isMounted) {
|
||||
setUser(sessionUser);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (isMounted) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load session.');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadBootstrapStatus = async () => {
|
||||
setBootstrapLoading(true);
|
||||
try {
|
||||
const status = await getAdminBootstrapStatus();
|
||||
if (isMounted) {
|
||||
setBootstrapStatus(status);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (isMounted) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load bootstrap status.');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setBootstrapLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadBootstrapStatus();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
||||
|
||||
const handleSignIn = async () => {
|
||||
if (isBootstrapRequired && bootstrapLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isBootstrapRequired) {
|
||||
const nextUser = await claimAdminBootstrap({
|
||||
email,
|
||||
password,
|
||||
displayName: displayName.trim() || undefined,
|
||||
bootstrapToken,
|
||||
});
|
||||
setUser(nextUser);
|
||||
setNotice('First site admin created and signed in.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUser = await signInWithLocalAuth({ email, password });
|
||||
setUser(nextUser);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Authentication failed.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
const sessionId = user?.sessionId;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setUser(null);
|
||||
|
||||
try {
|
||||
await signOutWithLocalAuth(sessionId);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to sign out.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <FullscreenCard title="Loading admin portal..." />;
|
||||
}
|
||||
|
||||
if (!hasApiConfig) {
|
||||
return (
|
||||
<FullscreenCard title="Local API config required">
|
||||
<p className="text-sm text-stone-600">Set VITE_API_BASE_URL and restart the admin frontend.</p>
|
||||
</FullscreenCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2 text-white">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-900">LocaleScope Admin</h1>
|
||||
<p className="text-sm text-stone-500">Sign in to access admin operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? <Alert variant="success">{notice}</Alert> : null}
|
||||
{error ? <Alert variant="error" className="mt-3">{error}</Alert> : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input id="admin-email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
autoComplete={isBootstrapRequired ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isBootstrapRequired ? (
|
||||
<>
|
||||
<div>
|
||||
<FieldLabel>Display name</FieldLabel>
|
||||
<Input
|
||||
id="admin-display-name"
|
||||
autoComplete="name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Bootstrap token</FieldLabel>
|
||||
<Input
|
||||
id="admin-bootstrap-token"
|
||||
type="password"
|
||||
value={bootstrapToken}
|
||||
onChange={(event) => setBootstrapToken(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{isBootstrapRequired && !bootstrapLoading ? (
|
||||
<Alert variant="info">Bootstrap is required because no active app admin exists.</Alert>
|
||||
) : null}
|
||||
<Button type="button" className="w-full" disabled={isSubmitting || bootstrapLoading} onClick={() => void handleSignIn()}>
|
||||
{isSubmitting ? (isBootstrapRequired ? 'Creating admin...' : 'Signing in...') : isBootstrapRequired ? 'Create first site admin' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return (
|
||||
<FullscreenCard title="Access denied">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-stone-600">Your account does not have admin access.</p>
|
||||
{error ? <Alert variant="error">{error}</Alert> : null}
|
||||
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</FullscreenCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="border-b border-stone-200 bg-white px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">LocaleScope</p>
|
||||
<p className="text-lg font-semibold text-stone-900">Admin Portal</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<AdminPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenCard({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
|
||||
<Card className="w-full max-w-xl p-6">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2 text-white">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-900">{title}</h1>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AdminPortal } from './AdminPortal';
|
||||
import '@/app/src/index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AdminPortal />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
AdminAuditLogFilters,
|
||||
AdminAuditLogListResponse,
|
||||
AdminBillingResyncRequest,
|
||||
AdminBillingResyncResponse,
|
||||
AdminApplicationAdminResponse,
|
||||
AdminApplicationAdminsListResponse,
|
||||
AdminAnalyticsSummary,
|
||||
AdminSecurityPostureResponse,
|
||||
AdminSupportDiagnosticsResponse,
|
||||
ApplicationAdminStatus,
|
||||
BillingAdminWorkspaceDetailResponse,
|
||||
BillingAdminWorkspaceListResponse,
|
||||
} from '@/shared/types';
|
||||
import { apiRequest } from '@/app/src/lib/api';
|
||||
|
||||
export async function getAdminAnalyticsSummary(days = 30) {
|
||||
const params = new URLSearchParams({ days: String(days) });
|
||||
const response = await apiRequest<{ summary: AdminAnalyticsSummary }>(`/admin/analytics/summary?${params.toString()}`);
|
||||
return response.summary;
|
||||
}
|
||||
|
||||
export async function listAdminBillingWorkspaces(query?: string) {
|
||||
const search = query?.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
|
||||
return apiRequest<BillingAdminWorkspaceListResponse>(`/admin/billing/workspaces${search}`);
|
||||
}
|
||||
|
||||
export async function getAdminBillingWorkspaceDetail(workspaceId: string) {
|
||||
return apiRequest<BillingAdminWorkspaceDetailResponse>(`/admin/billing/workspaces/${workspaceId}`);
|
||||
}
|
||||
|
||||
export async function listApplicationAdmins() {
|
||||
return apiRequest<AdminApplicationAdminsListResponse>('/admin/access/admins');
|
||||
}
|
||||
|
||||
export async function upsertApplicationAdmin(email: string) {
|
||||
return apiRequest<AdminApplicationAdminResponse>('/admin/access/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateApplicationAdminStatus(adminId: string, status: ApplicationAdminStatus) {
|
||||
return apiRequest<AdminApplicationAdminResponse>(`/admin/access/admins/${adminId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminAuditLogs(filters: AdminAuditLogFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.actorEmail?.trim()) {
|
||||
params.set('actorEmail', filters.actorEmail.trim());
|
||||
}
|
||||
|
||||
if (filters.action?.trim()) {
|
||||
params.set('action', filters.action.trim());
|
||||
}
|
||||
|
||||
if (filters.workspaceId?.trim()) {
|
||||
params.set('workspaceId', filters.workspaceId.trim());
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
params.set('from', filters.from);
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
params.set('to', filters.to);
|
||||
}
|
||||
|
||||
if (typeof filters.page === 'number') {
|
||||
params.set('page', String(filters.page));
|
||||
}
|
||||
|
||||
if (typeof filters.pageSize === 'number') {
|
||||
params.set('pageSize', String(filters.pageSize));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiRequest<AdminAuditLogListResponse>(`/admin/ops/audit${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getAdminSecurityPosture() {
|
||||
return apiRequest<AdminSecurityPostureResponse>('/admin/ops/security-posture');
|
||||
}
|
||||
|
||||
export async function getAdminSupportDiagnostics(query?: { windowDays?: number; staleSyncThresholdHours?: number; sampleLimit?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (typeof query?.windowDays === 'number') {
|
||||
params.set('windowDays', String(query.windowDays));
|
||||
}
|
||||
|
||||
if (typeof query?.staleSyncThresholdHours === 'number') {
|
||||
params.set('staleSyncThresholdHours', String(query.staleSyncThresholdHours));
|
||||
}
|
||||
|
||||
if (typeof query?.sampleLimit === 'number') {
|
||||
params.set('sampleLimit', String(query.sampleLimit));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiRequest<AdminSupportDiagnosticsResponse>(`/admin/ops/diagnostics${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export async function requestAdminBillingResync(payload: AdminBillingResyncRequest) {
|
||||
return apiRequest<AdminBillingResyncResponse>('/admin/mutations/billing/resync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: path.resolve(__dirname),
|
||||
envDir: path.resolve(__dirname, '..'),
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '..'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['project-1.duramente.com'],
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist-admin',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user