Public Access
1
0

chore: reorganize frontend into app and admin roots

This commit is contained in:
pguerrerox
2026-05-30 00:45:06 +00:00
parent d71f2f1f8a
commit a926d06b54
39 changed files with 76 additions and 49 deletions
+12
View File
@@ -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>
+258
View File
@@ -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>
);
}
+10
View File
@@ -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
+113
View File
@@ -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),
});
}
+25
View File
@@ -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',
},
});