feat: Mimecast multi-tenant held mail viewer
- migration 062: mimecast_tenants table (company_id, client_id/secret, account_code) - Seed Wulf (CUSA13A95) + Seubert (CUSA96A181) tenants - MimecastClient.getHeldMessages(): full pagination via meta.pagination.next cursor (API always returns 10/page regardless of pageSize param, totalCount in meta) - getMimecastClientForTenant() factory for per-tenant instantiation - GET /api/mimecast/held?tenantId=&recipient= — fetches all tenants in parallel, merges + sorts by date, returns per-tenant counts + combined messages[] - Held Mail tab on /admin/sync/mimecast (on-demand load, recipient filter, tenant badges, policy filter dropdown, DMARC/impersonation highlighted red)
This commit is contained in:
parent
a98c0daf15
commit
fcdec8e38b
12 changed files with 2208 additions and 27 deletions
1074
.firecrawl/mimecast-hold-docs.md
Normal file
1074
.firecrawl/mimecast-hold-docs.md
Normal file
File diff suppressed because it is too large
Load diff
1
.firecrawl/mimecast-hold.json
Normal file
1
.firecrawl/mimecast-hold.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
||||||
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
||||||
Clock, ChevronDown, ChevronRight,
|
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
||||||
|
PauseCircle, Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||||
|
|
||||||
|
|
@ -288,6 +289,117 @@ function ThreatsTab() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cloud Users Tab ───────────────────────────────────────────────────────────
|
||||||
|
function CloudUserTab() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [domain, setDomain] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
|
||||||
|
const handleEmailChange = (v: string) => {
|
||||||
|
setEmail(v);
|
||||||
|
const atIdx = v.indexOf('@');
|
||||||
|
if (atIdx >= 0) setDomain(v.slice(atIdx + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookup = async () => {
|
||||||
|
if (!email || !domain) return;
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
setShowRaw(false);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ emailAddress: email, domain });
|
||||||
|
const res = await fetch(`/api/mimecast/cloud-user?${params}`);
|
||||||
|
setResult(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
setResult({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = result?.user;
|
||||||
|
const lockedOut = user?.lockedOut ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="user@domain.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => handleEmailChange(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && lookup()}
|
||||||
|
className="border rounded-md px-3 py-1.5 text-sm bg-background w-72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Domain</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="domain.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={e => setDomain(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && lookup()}
|
||||||
|
className="border rounded-md px-3 py-1.5 text-sm bg-background w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={lookup} disabled={loading || !email || !domain}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Search className="w-4 h-4 mr-1" />}
|
||||||
|
Look Up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result?.error && (
|
||||||
|
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">
|
||||||
|
{result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.error && !result.found && (
|
||||||
|
<div className="rounded-lg border p-4 text-sm text-muted-foreground">
|
||||||
|
User not found in Mimecast Cloud Gateway.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className={`rounded-lg border p-4 flex items-center gap-3 ${lockedOut ? 'border-red-400 bg-red-50 dark:bg-red-950/20' : 'border-green-400 bg-green-50 dark:bg-green-950/20'}`}>
|
||||||
|
{lockedOut
|
||||||
|
? <LockKeyhole className="w-5 h-5 text-red-600 shrink-0" />
|
||||||
|
: <UnlockKeyhole className="w-5 h-5 text-green-600 shrink-0" />}
|
||||||
|
<div>
|
||||||
|
<p className={`font-semibold text-sm ${lockedOut ? 'text-red-700' : 'text-green-700'}`}>
|
||||||
|
{lockedOut ? 'Account Locked Out' : 'Account Active'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.name && <span>{user.name} · </span>}
|
||||||
|
{user.emailAddress}
|
||||||
|
{user.status && <span> · Status: {user.status}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setShowRaw(v => !v)}
|
||||||
|
>
|
||||||
|
{showRaw ? 'Hide' : 'Show'} raw response
|
||||||
|
</button>
|
||||||
|
{showRaw && (
|
||||||
|
<pre className="rounded-lg border bg-muted/30 p-3 text-xs overflow-auto max-h-72">
|
||||||
|
{JSON.stringify(user._raw ?? user, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── History Tab ───────────────────────────────────────────────────────────────
|
// ── History Tab ───────────────────────────────────────────────────────────────
|
||||||
function HistoryTab() {
|
function HistoryTab() {
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
|
@ -346,6 +458,177 @@ function HistoryTab() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Held Mail Tab ─────────────────────────────────────────────────────────────
|
||||||
|
function HeldMailTab() {
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
const [tenantFilter, setTenantFilter] = useState('');
|
||||||
|
const [policyFilter, setPolicyFilter] = useState('');
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const load = (recipientVal?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const r = recipientVal ?? recipient;
|
||||||
|
if (r) params.set('recipient', r);
|
||||||
|
if (tenantFilter) params.set('tenantId', tenantFilter);
|
||||||
|
fetch(`/api/mimecast/held?${params}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(d => { setData(d); setLoaded(true); })
|
||||||
|
.catch(() => setData(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages: any[] = data?.messages ?? [];
|
||||||
|
const filtered = policyFilter
|
||||||
|
? messages.filter(m => m.policyInfo?.toLowerCase().includes(policyFilter.toLowerCase()))
|
||||||
|
: messages;
|
||||||
|
|
||||||
|
const policies = [...new Set(messages.map((m: any) => m.policyInfo).filter(Boolean))].sort();
|
||||||
|
const tenants: any[] = data?.tenants ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-end">
|
||||||
|
<div className="flex-1 min-w-56">
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Recipient email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="filter by recipient address…"
|
||||||
|
value={recipient}
|
||||||
|
onChange={e => setRecipient(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && load()}
|
||||||
|
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tenants.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Tenant</label>
|
||||||
|
<select value={tenantFilter} onChange={e => setTenantFilter(e.target.value)}
|
||||||
|
className="border rounded-md px-3 py-1.5 text-sm bg-background">
|
||||||
|
<option value="">All tenants</option>
|
||||||
|
{tenants.map(t => (
|
||||||
|
<option key={t.tenantId} value={String(t.tenantId)}>{t.accountName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loaded && policies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Policy</label>
|
||||||
|
<select value={policyFilter} onChange={e => setPolicyFilter(e.target.value)}
|
||||||
|
className="border rounded-md px-3 py-1.5 text-sm bg-background min-w-48">
|
||||||
|
<option value="">All policies</option>
|
||||||
|
{policies.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => load()} disabled={loading} className="gap-2">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
|
{loaded ? 'Refresh' : 'Load Held Mail'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenant summary badges */}
|
||||||
|
{loaded && tenants.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tenants.map(t => (
|
||||||
|
<div key={t.tenantId} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs border ${
|
||||||
|
t.error ? 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-600' :
|
||||||
|
t.count > 0 ? 'border-yellow-400/40 bg-yellow-500/5 text-yellow-700' :
|
||||||
|
'border-border bg-muted/30 text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
<Building2 className="w-3 h-3" />
|
||||||
|
<span className="font-medium">{t.accountName}</span>
|
||||||
|
{t.error
|
||||||
|
? <span>error</span>
|
||||||
|
: <span>— {t.count.toLocaleString()}{t.totalCount > t.count ? ` of ${t.totalCount.toLocaleString()}` : ''} held</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loaded && !loading && (
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground text-sm">
|
||||||
|
Click “Load Held Mail” to fetch held messages across all configured Mimecast tenants.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">Fetching held messages from all tenants…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loaded && !loading && filtered.length === 0 && (
|
||||||
|
<div className="rounded-lg border p-12 text-center text-muted-foreground text-sm">
|
||||||
|
No held messages found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loaded && !loading && filtered.length > 0 && (
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
<div className="px-4 py-2 bg-muted/40 border-b flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{filtered.length.toLocaleString()} held message{filtered.length !== 1 ? 's' : ''}
|
||||||
|
{data?.totalCount > filtered.length ? ` (showing ${filtered.length} of ${data.totalCount.toLocaleString()} total)` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Date</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">To</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">From</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Subject</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Policy</th>
|
||||||
|
{tenants.length > 1 && <th className="text-left px-4 py-2 font-medium">Tenant</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filtered.map((m: any) => (
|
||||||
|
<tr key={m.id} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap text-xs">
|
||||||
|
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="text-xs">{m.to}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="font-medium text-xs">{m.fromDisplay || m.from}</div>
|
||||||
|
{m.fromDisplay && <div className="text-xs text-muted-foreground">{m.from}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 max-w-xs">
|
||||||
|
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
|
||||||
|
? 'bg-red-500/10 text-red-600'
|
||||||
|
: 'bg-yellow-500/10 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{m.policyInfo || m.reason || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{tenants.length > 1 && (
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground">{m.accountName}</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
export default function MimecastSyncPage() {
|
export default function MimecastSyncPage() {
|
||||||
const [statusData, setStatusData] = useState<any>(null);
|
const [statusData, setStatusData] = useState<any>(null);
|
||||||
|
|
@ -414,10 +697,12 @@ export default function MimecastSyncPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue="status" className="w-full">
|
<Tabs defaultValue="status" className="w-full">
|
||||||
<TabsList className="grid w-full max-w-2xl grid-cols-5">
|
<TabsList className="grid w-full max-w-4xl grid-cols-7">
|
||||||
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
|
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
|
||||||
|
<TabsTrigger value="held" className="gap-1.5"><PauseCircle className="h-4 w-4" />Held Mail</TabsTrigger>
|
||||||
<TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
|
<TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
|
||||||
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
|
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
|
||||||
|
<TabsTrigger value="cloudusers" className="gap-1.5"><Users className="h-4 w-4" />Cloud Users</TabsTrigger>
|
||||||
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger>
|
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger>
|
||||||
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
|
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
@ -425,8 +710,10 @@ export default function MimecastSyncPage() {
|
||||||
<TabsContent value="status" className="mt-6">
|
<TabsContent value="status" className="mt-6">
|
||||||
<StatusTab data={statusData} onSync={handleSync} syncing={syncing} />
|
<StatusTab data={statusData} onSync={handleSync} syncing={syncing} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="held" className="mt-6"><HeldMailTab /></TabsContent>
|
||||||
<TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
|
<TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
|
||||||
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
|
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
|
||||||
|
<TabsContent value="cloudusers" className="mt-6"><CloudUserTab /></TabsContent>
|
||||||
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
|
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
|
||||||
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
|
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
22
app/api/mimecast/cloud-user/route.ts
Normal file
22
app/api/mimecast/cloud-user/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMimecastClient } from '@/lib/services/mimecast-client';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const emailAddress = req.nextUrl.searchParams.get('emailAddress');
|
||||||
|
const domain = req.nextUrl.searchParams.get('domain');
|
||||||
|
|
||||||
|
if (!emailAddress || !domain) {
|
||||||
|
return NextResponse.json({ error: 'emailAddress and domain are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getMimecastClient();
|
||||||
|
const user = await client.getCloudUser(emailAddress, domain);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ found: false });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ found: true, user });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/api/mimecast/held/route.ts
Normal file
91
app/api/mimecast/held/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
import { getMimecastClientForTenant } from '@/lib/services/mimecast-client';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const tenantId = searchParams.get('tenantId');
|
||||||
|
const recipient = searchParams.get('recipient') ?? undefined;
|
||||||
|
|
||||||
|
// Build query — filter by tenant if provided, else all enabled tenants
|
||||||
|
let tenants: any[];
|
||||||
|
if (tenantId) {
|
||||||
|
const r = await postgresClient.query(
|
||||||
|
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||||
|
LEFT JOIN companies c ON c.id = mt.company_id
|
||||||
|
WHERE mt.id = $1 AND mt.enabled = true`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
tenants = r.rows;
|
||||||
|
} else {
|
||||||
|
const r = await postgresClient.query(
|
||||||
|
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||||
|
LEFT JOIN companies c ON c.id = mt.company_id
|
||||||
|
WHERE mt.enabled = true
|
||||||
|
ORDER BY mt.account_name`
|
||||||
|
);
|
||||||
|
tenants = r.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenants.length) {
|
||||||
|
return NextResponse.json({ error: 'No configured Mimecast tenants found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch held messages for each tenant in parallel
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
tenants.map(async (tenant) => {
|
||||||
|
const client = getMimecastClientForTenant(tenant);
|
||||||
|
const { messages, totalCount } = await client.getHeldMessages({ recipient, maxMessages: 500 });
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
accountCode: tenant.account_code,
|
||||||
|
accountName: tenant.account_name,
|
||||||
|
companyName: tenant.company_name ?? tenant.account_name,
|
||||||
|
companyId: tenant.company_id,
|
||||||
|
messages,
|
||||||
|
totalCount,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantResults = results.map((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') return r.value;
|
||||||
|
return {
|
||||||
|
tenantId: tenants[i].id,
|
||||||
|
accountCode: tenants[i].account_code,
|
||||||
|
accountName: tenants[i].account_name,
|
||||||
|
companyName: tenants[i].company_name ?? tenants[i].account_name,
|
||||||
|
companyId: tenants[i].company_id,
|
||||||
|
messages: [],
|
||||||
|
totalCount: 0,
|
||||||
|
error: r.reason?.message ?? 'Failed to fetch',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMessages = tenantResults.flatMap(t =>
|
||||||
|
t.messages.map((m: any) => ({ ...m, tenantId: t.tenantId, accountName: t.accountName, companyName: t.companyName }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort all messages newest first
|
||||||
|
allMessages.sort((a, b) => new Date(b.dateReceived).getTime() - new Date(a.dateReceived).getTime());
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tenants: tenantResults.map(t => ({
|
||||||
|
tenantId: t.tenantId,
|
||||||
|
accountName: t.accountName,
|
||||||
|
companyName: t.companyName,
|
||||||
|
companyId: t.companyId,
|
||||||
|
count: t.messages.length,
|
||||||
|
totalCount: t.totalCount,
|
||||||
|
error: t.error,
|
||||||
|
})),
|
||||||
|
messages: allMessages,
|
||||||
|
totalCount: allMessages.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mimecast] held messages error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,18 +6,20 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
console.log('Starting classification icons sync...');
|
console.log('Starting classification icons sync...');
|
||||||
|
|
||||||
// Fetch classification icons from Autotask API
|
// Fetch classification picklist from Companies field info
|
||||||
const autotaskClient = getAutotaskClient();
|
const autotaskClient = getAutotaskClient();
|
||||||
const classifications = await autotaskClient.getClassificationIcons();
|
const fields = await autotaskClient.getFieldInfo('Companies');
|
||||||
|
const classificationField = fields.find((f: any) => f.name === 'classification');
|
||||||
|
|
||||||
if (!classifications || classifications.length === 0) {
|
if (!classificationField || !classificationField.picklistValues?.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No classifications found in Autotask' },
|
{ error: 'No classification picklist found in Companies field info' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Fetched ${classifications.length} classification icons from Autotask`);
|
const classifications = classificationField.picklistValues;
|
||||||
|
console.log(`Fetched ${classifications.length} classification values from Autotask`);
|
||||||
|
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
@ -44,9 +46,9 @@ export async function POST(request: NextRequest) {
|
||||||
synced_at = CURRENT_TIMESTAMP
|
synced_at = CURRENT_TIMESTAMP
|
||||||
RETURNING (xmax = 0) AS inserted`,
|
RETURNING (xmax = 0) AS inserted`,
|
||||||
[
|
[
|
||||||
classification.id,
|
parseInt(String(classification.value)),
|
||||||
classification.name,
|
classification.label,
|
||||||
classification.description || null,
|
null,
|
||||||
classification.isActive !== false,
|
classification.isActive !== false,
|
||||||
classification.isSystem || false
|
classification.isSystem || false
|
||||||
]
|
]
|
||||||
|
|
@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
||||||
total: classifications.length,
|
total: classifications.length,
|
||||||
inserted,
|
inserted,
|
||||||
updated,
|
updated,
|
||||||
message: `Synced ${classifications.length} classification icons`
|
message: `Synced ${classifications.length} classifications from Companies field info`
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
280
docs/DATTO_RMM_API_GUIDE.md
Normal file
280
docs/DATTO_RMM_API_GUIDE.md
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
# Datto RMM API Guide for openclaw
|
||||||
|
|
||||||
|
Everything learned from building the Datto RMM integration in Pulse. Use this as a reference for any future work touching the Datto RMM API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Datto RMM API v2 uses **OAuth2 with password grant type** — not a simple API key header.
|
||||||
|
|
||||||
|
- **Auth endpoint**: `https://concord-api.centrastage.net/auth/oauth/token`
|
||||||
|
- **Base API URL**: `https://concord-api.centrastage.net/api/v2`
|
||||||
|
- **Client credentials**: always `public-client:public` (this is the public OAuth client — not your credentials)
|
||||||
|
- **Your credentials**: API Access Key = `username`, API Secret Key = `password`
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://concord-api.centrastage.net/auth/oauth/token
|
||||||
|
Authorization: Basic cHVibGljLWNsaWVudDpwdWJsaWM= (base64 of "public-client:public")
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=password&username=YOUR_API_KEY&password=YOUR_API_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
Response returns `access_token` (bearer token). Tokens last ~1 hour; refresh proactively at 50 minutes.
|
||||||
|
|
||||||
|
**Environment variables used in Pulse:**
|
||||||
|
```
|
||||||
|
DATTO_RMM_API_URL=https://concord-api.centrastage.net
|
||||||
|
DATTO_RMM_API_KEY=<your API access key>
|
||||||
|
DATTO_RMM_API_SECRET=<your API secret key>
|
||||||
|
DATTO_RMM_WEBHOOK_SECRET=<shared secret for webhook validation>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making API Calls
|
||||||
|
|
||||||
|
All requests go to `https://concord-api.centrastage.net/api/v2{endpoint}` with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
The response body is always JSON. Always parse it — the API never returns empty 200s (except for write operations where you should guard with `text ? JSON.parse(text) : {}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Endpoints
|
||||||
|
|
||||||
|
### Sites
|
||||||
|
```
|
||||||
|
GET /account/sites → { sites: [...], pageDetails: {...} }
|
||||||
|
GET /site/{siteUid}/devices → { devices: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Sites map to Autotask companies via `autotaskCompanyId` / `autotaskCompanyName` fields on the site object. This is the primary join key between the two systems.
|
||||||
|
|
||||||
|
### Devices
|
||||||
|
```
|
||||||
|
GET /account/devices → { devices: [...], pageDetails: {...} }
|
||||||
|
GET /devices/{deviceId} → { item: DattoRMMDevice }
|
||||||
|
GET /device/{deviceId}/auditdata → detailed hardware info (bios, processors, memory, disks)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default page size is 250. Always paginate using `pageDetails.nextPageUrl` — follow it until null.
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
```
|
||||||
|
GET /account/alerts/open → { alerts: [...], pageDetails: {...} }
|
||||||
|
GET /account/alerts/resolved → { alerts: [...], pageDetails: {...} }
|
||||||
|
GET /alert/{alertUid} → single alert with full alertContext
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolved alerts can be huge — cap page fetches (e.g. 4 pages) rather than fetching all.
|
||||||
|
|
||||||
|
For PING alerts, `alertContext['@class'] === 'ping_ctx'` and `alertContext.instanceName` holds the ping target hostname.
|
||||||
|
|
||||||
|
### Components & Quick Jobs
|
||||||
|
```
|
||||||
|
GET /account/components → { components: [...] } (automation scripts/tasks)
|
||||||
|
PUT /device/{deviceUid}/quickjob → run a component on a device
|
||||||
|
GET /job/{jobUid}/results/{deviceUid} → job output/results
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick job payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobName": "My Job Name",
|
||||||
|
"jobComponent": {
|
||||||
|
"componentUid": "abc-123-...",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "VAR_NAME", "value": "value" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pagination Pattern
|
||||||
|
|
||||||
|
The API uses `pageDetails.nextPageUrl` for cursor-based pagination. Always follow it:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let url: string | null = `https://concord-api.centrastage.net/api/v2/account/sites?pageSize=250`;
|
||||||
|
while (url) {
|
||||||
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
const body = await resp.json();
|
||||||
|
items.push(...(body.sites || []));
|
||||||
|
url = body.pageDetails?.nextPageUrl ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Some older endpoints use `?page=1&pageSize=250` query params — check which pattern each endpoint uses. The `fetchAllPages` helper in `datto-rmm-client.ts` handles the `nextPageUrl` style.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model (PostgreSQL Tables)
|
||||||
|
|
||||||
|
### `datto_rmm_sites`
|
||||||
|
| Column | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `id` | Datto integer ID (PK) |
|
||||||
|
| `uid` | Datto GUID string (unique) |
|
||||||
|
| `name` | Site display name |
|
||||||
|
| `autotask_company_id` | FK → `companies(id)` — join key |
|
||||||
|
| `autotask_company_name` | Denormalized for display |
|
||||||
|
| `number_of_devices` | Online/offline counts from API |
|
||||||
|
| `portal_url` | Deep link to Datto portal |
|
||||||
|
|
||||||
|
### `datto_rmm_devices`
|
||||||
|
| Column | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `id` | Datto integer ID (PK) |
|
||||||
|
| `uid` | Datto GUID (unique, used for API calls and quick jobs) |
|
||||||
|
| `site_id` | FK → `datto_rmm_sites(id)` |
|
||||||
|
| `hostname` | Device hostname |
|
||||||
|
| `online` / `suspended` / `deleted` | Status flags |
|
||||||
|
| `device_type_category` | e.g. "Desktop", "Server", "Laptop" |
|
||||||
|
| `operating_system` | OS string |
|
||||||
|
| `antivirus_product` / `antivirus_status` | AV info |
|
||||||
|
| `patch_status` | Patch management summary |
|
||||||
|
| `patches_approved_pending` | Count of pending patches |
|
||||||
|
| `last_seen` | Timestamp (ms epoch from API, stored as TIMESTAMPTZ) |
|
||||||
|
| `web_remote_url` | Direct remote session URL |
|
||||||
|
| `udf` | JSONB — User Defined Fields 1–10 from API |
|
||||||
|
|
||||||
|
### `datto_rmm_alerts`
|
||||||
|
| Column | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `alert_uid` | PK (Datto GUID string) |
|
||||||
|
| `device_uid` / `device_name` | Source device |
|
||||||
|
| `site_uid` / `site_name` | Source site |
|
||||||
|
| `priority` | "Critical", "High", "Moderate", "Low", "Information" |
|
||||||
|
| `alert_context` | JSONB — varies by alert type, contains `@class` discriminator |
|
||||||
|
| `resolved` | Boolean |
|
||||||
|
| `resolved_on` | Timestamp |
|
||||||
|
| `muted` | Boolean |
|
||||||
|
| `ticket_number` | Linked Autotask ticket if any |
|
||||||
|
| `alert_category` | e.g. "Patch Management" |
|
||||||
|
| `alert_type` | e.g. "PING", "DISK", "CPU" |
|
||||||
|
| `alert_message_en` | Human-readable alert message |
|
||||||
|
| `device_udf1`–`device_udf29` | All 29 UDF fields from webhook payloads |
|
||||||
|
| `ping_target` | Resolved from API for PING alerts |
|
||||||
|
| `triggered` | Raw webhook "True"/"False" string |
|
||||||
|
|
||||||
|
### `datto_rmm_webhook_logs`
|
||||||
|
Raw capture table — stores every incoming webhook payload verbatim for inspection before processing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
Datto RMM can POST alert events to your endpoint when alerts fire or resolve.
|
||||||
|
|
||||||
|
**Webhook receiver**: `POST /api/webhooks/datto-rmm`
|
||||||
|
|
||||||
|
**Authentication**: Datto sends a shared secret in the `X-Datto-Webhook-Secret` header. Validate it against `DATTO_RMM_WEBHOOK_SECRET` env var.
|
||||||
|
|
||||||
|
**Alert webhook payload shape** (flat JSON, not nested like the REST API):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alert_uid": "abc-123-...",
|
||||||
|
"triggered": "True", // "True" = alert fired, "False" = resolved
|
||||||
|
"alert_type": "PING",
|
||||||
|
"alert_category": "Networking",
|
||||||
|
"alert_priority": "Critical",
|
||||||
|
"alert_message_en": "Ping monitor failed for ...",
|
||||||
|
"device_uid": "...",
|
||||||
|
"device_hostname": "SERVER01",
|
||||||
|
"device_ip": "10.0.0.1",
|
||||||
|
"device_os": "Windows Server 2019",
|
||||||
|
"device_description": "...",
|
||||||
|
"device_id": "12345",
|
||||||
|
"site_uid": "...",
|
||||||
|
"site_name": "ACME Corp",
|
||||||
|
"site_id": "678",
|
||||||
|
"platform": "Windows",
|
||||||
|
"last_user": "DOMAIN\\user",
|
||||||
|
"device_udf1": "...",
|
||||||
|
// ... device_udf2 through device_udf29
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key gotcha: `triggered === "True"` means the alert is **active** (not resolved). `triggered === "False"` means it **resolved**. Map `triggered === "False"` → `resolved = true`.
|
||||||
|
|
||||||
|
**Always return HTTP 200** even on errors — Datto will disable your webhook endpoint if it receives repeated non-200 responses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Architecture in Pulse
|
||||||
|
|
||||||
|
The sync pipeline runs in order: **sites → devices → open_alerts → resolved_alerts**
|
||||||
|
|
||||||
|
Sites must sync before devices (FK constraint). The sync service guards against orphaned FK refs by pre-fetching known IDs and setting FK fields to null when the parent doesn't exist yet.
|
||||||
|
|
||||||
|
Timestamps from the Datto API come as **millisecond epoch integers**. Convert with `new Date(milliseconds)` before storing in PostgreSQL.
|
||||||
|
|
||||||
|
The factory singleton (`datto-rmm-factory.ts`) is the standard way to get a client instance in API routes:
|
||||||
|
```typescript
|
||||||
|
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||||
|
const client = getDattoRMMClient();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Auth URL is different from API URL** — token is fetched from `concord-api.centrastage.net/auth/...`, API calls go to `concord-api.centrastage.net/api/v2/...`
|
||||||
|
|
||||||
|
2. **Parse auth response carefully** — if auth fails, the server may return HTML (an error page) instead of JSON. Always try/catch the JSON.parse and log the raw text on failure.
|
||||||
|
|
||||||
|
3. **`uid` vs `id`** — devices and sites have both an integer `id` and a GUID `uid`. The REST API uses `uid` in paths for most operations. Quick jobs require `deviceUid` (the GUID), not the integer ID.
|
||||||
|
|
||||||
|
4. **UDFs in webhooks vs REST API** — The REST API returns UDFs as `udf: { udf1: "...", udf2: "..." }` (nested object, up to 10). Webhooks flatten them to `device_udf1` through `device_udf29` as top-level fields (29 total).
|
||||||
|
|
||||||
|
5. **Alert context varies by type** — always check `alertContext['@class']` to know what fields are available. For PING alerts, fetch `/alert/{uid}` to get `instanceName` (the ping target) since it's not in the bulk alert list response.
|
||||||
|
|
||||||
|
6. **Page size 250** is the effective maximum — don't request more.
|
||||||
|
|
||||||
|
7. **Resolved alerts grow without bound** — never fetch all resolved alerts in production. Limit to recent pages (e.g. 4 pages = ~1000 most recent).
|
||||||
|
|
||||||
|
8. **Site → Company mapping** — `site.autotaskCompanyId` is a string from the API even though it's an integer ID. Always `parseInt()` it and validate `> 0` and `!isNaN()` before using as a FK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Alert Priority Values
|
||||||
|
|
||||||
|
- `Critical`
|
||||||
|
- `High`
|
||||||
|
- `Moderate`
|
||||||
|
- `Low`
|
||||||
|
- `Information`
|
||||||
|
|
||||||
|
## Quick Reference: Common Alert Types
|
||||||
|
|
||||||
|
- `PING` — ping monitor failure
|
||||||
|
- `DISK` — disk space/health
|
||||||
|
- `CPU` — CPU utilization
|
||||||
|
- `MEMORY` — RAM utilization
|
||||||
|
- `SERVICE` — Windows service down
|
||||||
|
- `EVENTLOG` — Windows event log match
|
||||||
|
- `PATCH` — patch management
|
||||||
|
- `ANTIVIRUS` — AV status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Integration
|
||||||
|
|
||||||
|
In Pulse, incoming Datto RMM webhooks can trigger the **pipeline engine** (fire-and-forget):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
|
||||||
|
console.error('[DATTO-RMM-WEBHOOK] Pipeline processing error:', err)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The trigger type `'datto_rmm'` matches pipeline rules configured in the admin UI. Pipelines can take actions like creating Autotask tickets, sending notifications, etc.
|
||||||
104
docs/clista-changes-2026-03-26.md
Normal file
104
docs/clista-changes-2026-03-26.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Clista Electric — User List Changes Since January 2026
|
||||||
|
|
||||||
|
_Comparison of "Clista Approved - User List and Classification.xlsx" against Autotask/M365 as of 2026-03-26. Enriched with ticket history._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Users Added Since January
|
||||||
|
|
||||||
|
| Name | Email | Added | Classification (from tickets) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Brett Tokarski** | btokarski@clistaelectric.com | Jan 6 | Likely **BRONZE** — iPad + Bluebeam Complete (estimator/PM profile), no laptop deployed |
|
||||||
|
| **Joe Glassbrenner** | jglassbrenner@clistaelectric.com | Jan 6 | Likely **EMAIL ONLY** — iPad setup only, no workstation |
|
||||||
|
| **Daniel Archer** | darcher@clistaelectric.com | Mar 3 | Likely **EMAIL ONLY** — mailbox + Foreman distribution list + iPad; field foreman |
|
||||||
|
|
||||||
|
New device added: **SPRO009** (Jan 15, assigned to Josh Miller — "Activate SPRO009 and install Wulf tools")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deleted Since January
|
||||||
|
|
||||||
|
- **Joe Laplace** (jlaplace@clistaelectric.com) — removed Jan 6. His laptop (**LT049**) was reassigned to Bryan Detweiler (ticket T20260119.0181).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users in Autotask NOT on the Spreadsheet
|
||||||
|
|
||||||
|
Ticket history used to determine likely classification:
|
||||||
|
|
||||||
|
| Name | Email | Likely Classification | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Bryan Detweiler** | bdetweiler@clistaelectric.com | **BRONZE USER** | Received LT049 (Joe Laplace's repurposed laptop, Jan 13). iPad email setup Oct 2025. |
|
||||||
|
| **Nicholas Flinko** | nflinko@clistaelectric.com | **BRONZE USER** | Has LT032 (multiple patch/disk/Teams tickets on LT032 under his name). Spreadsheet incorrectly lists LT032 under Jared Boger. |
|
||||||
|
| **Tanner Lacher** | tlacher@clistaelectric.com | **EMAIL ONLY** | "New iPad and Email Setup Request" Oct 2025 — iPad only, no workstation. |
|
||||||
|
| **Randy Nocleg** | rnocleg@clistaelectric.com | **EMAIL ONLY** | "Mobile email set up" Jun 2025 — mobile only. |
|
||||||
|
| **John Pronko** | jpronko@clistaelectric.com | **EMAIL ONLY** | "Duo and Outlook Setup on iPad" Nov 2025 — iPad only. |
|
||||||
|
| **Bill Shindledecker** | bShindledecker@clistaelectric.com | **EMAIL ONLY** | "Email Setup for iPad" Oct 2025 — iPad only. |
|
||||||
|
| **Jeff Gatto** | jgatto@clistaelectric.com | **Unknown** | No tickets found. |
|
||||||
|
| **Rob Gerhart** | rgerhart@clistaelectric.com | **Unknown** | No tickets found. |
|
||||||
|
| **Mike Jr.** | mikejr@clistaelectric.com | **Likely duplicate** | Only 2 tickets (unusual sign-in alerts, Oct 2025). Likely a legacy/stale account for Mike Clista Jr. (mclistajr@). |
|
||||||
|
| **safety unknown** | safety@clistaelectric.com | **DEVICE EMAIL or GENERAL** | Generic safety mailbox — no user-specific tickets. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flagged REMOVE — Still Have Active Licenses in M365
|
||||||
|
|
||||||
|
Both are still licensed in the M365 export:
|
||||||
|
|
||||||
|
- **CAD1** — still has Microsoft 365 Business Standard
|
||||||
|
- **Sam Cocola** — still has Microsoft 365 Basic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device Reassignments Found in Tickets
|
||||||
|
|
||||||
|
Devices that moved or were reassigned since the spreadsheet was made:
|
||||||
|
|
||||||
|
| Device | Old Assignment (spreadsheet) | New Assignment (from tickets) | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **LT049** | (not on spreadsheet) | Bryan Detweiler | Reassigned from Joe Laplace when he left (Jan 13) |
|
||||||
|
| **LT074** | (not on spreadsheet) | Mark Blum | Repurposed deployment Mar 4; onboarding check-in Mar 11. **Mark Blum should be reclassified — he was EMAIL ONLY but now has a workstation.** |
|
||||||
|
| **LT078** | (not on spreadsheet) | Brendon Bittel | Patch alerts Dec 2025. **Bittel was EMAIL ONLY but now has a laptop.** |
|
||||||
|
| **LT079** | (not on spreadsheet) | Travis Lenhart | Patch failure Mar 2026. Lenhart was EMAIL ONLY with MIX003 (now inactive). **Upgrade to BRONZE.** |
|
||||||
|
| **LT080** | (not on spreadsheet) | Andrew Holzworth | Docking station ticket Feb 2026. LT040 (his old device) was repurposed for field use Jan 20. |
|
||||||
|
| **LT076** | (not on spreadsheet) | Roseann March | VPN confirmation ticket Nov 2025. She already had WL-LT004 + DT034; LT076 may be a replacement. |
|
||||||
|
| **LT077** | (not on spreadsheet) | Roseann March | Patch failure Dec 2025 — second device or replacement for WL-LT004. |
|
||||||
|
| **LT032** | Jared Boger (EMAIL ONLY) | Nicholas Flinko | Flinko is the contact on all LT032 alerts — Boger classification may be stale. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users Whose Classification Should Be Updated
|
||||||
|
|
||||||
|
Based on device deployments found in tickets:
|
||||||
|
|
||||||
|
| Name | Current Classification | Suggested Update | Reason |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Mark Blum** | EMAIL ONLY | **BRONZE USER** | Received workstation LT074 (Mar 2026) |
|
||||||
|
| **Brendon Bittel** | EMAIL ONLY | **BRONZE USER** | Has laptop LT078 (added Dec 2025) |
|
||||||
|
| **Travis Lenhart** | EMAIL ONLY | **BRONZE USER** | Has laptop LT079; MIX003 retired |
|
||||||
|
| **Bryan Detweiler** | Not on list | **BRONZE USER** | Add to list — has LT049 |
|
||||||
|
| **Jared Boger** | EMAIL ONLY (LT032) | Review — LT032 now under Flinko | Boger may have no device now |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Devices on Spreadsheet Now Inactive in DB
|
||||||
|
|
||||||
|
| Device | Assigned To (per spreadsheet) | Status | Likely Replacement |
|
||||||
|
|---|---|---|---|
|
||||||
|
| LT017 | Justin Klosky | Inactive | Unknown |
|
||||||
|
| LT028 | Anthony Laskey | Inactive | Unknown |
|
||||||
|
| LT032 | Jared Boger | Inactive | Now under Nicholas Flinko |
|
||||||
|
| MIX003 | Travis Lenhart | Inactive | LT079 |
|
||||||
|
| WL-LT004 | Roseann March | Inactive | LT076 or LT077 |
|
||||||
|
| LT040 | Andrew Holzworth | Repurposed | LT080 — "repurposed for field use" Jan 20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Quality Issues
|
||||||
|
|
||||||
|
- **Dominic Edwards** — email typo in Autotask: `dedwards@clistaelectric.om` (missing the 'c')
|
||||||
|
- **Ron Marangoni** — spreadsheet says `marangoni@`, M365 and Autotask both show `rmarangoni@`
|
||||||
|
- **Sonny Stewart** — spreadsheet lists "Microsoft 365" license but M365 export shows no license assigned
|
||||||
|
- **Michael Skibinski** — duplicate contact record created Feb 13, 2026 (two records, same email)
|
||||||
|
- **Dave Warywoda**, **Grant Hoffman**, **Dominic Edwards**, **Donald Maraugha** — on rachel_list but absent from M365 export entirely
|
||||||
|
- **LT073 and LT075** — active devices with no ticket history found; assignment unknown
|
||||||
|
|
@ -73,6 +73,32 @@ export interface MimecastMessageInfo {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MimecastHeldMessage {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
fromDisplay: string;
|
||||||
|
to: string;
|
||||||
|
toDisplay: string;
|
||||||
|
dateReceived: string;
|
||||||
|
reason: string;
|
||||||
|
reasonCode: string;
|
||||||
|
policyInfo: string;
|
||||||
|
route: string;
|
||||||
|
hasAttachments: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MimecastCloudUser {
|
||||||
|
emailAddress: string;
|
||||||
|
domain: string;
|
||||||
|
lockedOut: boolean;
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
alias?: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
|
|
@ -398,6 +424,87 @@ export class MimecastClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cloud Gateway ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/cloud-gateway/v1/users?emailAddress=...&domain=...
|
||||||
|
*/
|
||||||
|
async getCloudUser(emailAddress: string, domain: string): Promise<MimecastCloudUser | null> {
|
||||||
|
const data = await this.request<any>('GET', '/user/cloud-gateway/v1/users', undefined, {
|
||||||
|
emailAddress,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
const user = data?.value?.[0] ?? data?.data?.[0] ?? null;
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
emailAddress: user.emailAddress ?? emailAddress,
|
||||||
|
domain: user.domain ?? domain,
|
||||||
|
lockedOut: user.lockedOut ?? false,
|
||||||
|
status: user.status ?? undefined,
|
||||||
|
name: user.name ?? undefined,
|
||||||
|
alias: user.alias ?? undefined,
|
||||||
|
_raw: user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Held Messages ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/gateway/get-hold-message-list
|
||||||
|
* admin: true = see user-level hold queues as an admin
|
||||||
|
* pageSize is ignored by the API (always returns 10); paginate via meta.pagination.next
|
||||||
|
* Fetches ALL pages up to maxMessages limit.
|
||||||
|
*/
|
||||||
|
async getHeldMessages(options: {
|
||||||
|
recipient?: string;
|
||||||
|
maxMessages?: number;
|
||||||
|
} = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> {
|
||||||
|
const maxMessages = options.maxMessages ?? 500;
|
||||||
|
const all: MimecastHeldMessage[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const reqBody: any = { admin: true };
|
||||||
|
if (options.recipient) {
|
||||||
|
reqBody.searchBy = { fieldName: 'recipient', value: options.recipient };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: any = { data: [reqBody] };
|
||||||
|
if (cursor) {
|
||||||
|
body.meta = { pagination: { pageToken: cursor } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.request<any>('POST', '/api/gateway/get-hold-message-list', body);
|
||||||
|
const pagination = result?.meta?.pagination ?? {};
|
||||||
|
const msgs: any[] = result?.data ?? [];
|
||||||
|
|
||||||
|
if (totalCount === 0) totalCount = pagination.totalCount ?? msgs.length;
|
||||||
|
|
||||||
|
for (const m of msgs) {
|
||||||
|
all.push({
|
||||||
|
id: m.id,
|
||||||
|
subject: m.subject ?? '',
|
||||||
|
from: m.fromHeader?.emailAddress ?? m.from?.emailAddress ?? '',
|
||||||
|
fromDisplay: m.fromHeader?.displayableName ?? m.from?.displayableName ?? '',
|
||||||
|
to: m.to?.emailAddress ?? '',
|
||||||
|
toDisplay: m.to?.displayableName ?? '',
|
||||||
|
dateReceived: m.dateReceived ?? '',
|
||||||
|
reason: m.reason ?? '',
|
||||||
|
reasonCode: m.reasonCode ?? '',
|
||||||
|
policyInfo: m.policyInfo ?? '',
|
||||||
|
route: m.route ?? '',
|
||||||
|
hasAttachments: m.hasAttachments ?? false,
|
||||||
|
size: m.size ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = pagination.next ?? null;
|
||||||
|
} while (cursor && all.length < maxMessages);
|
||||||
|
|
||||||
|
return { messages: all, totalCount };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account ────────────────────────────────────────────────────────────────
|
// ── Account ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -436,3 +543,17 @@ export function getMimecastClient(): MimecastClient {
|
||||||
}
|
}
|
||||||
return _client;
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMimecastClientForTenant(tenant: {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
base_url?: string;
|
||||||
|
account_code?: string;
|
||||||
|
}): MimecastClient {
|
||||||
|
return new MimecastClient({
|
||||||
|
clientId: tenant.client_id,
|
||||||
|
clientSecret: tenant.client_secret,
|
||||||
|
baseUrl: tenant.base_url ?? 'https://api.services.mimecast.com',
|
||||||
|
accountCode: tenant.account_code ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
49
migrations/061_create_recurring_revenue_views.sql
Normal file
49
migrations/061_create_recurring_revenue_views.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
-- Views filtering all core Autotask data to CompanyCategory ID 1 (Recurring Revenue Customer)
|
||||||
|
|
||||||
|
-- Base: recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_companies AS
|
||||||
|
SELECT *
|
||||||
|
FROM companies
|
||||||
|
WHERE company_category_id = 1
|
||||||
|
AND is_active = true;
|
||||||
|
|
||||||
|
-- Tickets for recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_tickets AS
|
||||||
|
SELECT t.*
|
||||||
|
FROM tickets t
|
||||||
|
INNER JOIN v_mrr_companies c ON c.id = t.company_id
|
||||||
|
WHERE t.is_deleted = false;
|
||||||
|
|
||||||
|
-- Contacts for recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_contacts AS
|
||||||
|
SELECT ct.*
|
||||||
|
FROM contacts ct
|
||||||
|
INNER JOIN v_mrr_companies c ON c.id = ct.company_id
|
||||||
|
WHERE ct.is_deleted = false;
|
||||||
|
|
||||||
|
-- Contracts for recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_contracts AS
|
||||||
|
SELECT cn.*
|
||||||
|
FROM contracts cn
|
||||||
|
INNER JOIN v_mrr_companies c ON c.id = cn.company_id
|
||||||
|
WHERE cn.is_deleted = false;
|
||||||
|
|
||||||
|
-- Configuration items for recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_configuration_items AS
|
||||||
|
SELECT ci.*
|
||||||
|
FROM configuration_items ci
|
||||||
|
INNER JOIN v_mrr_companies c ON c.id = ci.company_id
|
||||||
|
WHERE ci.is_deleted = false;
|
||||||
|
|
||||||
|
-- Time entries for recurring revenue companies (via ticket)
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_time_entries AS
|
||||||
|
SELECT te.*
|
||||||
|
FROM time_entries te
|
||||||
|
INNER JOIN v_mrr_tickets t ON t.id = te.ticket_id;
|
||||||
|
|
||||||
|
-- Billing items for recurring revenue companies
|
||||||
|
CREATE OR REPLACE VIEW v_mrr_billing_items AS
|
||||||
|
SELECT bi.*
|
||||||
|
FROM billing_items bi
|
||||||
|
INNER JOIN v_mrr_companies c ON c.id = bi.company_id
|
||||||
|
WHERE bi.is_deleted = false;
|
||||||
19
migrations/062_create_mimecast_tenants.sql
Normal file
19
migrations/062_create_mimecast_tenants.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- Migration 062: Mimecast multi-tenant credentials
|
||||||
|
-- Stores per-client Mimecast API credentials for held mail and other per-tenant queries
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mimecast_tenants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
|
account_code VARCHAR(50),
|
||||||
|
account_name VARCHAR(255),
|
||||||
|
client_id VARCHAR(255) NOT NULL,
|
||||||
|
client_secret VARCHAR(255) NOT NULL,
|
||||||
|
base_url VARCHAR(255) DEFAULT 'https://api.services.mimecast.com',
|
||||||
|
enabled BOOLEAN DEFAULT true,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mimecast_tenants_company ON mimecast_tenants(company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mimecast_tenants_enabled ON mimecast_tenants(enabled);
|
||||||
131
scripts/test-duo.mjs
Normal file
131
scripts/test-duo.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* Quick connectivity test for both Duo API integrations.
|
||||||
|
* Calls a lightweight read endpoint on each:
|
||||||
|
* Accounts API → GET /admin/v1/accounts (lists child accounts)
|
||||||
|
* Admin API → GET /admin/v1/info/summary (tenant summary stats)
|
||||||
|
*
|
||||||
|
* Duo auth: HMAC-SHA1 signed "Authorization: Basic <b64(ikey:sig)>" header
|
||||||
|
* https://duo.com/docs/adminapi#authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
// ── credentials from .env.local ──────────────────────────────────────────────
|
||||||
|
const ACCOUNTS = {
|
||||||
|
ikey: process.env.DUOACCOUNTS_INTEGRATION_KEY,
|
||||||
|
skey: process.env.DUOACCOUNTS_SECRET_KEY,
|
||||||
|
host: process.env.DUOACCOUNTS_API_HOSTNAME,
|
||||||
|
};
|
||||||
|
const ADMIN = {
|
||||||
|
ikey: process.env.DUOADMIN_INTEGRATION_KEY,
|
||||||
|
skey: process.env.DUOADMIN_SECRET_KEY,
|
||||||
|
host: process.env.DUOADMIN_API_HOSTNAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Duo HMAC signer ───────────────────────────────────────────────────────────
|
||||||
|
function duoSign(ikey, skey, host, method, path, params = {}) {
|
||||||
|
const date = new Date().toUTCString();
|
||||||
|
const sortedParams = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const canon = [date, method.toUpperCase(), host.toLowerCase(), path, sortedParams].join('\n');
|
||||||
|
const sig = crypto.createHmac('sha1', skey).update(canon).digest('hex');
|
||||||
|
const auth = Buffer.from(`${ikey}:${sig}`).toString('base64');
|
||||||
|
|
||||||
|
return { date, auth: `Basic ${auth}`, query: sortedParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── generic HTTPS GET ─────────────────────────────────────────────────────────
|
||||||
|
function duoGet(creds, path, params = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { date, auth, query } = duoSign(creds.ikey, creds.skey, creds.host, 'GET', path, params);
|
||||||
|
const url = `https://${creds.host}${path}${query ? '?' + query : ''}`;
|
||||||
|
|
||||||
|
const req = https.get(url, {
|
||||||
|
headers: { Authorization: auth, Date: date, 'Content-Type': 'application/json' },
|
||||||
|
}, res => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', d => body += d);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data: body }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── run tests ────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Duo API Connectivity Test ===\n');
|
||||||
|
|
||||||
|
// Validate env
|
||||||
|
for (const [name, val] of Object.entries({ ...ACCOUNTS, ...ADMIN })) {
|
||||||
|
if (!val) { console.error(`Missing env var for key: ${name}`); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Accounts API — POST /accounts/v1/account/list (all Accounts API endpoints use POST)
|
||||||
|
console.log('── Accounts API ────────────────────────────────');
|
||||||
|
console.log(`Host : ${ACCOUNTS.host}`);
|
||||||
|
console.log(`IKey : ${ACCOUNTS.ikey}`);
|
||||||
|
try {
|
||||||
|
const r = await duoPost(ACCOUNTS, '/accounts/v1/account/list');
|
||||||
|
console.log(`HTTP : ${r.status}`);
|
||||||
|
if (r.status === 200) {
|
||||||
|
const accounts = r.data?.response ?? [];
|
||||||
|
console.log(`✓ OK — ${accounts.length} child account(s) returned`);
|
||||||
|
accounts.slice(0, 5).forEach(a => console.log(` · ${a.name} (${a.account_id}) — ${a.api_hostname}`));
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// 2. Admin API — GET /admin/v1/info/summary
|
||||||
|
console.log('── Admin API ───────────────────────────────────');
|
||||||
|
console.log(`Host : ${ADMIN.host}`);
|
||||||
|
console.log(`IKey : ${ADMIN.ikey}`);
|
||||||
|
try {
|
||||||
|
const r = await duoGet(ADMIN, '/admin/v1/info/summary');
|
||||||
|
console.log(`HTTP : ${r.status}`);
|
||||||
|
if (r.status === 200) {
|
||||||
|
const s = r.data?.response ?? {};
|
||||||
|
console.log('✓ OK — Summary:');
|
||||||
|
console.log(` Users : ${s.user_count ?? 'n/a'}`);
|
||||||
|
console.log(` Integrations : ${s.integration_count ?? 'n/a'}`);
|
||||||
|
console.log(` Phones : ${s.telephony_credits_remaining ?? 'n/a'} credits remaining`);
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Admin API — GET /admin/v1/users (first page, limit 5)
|
||||||
|
console.log();
|
||||||
|
console.log('── Admin API — Users (first 5) ─────────────────');
|
||||||
|
try {
|
||||||
|
const r = await duoGet(ADMIN, '/admin/v1/users', { limit: '5', offset: '0' });
|
||||||
|
console.log(`HTTP : ${r.status}`);
|
||||||
|
if (r.status === 200) {
|
||||||
|
const users = r.data?.response ?? [];
|
||||||
|
console.log(`✓ OK — ${users.length} user(s) in page`);
|
||||||
|
users.forEach(u => console.log(` · ${u.username} — ${u.status} — ${u.email ?? '(no email)'}`));
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Done ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue