wulf-pulse/components/analyzer/share-modal.tsx
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

303 lines
10 KiB
TypeScript

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Share2, Clock, Users, Check } from 'lucide-react';
import { toast } from 'sonner';
interface ShareModalProps {
analysisId: string;
}
interface DirectoryEntry {
email: string;
displayName: string | null;
jobTitle: string | null;
department: string | null;
}
interface RecentEntry {
email: string;
lastSharedAt: string;
}
interface RecipientsResponse {
recent: RecentEntry[];
directory: DirectoryEntry[];
allowedDomains: string[];
}
const MAX_DIRECTORY_VISIBLE = 12;
function timeAgo(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (ms < hour) return `${Math.max(1, Math.round(ms / minute))}m ago`;
if (ms < day) return `${Math.round(ms / hour)}h ago`;
return `${Math.round(ms / day)}d ago`;
}
export function ShareModal({ analysisId }: ShareModalProps) {
const [open, setOpen] = useState(false);
const [recipientEmail, setRecipientEmail] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [recipients, setRecipients] = useState<RecipientsResponse | null>(null);
const [recipientsError, setRecipientsError] = useState<string | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Fetch recipients lazily on first dialog open.
useEffect(() => {
if (!open) return;
if (recipients !== null) return;
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/analyzer/share/recipients');
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `Request failed: ${res.status}`);
}
const data = (await res.json()) as RecipientsResponse;
if (!cancelled) setRecipients(data);
} catch (err) {
if (!cancelled) {
setRecipientsError(
err instanceof Error ? err.message : 'Unknown error'
);
}
}
})();
return () => {
cancelled = true;
};
}, [open, recipients]);
const filteredDirectory = useMemo(() => {
if (!recipients) return [];
const q = recipientEmail.trim().toLowerCase();
if (!q) return recipients.directory.slice(0, MAX_DIRECTORY_VISIBLE);
return recipients.directory
.filter(
(d) =>
d.email.toLowerCase().includes(q) ||
(d.displayName ?? '').toLowerCase().includes(q)
)
.slice(0, MAX_DIRECTORY_VISIBLE);
}, [recipients, recipientEmail]);
const hasRecent = (recipients?.recent.length ?? 0) > 0;
function pick(email: string): void {
setRecipientEmail(email);
setShowSuggestions(false);
inputRef.current?.blur();
}
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setSubmitting(true);
try {
const res = await fetch(`/api/analyzer/analyses/${analysisId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipientEmail,
note: note.trim() || undefined,
}),
});
const data = (await res.json().catch(() => ({}))) as {
error?: string;
message?: string;
emailSent?: boolean;
emailError?: string | null;
};
if (!res.ok) {
throw new Error(data.message ?? data.error ?? `Request failed: ${res.status}`);
}
if (data.emailSent === false) {
toast.warning(
`Share recorded for ${recipientEmail}, but email failed: ${data.emailError ?? 'unknown error'}`
);
} else {
toast.success(`Shared with ${recipientEmail}`);
}
setOpen(false);
setRecipientEmail('');
setNote('');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSubmitting(false);
}
}
// Reset transient state when the dialog closes.
function onOpenChange(next: boolean): void {
setOpen(next);
if (!next) {
setShowSuggestions(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Share2 className="w-4 h-4 mr-2" />
Share
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share this analysis</DialogTitle>
<DialogDescription>
{recipients?.allowedDomains.length
? `Allowed domains: ${recipients.allowedDomains.join(', ')}`
: 'Recipient must be on an allowed domain.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="recipient">Recipient</Label>
<div className="relative">
<Input
id="recipient"
ref={inputRef}
type="email"
required
autoComplete="off"
value={recipientEmail}
onChange={(e) => {
setRecipientEmail(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => {
// Delay so a click on a suggestion lands before we hide.
setTimeout(() => setShowSuggestions(false), 150);
}}
placeholder="Search teammates or type an email…"
/>
{showSuggestions && recipients !== null && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-72 overflow-auto">
{hasRecent && recipientEmail.trim().length === 0 && (
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" /> Recent
</div>
)}
{hasRecent &&
recipientEmail.trim().length === 0 &&
recipients.recent.slice(0, 3).map((r) => (
<button
key={'recent-' + r.email}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
onMouseDown={(e) => {
e.preventDefault();
pick(r.email);
}}
>
<span className="truncate">{r.email}</span>
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(r.lastSharedAt)}
</span>
</button>
))}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1 border-t">
<Users className="w-3 h-3" /> Directory
</div>
{filteredDirectory.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No matching directory users.
</div>
) : (
filteredDirectory.map((d) => (
<button
key={'dir-' + d.email}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-start justify-between gap-2"
onMouseDown={(e) => {
e.preventDefault();
pick(d.email);
}}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{d.displayName ?? d.email}
</div>
{d.displayName && (
<div className="truncate text-xs text-muted-foreground">
{d.email}
{d.jobTitle ? ` · ${d.jobTitle}` : ''}
</div>
)}
</div>
{recipientEmail === d.email && (
<Check className="w-4 h-4 shrink-0 text-primary" />
)}
</button>
))
)}
</div>
)}
</div>
{recipientsError && (
<p className="text-xs text-muted-foreground">
Couldn&rsquo;t load directory ({recipientsError}). Type any
allowed-domain email to share.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="note">Note (optional)</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Why you're sharing this…"
rows={3}
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting || !recipientEmail}>
{submitting ? 'Sharing…' : 'Share'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}