feat: user modal

This commit is contained in:
dextmorgn
2025-02-04 16:58:09 +01:00
parent eca47d028e
commit 5bee9a612a
11 changed files with 383 additions and 73 deletions

View File

@@ -34,6 +34,8 @@
"@tailwindcss/postcss": "^4.0.3",
"@xyflow/react": "^12.4.2",
"clsx": "2.1.1",
"d3-force": "^3.0.0",
"d3-quadtree": "^3.0.1",
"framer-motion": "11.13.1",
"intl-messageformat": "^10.5.0",
"lucide-react": "^0.474.0",

View File

@@ -1,5 +1,6 @@
import { getInvestigationData } from '@/src/lib/actions/investigations'
import InvestigationGraph from '@/src/components/investigations/graph'
import IndividualModal from '@/src/components/investigations/individual-modal'
const DashboardPage = async ({
params,
@@ -8,9 +9,11 @@ const DashboardPage = async ({
}) => {
const { investigation_id } = await (params)
const { nodes, edges } = await getInvestigationData(investigation_id)
return (
<div>
<InvestigationGraph initialNodes={nodes} initialEdges={edges} />
<IndividualModal />
</div>
)
}

View File

@@ -61,7 +61,7 @@ export default function NewCase() {
</Text>
<TextField.Root
name="description"
placeholder="Investigation sur une campagne de phishing via Twitter et LinkedIn."
placeholder="Investigation sur une campagne de phishing via LinkedIn."
/>
</label>
</Flex>

View File

@@ -0,0 +1,285 @@
"use client"
import type React from "react"
import { useEffect, useState } from "react"
import {
Flex,
Dialog,
TextField,
Button,
Box,
Skeleton,
Card,
Inset,
Separator,
Avatar,
Tabs,
IconButton,
} from "@radix-ui/themes"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useIndividual } from "@/src/lib/hooks/use-individual"
import { Pencil1Icon, Cross2Icon, PlusIcon, TrashIcon } from "@radix-ui/react-icons"
import social from "./nodes/social"
const IndividualModal = () => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const individual_id = searchParams.get("individual_id")
const { individual, isLoading } = useIndividual(individual_id)
const [editMode, setEditMode] = useState(false)
const [emails, setEmails] = useState([""])
const [phones, setPhones] = useState([""])
const [accounts, setAccounts] = useState([""])
const [ipAddresses, setIpAddresses] = useState([""])
console.log(individual)
useEffect(() => {
setEmails(individual?.emails.map(({ email }: any) => email) || [""])
setPhones(individual?.phone_numbers.map(({ phone_number }: any) => phone_number) || [""])
setIpAddresses(individual?.ip_addresses.map(({ ip_address }: any) => ip_address) || [""])
setAccounts(individual?.social_accounts.map(({ username }: any) => username) || [""])
}, [individual])
const handleCloseModal = () => {
router.push(pathname)
}
const handleSave = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const formContent = Object.fromEntries(formData.entries())
formContent.emails = emails as any
formContent.phones = phones as any
formContent.ip_addresses = ipAddresses as any
alert(JSON.stringify(formContent, null, 2))
setEditMode(false)
}
const handleAddField = (setter: React.Dispatch<React.SetStateAction<string[]>>) => {
setter((prev) => [...prev, ""])
}
const handleRemoveField = (index: number, setter: React.Dispatch<React.SetStateAction<string[]>>) => {
setter((prev) => prev.filter((_, i) => i !== index))
}
const handleFieldChange = (index: number, value: string, setter: React.Dispatch<React.SetStateAction<string[]>>) => {
setter((prev) => prev.map((item, i) => (i === index ? value : item)))
}
if (!isLoading && !individual) {
return (
<Dialog.Root open={Boolean(individual_id)} onOpenChange={handleCloseModal}>
<Dialog.Content>
<Dialog.Title>No data</Dialog.Title>
<Dialog.Description size="2" mb="4">
No data found for this individual.
</Dialog.Description>
<Dialog.Close>
<Button variant="soft" color="gray">
Close
</Button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Root>
)
}
return (
<Dialog.Root open={Boolean(individual_id)} onOpenChange={handleCloseModal}>
<Dialog.Content style={{ maxWidth: "900px", width: "90vw" }} minHeight={"80vh"}>
<Skeleton loading={isLoading}>
<form className="flex flex-col gap-3 justify-between h-full" onSubmit={handleSave}>
<Flex direction="column" gap="4">
<Flex justify="between" align="center">
<Dialog.Title>User Profile</Dialog.Title>
<IconButton
type="button"
variant="ghost"
onClick={() => setEditMode(!editMode)}
aria-label={editMode ? "Cancel edit" : "Edit profile"}
>
{editMode ? <Cross2Icon /> : <Pencil1Icon />}
</IconButton>
</Flex>
<Flex gap="6">
<Flex direction={"column"}>
<Avatar
size="9"
fallback={individual?.full_name?.[0] || "?"}
radius="full"
/>
{editMode && (
<Button type="button" size="1" variant="soft" style={{ marginTop: "8px" }}>
Change Photo
</Button>
)}
</Flex>
<Box style={{ flexGrow: 1 }}>
<Tabs.Root defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="social_account">Social accounts</Tabs.Trigger>
<Tabs.Trigger value="emails">Emails</Tabs.Trigger>
<Tabs.Trigger value="phone_numbers">Phone numbers</Tabs.Trigger>
<Tabs.Trigger value="ip_addresses">IP addresses</Tabs.Trigger>
</Tabs.List>
<Box pt="3">
<Tabs.Content value="overview">
<Flex direction="column" gap="3">
<TextField.Root
defaultValue={individual?.full_name}
placeholder="Full Name"
name="full_name"
disabled={!editMode}
/>
{emails.map((email: string | number | undefined, index: React.Key | null | undefined) => (
<Flex key={index} gap="2" align="center">
<TextField.Root
value={email}
onChange={(e) => handleFieldChange(index, e.target.value, setEmails)}
placeholder="Email"
type="email"
disabled={!editMode}
style={{ flexGrow: 1 }}
/>
{editMode && (
<IconButton
type="button"
variant="ghost"
onClick={() => handleRemoveField(index, setEmails)}
aria-label="Remove email"
>
<TrashIcon />
</IconButton>
)}
</Flex>
))}
{editMode && (
<Button type="button" onClick={() => handleAddField(setEmails)} variant="soft">
<PlusIcon /> Add Email
</Button>
)}
{phones.map((phone, index) => (
<Flex key={index} gap="2" align="center">
<TextField.Root
value={phone}
onChange={(e) => handleFieldChange(index, e.target.value, setPhones)}
placeholder="Phone Number"
type="tel"
disabled={!editMode}
style={{ flexGrow: 1 }}
/>
{editMode && (
<IconButton
type="button"
variant="ghost"
onClick={() => handleRemoveField(index, setPhones)}
aria-label="Remove phone"
>
<TrashIcon />
</IconButton>
)}
</Flex>
))}
{editMode && (
<Button type="button" onClick={() => handleAddField(setPhones)} variant="soft">
<PlusIcon /> Add Phone
</Button>
)}
</Flex>
</Tabs.Content>
<Tabs.Content value="social_account">
<Flex direction="column" gap="3">
<TextField.Root
defaultValue={individual?.username}
placeholder="Username"
name="username"
disabled={!editMode}
/>
{editMode && (
<TextField.Root placeholder="New Password" name="new_password" type="password" />
)}
{accounts.map((account, index) => (
<Flex key={index} gap="2" align="center">
<TextField.Root
value={account}
onChange={(e) => handleFieldChange(index, e.target.value, setAccounts)}
placeholder="Social account"
disabled={!editMode}
style={{ flexGrow: 1 }}
/>
{editMode && (
<IconButton
variant="ghost"
onClick={() => handleRemoveField(index, setAccounts)}
aria-label="Remove social account"
>
<TrashIcon />
</IconButton>
)}
</Flex>
))}
{editMode && (
<Button type="button" onClick={() => handleAddField(setIpAddresses)} variant="soft">
<PlusIcon /> Add IP Address
</Button>
)}
</Flex>
</Tabs.Content>
<Tabs.Content value="emails">
<Flex direction="column" gap="3">
<TextField.Root
defaultValue={individual?.language}
placeholder="Preferred Language"
name="language"
disabled={!editMode}
/>
<TextField.Root
defaultValue={individual?.timezone}
placeholder="Timezone"
name="timezone"
disabled={!editMode}
/>
</Flex>
</Tabs.Content>
<Tabs.Content value="phone_numbers">
<Flex direction="column" gap="3">
<TextField.Root
defaultValue={individual?.language}
placeholder="Preferred Language"
name="language"
disabled={!editMode}
/>
<TextField.Root
defaultValue={individual?.timezone}
placeholder="Timezone"
name="timezone"
disabled={!editMode}
/>
</Flex>
</Tabs.Content>
</Box>
</Tabs.Root>
</Box>
</Flex>
</Flex>
<Flex gap="3" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
Cancel
</Button>
</Dialog.Close>
{editMode && <Button type="submit">Save Changes</Button>}
</Flex>
</form>
</Skeleton>
</Dialog.Content>
</Dialog.Root>
)
}
export default IndividualModal

