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:
lorentz 2026-03-31 22:38:22 -04:00
parent a98c0daf15
commit fcdec8e38b
12 changed files with 2208 additions and 27 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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 &ldquo;Load Held Mail&rdquo; 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,21 +697,25 @@ 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="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger> <TabsTrigger value="held" className="gap-1.5"><PauseCircle className="h-4 w-4" />Held Mail</TabsTrigger>
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger> <TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger> <TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</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="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
</TabsList> </TabsList>
<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="messages" className="mt-6"><MessagesTab /></TabsContent> <TabsContent value="held" className="mt-6"><HeldMailTab /></TabsContent>
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent> <TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent> <TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent> <TabsContent value="cloudusers" className="mt-6"><CloudUserTab /></TabsContent>
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
</Tabs> </Tabs>
</div> </div>
); );

View 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 });
}
}

View 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 });
}
}

View file

@ -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
View 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 110 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.

View 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

View file

@ -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 ?? '',
});
}

View 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;

View 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
View 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();