- 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>
303 lines
10 KiB
TypeScript
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’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>
|
|
);
|
|
}
|