View File

@@ -42,6 +42,7 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
/>
<Box flexGrow="1">
<TextField.Root
required
defaultValue={""}
name={"full_name"}
placeholder={`Name of the individual`}

View File

@@ -1,23 +1,35 @@
"use client"
import React, { memo, useState } from 'react';
import React, { memo, useCallback } from 'react';
import { Handle, Position } from '@xyflow/react';
import { useInvestigationContext } from '../investigation-provider';
import { Avatar, Card, Box, Flex, Text, ContextMenu, Dialog, TextField, Button, Spinner, Badge, Tooltip, Inset } from '@radix-ui/themes';
import { Avatar, Card, Box, Flex, Text, ContextMenu, Spinner, Badge, Tooltip } from '@radix-ui/themes';
import { AtSignIcon, CameraIcon, FacebookIcon, InstagramIcon, LocateIcon, MessageCircleDashedIcon, PhoneIcon, SendIcon, UserIcon } from 'lucide-react';
import { NodeProvider, useNodeContext } from './node-context';
import { cn } from '@/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
function Custom({ data }: any) {
const { settings } = useInvestigationContext()
const { setOpenAddNodeModal, handleDuplicateNode, handleDeleteNode, loading } = useNodeContext()
const [open, setOpen] = useState(false);
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(name, value)
return params.toString()
},
[searchParams]
)
const handleOpenIndividualModal = () => router.push(pathname + '?' + createQueryString('individual_id', data.id))
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<Box className={cn(loading ? "!opacity-40" : "!opacity-100")}>{settings.showNodeLabel ?
<Card className='!py-1' onClick={() => setOpen(true)}>
<Box onClick={handleOpenIndividualModal} className={cn(loading ? "!opacity-40" : "!opacity-100")}>{settings.showNodeLabel ?
<Card className='!py-1'>
<Flex gap="2" align="center">
<Avatar
size="2"
@@ -37,13 +49,13 @@ function Custom({ data }: any) {
</Flex>
</Card> :
<Tooltip content={data.full_name}>
<button className='!rounded-full border-transparent' onClick={() => setOpen(true)}>
<Avatar
size="3"
src={data?.image_url}
radius="full"
fallback={<UserIcon className='h-4 w-4' />}
/>
<button onClick={handleOpenIndividualModal} className='!rounded-full border-transparent'>
<Avatar
size="3"
src={data?.image_url}
radius="full"
fallback={<UserIcon className='h-4 w-4' />}
/>
</button>
</Tooltip>}
<Handle
@@ -79,7 +91,7 @@ function Custom({ data }: any) {
</ContextMenu.Sub>
</ContextMenu.SubContent>
</ContextMenu.Sub>
<ContextMenu.Item onClick={() => setOpen(true)}>View and edit</ContextMenu.Item>
<ContextMenu.Item>View and edit</ContextMenu.Item>
<ContextMenu.Item onClick={handleDuplicateNode}>Duplicate</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onClick={handleDeleteNode} shortcut="⌘ ⌫" color="red">
@@ -87,47 +99,6 @@ function Custom({ data }: any) {
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Content maxWidth="40vw">
<Dialog.Title>{data.full_name}</Dialog.Title>
<Dialog.Description size="2" mb="4">
{data.notes}
</Dialog.Description>
<Flex direction="column" gap="3">
<label>
<Text as="div" size="2" mb="1" weight="bold">
Name
</Text>
<TextField.Root
defaultValue={data?.full_name}
placeholder="Enter your full name"
name="full_name"
/>
</label>
<label>
<Text as="div" size="2" mb="1" weight="bold">
Email
</Text>
<TextField.Root
defaultValue="freja@example.com"
placeholder="Enter your email"
/>
</label>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="soft" color="gray">
Cancel
</Button>
</Dialog.Close>
<Dialog.Close>
<Button>Save</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</>
);
}

View File

@@ -1,33 +1,48 @@
"use client"
import React, { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Card, Box, Text, ContextMenu, Flex, Inset, Badge } from '@radix-ui/themes';
import { Card, Box, Text, ContextMenu, Flex, Inset, Badge, Tooltip, Avatar } from '@radix-ui/themes';
import { NodeProvider, useNodeContext } from './node-context';
import { LocateIcon } from 'lucide-react';
import { cn } from '@/utils';
import { useInvestigationContext } from '../investigation-provider';
function Custom({ data }: any) {
const { handleDeleteNode, loading } = useNodeContext()
const { settings } = useInvestigationContext()
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<Box className={cn(loading ? "!opacity-40" : "!opacity-100")}>
<Card>
<Inset>
<Flex className='items-center p-0'>
<Badge className='!h-[24px] !rounded-r-none'>
<LocateIcon className='h-3 w-3' />
</Badge>
<Box className='p-1'>
<Text as="div" size="1" weight="regular">
{data.label}
</Text>
</Box>
</Flex>
</Inset>
</Card>
{settings.showNodeLabel ?
<Card>
<Inset>
<Flex className='items-center p-0'>
<Badge className='!h-[24px] !rounded-r-none'>
<LocateIcon className='h-3 w-3' />
</Badge>
<Box className='p-1'>
<Text as="div" size="1" weight="regular">
{data.label}
</Text>
</Box>
</Flex>
</Inset>
</Card>
:
<Tooltip content={data.label}>
<button className='!rounded-full border-transparent'>
<Avatar
size="1"
src={data?.image_url}
radius="full"
/* @ts-ignore */
fallback={<LocateIcon className='h-3 w-3' />}
/>
</button>
</Tooltip>}
<Handle
type="target"
position={Position.Top}

View File

@@ -2,6 +2,7 @@
import { createClient } from "../supabase/server"
import { Investigation } from "@/src/types/investigation"
import { NodeData, EdgeData } from "@/src/types"
import { notFound } from "next/navigation"
interface ReturnTypeGetInvestigations {
investigations: Investigation[],
error?: any
@@ -30,7 +31,7 @@ export async function getInvestigation(investigationId: string): Promise<ReturnT
.single()
if (error) {
throw error
throw notFound()
}
return { investigation: investigation as Investigation, error: error }
}
@@ -41,7 +42,8 @@ export async function getInvestigationData(investigationId: string): Promise<{ n
.from('individuals')
.select('*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*)')
.eq('investigation_id', investigationId);
if (indError) throw indError;
if (indError) throw notFound();
if (!individuals) individuals = [];
// Extraire les IDs

View File

@@ -0,0 +1,17 @@
import { supabase } from "@/src/lib/supabase/client";
import { useQuery } from "@supabase-cache-helpers/postgrest-swr";
export function useIndividual(individualId: string | null | undefined) {
const { data: individual, mutate, isLoading, error } = useQuery(Boolean(individualId) ?
supabase
.from('individuals')
.select('*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*)')
.eq("id", individualId)
.single() : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
return { individual: individual ?? null, isLoading, refetch: mutate, error };
}

View File

@@ -23,6 +23,6 @@
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../ReconOps/src/lib/utils.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../ReconOps/src/lib/utils.ts", "src/components/investigations/collide.js"],
"exclude": ["node_modules"]
}

View File

@@ -4666,6 +4666,15 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
d3-force@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
dependencies:
d3-dispatch "1 - 3"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-interpolate@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
@@ -4673,6 +4682,11 @@ csstype@^3.0.2:
dependencies:
d3-color "1 - 3"
"d3-quadtree@1 - 3", d3-quadtree@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"