47 Commits

Author SHA1 Message Date
dextmorgn
e8ca7b39bf fix(types): apply extra=allow via Pydantic v2 model_config
FlowsintType declared an inner `class ConfigDict: extra = "allow"`, which
Pydantic v2 treats as an ordinary nested class and ignores entirely. The
base therefore fell back to the default extra="ignore", silently dropping
user-supplied extra properties on every entity type. Replaced with
model_config = ConfigDict(extra="allow"). Extra keys are now retained.

flowsint-types (44), flowsint-enrichers (12) and flowsint-core (390) suites pass.
2026-05-30 19:03:30 +02:00
dextmorgn
1d409d7929 fix(scan): persist results to existing details column, errors to error column
The Scan model exposes details (JSON) and error (Text) columns, but the
enricher/flow tasks assigned to scan.results — a non-mapped attribute.
SQLAlchemy accepted the assignment silently, so commit() never persisted
it and the data was lost. Success paths now write scan.details; failure
paths write scan.error. ScanCreate.results renamed to details to match.

Scan repository tests pass.
2026-05-30 19:02:14 +02:00
dextmorgn
ee06d6cf3e fix(api): restrict CORS origins via ALLOWED_ORIGINS env
allow_origins was ["*"] together with allow_credentials=True, which is
both insecure and rejected by browsers for credentialed requests. Read a
comma-separated ALLOWED_ORIGINS env var instead, defaulting to the Vite
dev origin http://localhost:5173. Documented in .env.example.
2026-05-30 18:49:44 +02:00
dextmorgn
dd00bae9ce fix(api): run production container as non-root flowsint user
The production stage created the flowsint user and chowned all app
files to it, but the USER directive was commented out, so the API
ran as root. Uncommented it. Verified: image builds, container reports
whoami=flowsint and /health returns 200.
2026-05-30 18:49:43 +02:00
dextmorgn
c9d594a8e7 fix(api): load AUTH_SECRET from validated core.auth source
deps.py read AUTH_SECRET via os.getenv() before load_dotenv() ran,
yielding None when the secret was only present in .env. Import the
already-validated AUTH_SECRET from core.auth (loads dotenv first and
raises if unset) instead of re-reading it. Removes the dup + ordering bug.
2026-05-30 18:44:07 +02:00
dextmorgn
67ba8cfcc6 feat(chore): update SKILL.md 2026-05-21 11:09:28 +02:00
dextmorgn
8fbcf0c025 chore(skills): add flowsint-transform-builder claude skill
Project-scoped Claude Code skill guiding enricher and type creation.
Whitelists .claude/skills/flowsint-transform-builder/ in .gitignore so
the skill ships with the repo while keeping the rest of .claude/ ignored.
2026-05-15 18:55:56 +02:00
dextmorgn
d431ef4fae docs: sync with flowsint-types and flowsint-enrichers (v1.2.8)
- available-enrichers.mdx: add 17 missing enrichers, drop phantom entries
- managing-enrichers.mdx: fix stale Troubleshooting referencing EnricherRegistry.register()
- managing-types.mdx: note 8 registered-but-uncategorized types
- bump all frontmatter to version 1.2.8 / 2026-05-15
2026-05-15 18:55:25 +02:00
dextmorgn
5be480d4f5 Merge pull request #138 from z3rodaycve/feature/collection-enrichers
Release of my collection of Flowsint enrichers
2026-05-10 17:58:55 +02:00
dextmorgn
431d3997f4 Merge pull request #142 from harmsolo13/fix/celery-healthcheck
fix: replace celery container's inherited curl-based healthcheck
2026-04-29 11:35:17 +02:00
harmsolo13
d4dbe8d0fe fix: replace celery container's inherited curl-based healthcheck
The celery container builds from flowsint-api/Dockerfile which carries
a HEALTHCHECK directive that does `curl -f http://localhost:5001/health`.
The API container has an HTTP server on 5001 — celery doesn't, it's a
worker. So the inherited healthcheck always fails and `docker ps` shows
celery as (unhealthy) even when the worker is actively processing jobs.

This is cosmetic noise today but bites in two real ways: (1) restart
policies that key off health won't re-up celery on a real failure
because Docker can't tell good unhealthy from bad unhealthy, (2) any
service that adds `depends_on: celery: condition: service_healthy`
will refuse to start.

Fix: add a service-level healthcheck on celery in both compose files
(prod and dev) that uses celery's own `inspect ping` primitive
against the worker's broker. Compose-level healthcheck overrides the
Dockerfile-level one, so no Dockerfile change needed.

Smoke-tested locally: container goes from (unhealthy) to (healthy)
within ~30s of restart with no other changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +09:30
z3rodaycve
f79403d361 Fixed to_ssl enricher to use httpX Docker Tool 2026-04-24 19:49:20 +02:00
z3rodaycve
0d04423b59 Release of DeHashed, httpX, veriphone, scamalytics enrichers 2026-04-21 16:49:12 +02:00
dextmorgn
62e34c6e9b chore: update README.md with discord link 2026-04-19 13:18:55 +02:00
dextmorgn
2a4878c8fc chore: update gh workflow 2026-04-11 15:20:07 +02:00
dextmorgn
6a14484ccd Merge pull request #136 from reconurge/feat/rbac
Feat/rbac
2026-04-11 14:51:34 +02:00
dextmorgn
47b0c05115 feat(app): RBAC permission gating, UserAvatar component, share dialog redesign and responsive grids
- Add UserAvatar and AvatarGroup components to /ui/avatar, migrate all consumers
- Display collaborator avatars in top navbar with AvatarGroup
- Gate edit actions behind canEdit: toolbar (connect, merge, layouts), nodes-view,
  nodes-panel, relationships-view (checkboxes + delete), edge-details-panel,
  graph-navigation (Add tab), import sheet, analysis creation
- Add role badge (owner/admin/editor/viewer) with colored icons in case-header
- Redesign share-dialog: role badges as dropdown triggers, skeleton loading,
  owner/member separation, hover-reveal delete button
- Switch all card grids from viewport breakpoints to CSS container queries
  (dashboard-stats, investigations-list, sketches-section, metrics-grid, custom-types)
- Extend container query classes in styles.css for all needed column counts
2026-04-11 14:44:29 +02:00
dextmorgn
8cd6b237bb fix(core/tests): update failing test 2026-04-11 14:44:29 +02:00
dextmorgn
ce1f50bb72 chore: update yarn.lock 2026-04-11 14:44:28 +02:00
dextmorgn
f5e6fb79ae feat(app): RBAC permission gating, share dialog, profile page and user display
- Add useCan hook reading permissions from $investigationId layout route loader
- Add $investigationId layout route to fetch investigation and share data with children
- Add share dialog with user search autocomplete (debounced) for collaborator management
- Add /dashboard/profile page for editing first name, last name, avatar URL
- Gate all edit/create/delete actions based on user role (deny by default)
  - Graph: node drag, quick add, link creation, context menus, detail panel editing
  - Analysis: editor readonly, title readonly, actions hidden for viewers
  - Investigation: delete (owner only), share (admin+), new sketch/analysis (editor+)
  - Settings: sketch info readonly, delete sketch and import hidden for viewers
  - Selected items panel: enrich, delete, ask AI hidden for viewers
- Fix nav-user to show real user data instead of hardcoded placeholder
- Add user-display utils (getDisplayName, getInitials) shared across components
- Add avatar display in nav-user, case-header collaborators and share dialog
2026-04-11 14:44:28 +02:00
dextmorgn
bb502379bd feat(api): add collaborator endpoints, profile update, user search and backfill migration
- Add GET/POST/PUT/DELETE /investigations/{id}/collaborators endpoints
- Inject current_user_role in all investigation responses
- Add GET/PUT /auth/me for profile management
- Add GET /auth/users/search for share dialog autocomplete
- Add ProfileUpdate schema with extra=forbid
- Add email to ProfileRead
- Add backfill migration for existing investigations missing owner role entries
2026-04-11 14:44:28 +02:00
dextmorgn
1a5d164805 feat(core): add RBAC with ADMIN role, collaborator management and auth user data
- Add ADMIN role to Role enum (OWNER > ADMIN > EDITOR > VIEWER)
- Update permission matrix: ADMIN can read/create/update/manage
- Fix get_with_relations bug filtering by owner_id (blocked non-owner collaborators)
- Add collaborator management methods to InvestigationRepository and InvestigationService
- Return user profile data in auth response
2026-04-11 14:44:28 +02:00
dextmorgn
400b21a7d1 feat(api): install uv Dockerfile 2026-04-11 14:42:39 +02:00
dextmorgn
85a85ed541 chore: switch to uv 2026-04-11 13:17:14 +02:00
dextmorgn
00ec9d6f26 feat(types): switch to uv 2026-04-11 13:15:06 +02:00
dextmorgn
cfaece5af2 feat(enrichers): switch to uv 2026-04-11 13:14:53 +02:00
dextmorgn
6f3af239b2 feat(core): switch to uv 2026-04-11 13:14:41 +02:00
dextmorgn
5569a2f9b7 feat(api): switch to uv 2026-04-11 13:14:16 +02:00
dextmorgn
c5525a8f38 Merge pull request #129 from z3rodaycve/feature/hudsonrock-enrichers
HudsonRock Integration Enrichers for Infostealer related data
2026-04-09 11:50:33 +02:00
dextmorgn
a38a773657 Merge pull request #128 from hillw3318-ui/tags-input
feat(app): Added 'tags-input'. Changed the text input for aliases in details panel
2026-04-08 11:28:40 +02:00
dextmorgn
6852ee3213 fix: missing AUTH_SECRET 2026-04-06 10:02:58 +02:00
dextmorgn
609c957677 feat(enrichers): remove heavy logging on whoxy enrichers 2026-03-16 11:15:16 +01:00
dextmorgn
976bd0ae99 feat(app): vertical link rendering 2026-03-16 11:15:16 +01:00
dextmorgn
4f5c24295c Merge pull request #131 from reconurge/dextmorgn-patch-1
Update README with support badges
2026-03-16 11:14:00 +01:00
dextmorgn
57e72cb337 Update README with support badges
Added support badges for Buy Me A Coffee and Ko-fi.
2026-03-16 11:13:44 +01:00
William Hill
1d376b8ac0 Merge branch 'reconurge:main' into tags-input 2026-03-16 00:32:00 +03:00
bjst0ne
def29144eb fix(app): fixed lists/arrays render in 'details-panel' 2026-03-16 00:37:03 +03:00
bjst0ne
3e8e4c8423 Merge branch 'main' into tags-input 2026-03-10 13:37:48 +03:00
bjst0ne
cd67ae369b refactor(app): changed props for 'tags-input' 2026-03-10 12:54:16 +03:00
z3rodaycve
ff9d270ddd Release of HudsonRockToUsername, HudsonRockToPhone, HudsonRockToEmail 2026-03-10 10:24:39 +01:00
bjst0ne
5cdd8e70af feat(app): add copy for lists in details-panel 2026-03-02 02:33:57 +03:00
bjst0ne
15878019fa feat(app): add tags-input for list fields in creation form 2026-03-02 02:05:05 +03:00
bjst0ne
523a387661 refactor(app): change tags-input to stacked variant 2026-03-01 19:06:11 +03:00
bjst0ne
171c606e07 fix(app): reduced the height of tags-input 2026-03-01 19:06:11 +03:00
bjst0ne
22400f2e20 refactor(app): change styles to general design 2026-03-01 19:06:11 +03:00
bjst0ne
04e1c2f542 refactor(app): changed condition for aliases input 2026-03-01 19:06:10 +03:00
bjst0ne
ae81c216b0 feat(app): added 'tags-input'. Changed the text input for aliases to tags-input in the details panel 2026-03-01 19:06:10 +03:00
126 changed files with 13475 additions and 19950 deletions

View File

@@ -0,0 +1,178 @@
---
name: flowsint-transform-builder
description: Expert guidance for building Flowsint enrichers and their supporting types. Use when the user wants to add a new enricher, create a new Flowsint type, wire a new external API/tool into Flowsint, debug type/enricher discovery, or design a pivot from entity A to entity B. Knows where types live, how the enricher base class works, how vault secrets and params resolve, and when to recommend creating a new type instead of forcing data into an existing one.
---
# Flowsint Transform Builder
You build enrichers and types for Flowsint. You do not memorize the catalog — you know where to look and how the pieces fit. Always read source before generating code: type definitions and existing enrichers are the ground truth.
## Authoritative source paths
Read these first. Never assume signatures or fields — open the file.
| What | Path |
|---|---|
| Type definitions | `flowsint-types/src/flowsint_types/<name>.py` |
| Type registry + decorator | `flowsint-types/src/flowsint_types/registry.py` |
| Type package exports | `flowsint-types/src/flowsint_types/__init__.py` |
| Enricher base class | `flowsint-core/src/flowsint_core/core/enricher_base.py` |
| Enricher registry + decorator | `flowsint-enrichers/src/flowsint_enrichers/registry.py` |
| Existing enrichers (templates) | `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<output>.py` |
| UI category mapping | `flowsint-core/src/flowsint_core/core/services/type_registry_service.py` (`_get_category_definitions`) |
| Vault interface | `flowsint-core/src/flowsint_core/core/vault.py` |
| Logger interface | `flowsint-core/src/flowsint_core/core/logger.py` |
| Tools (external CLI/API wrappers) | `tools/` (top-level), e.g. `tools.network.subfinder.SubfinderTool` |
| Doc — types tutorial | `docs/developers/managing-types.mdx` |
| Doc — enrichers tutorial | `docs/developers/managing-enrichers.mdx` |
| Doc — enricher catalog | `docs/sources/available-enrichers.mdx` |
## The first question: new type or reuse?
When the user describes a transform, decide before writing code:
1. **List the entities involved** — input data, output data, intermediate fields you'll attach.
2. **For each, check `flowsint-types/src/flowsint_types/`** — open the closest candidate file and read its fields.
3. **Decide:**
- **Reuse** if existing type covers all required fields (extras allowed — `ConfigDict.extra = "allow"`).
- **Extend an existing type** if 12 fields are missing — propose adding optional fields to the existing model.
- **Create new type** if the entity is conceptually distinct (different primary key, different label semantics, different graph role).
4. **Never cram data into a wrong type.** If a "Domain" enricher returns risk scores, a `RiskProfile` exists — don't stuff scores into `Domain` metadata. If nothing fits, propose a new type and tell the user why.
Surface the decision to the user before generating code: list candidate types you found, what's missing, and your recommendation.
## Anatomy of an enricher
Minimum surface (read `enricher_base.py` for the full contract):
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_core.core.logger import Logger
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_types import Domain, Ip # or whatever types
@flowsint_enricher
class MyEnricher(Enricher):
"""[Source name] One-line purpose."""
InputType = Domain # base type, not List[Domain]
OutputType = Ip
@classmethod
def name(cls) -> str: return "domain_to_ip" # snake_case, unique
@classmethod
def category(cls) -> str: return "Domain" # see note on casing below
@classmethod
def key(cls) -> str: return "domain" # primary field of InputType
@classmethod
def get_params_schema(cls): # optional, only if params needed
return [...]
async def scan(self, data: List[InputType]) -> List[OutputType]:
...
def postprocess(self, results, input_data):
for src, dst in zip(input_data, results):
self.create_node(src)
self.create_node(dst)
self.create_relationship(src, dst, "RESOLVES_TO")
return results
InputType = MyEnricher.InputType
OutputType = MyEnricher.OutputType
```
**File location:** `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<target>.py`. The directory matches the input type's lowercase name. If no directory exists for your input type, create it (no `__init__.py` needed — auto-discovery walks the tree).
**Registration:** the `@flowsint_enricher` decorator does it. Do not edit any `registry.py`. API restart picks up new files via `load_all_enrichers()`.
## Params and secrets
Defined via `get_params_schema()` classmethod. Each entry is a dict:
| Field | Required | Notes |
|---|---|---|
| `name` | yes | Param key; for `vaultSecret`, also the default vault key name |
| `type` | yes | One of `string`, `number`, `select`, `url`, `vaultSecret` |
| `description` | yes | Shown in UI |
| `required` | no | Defaults to `false` |
| `default` | no | Default value |
| `options` | for `select` | List of `{"label": ..., "value": ...}` |
Read params inside `scan()`:
```python
mode = self.params.get("mode", "passive")
api_key = self.get_secret("MY_API_KEY") # vault-resolved during async_init
```
**Vault resolution flow** (see `Enricher.resolve_params` in `enricher_base.py`):
1. If user passed a vault ID in params → vault looked up by that ID.
2. Else → vault looked up by the param name (e.g. `MY_API_KEY`).
3. If `required: true` and nothing found → `Exception("Required vault secret 'MY_API_KEY' is missing...")`.
**Never hardcode keys.** Always declare a `vaultSecret` param. Document the expected vault key name in the docstring.
## Graph operations (postprocess)
`create_node(obj)` and `create_relationship(from_obj, to_obj, rel_label="IS_RELATED_TO")` take Pydantic objects directly. Don't manually construct node dicts — pass the typed instance.
Relationship label convention: `UPPER_SNAKE_CASE` verb phrase (`HAS_DOMAIN`, `RESOLVES_TO`, `FOUND_IN_BREACH`). Be consistent with existing enrichers — grep before inventing a new label.
`self.log_graph_message("...")` for graph-related progress logs. `Logger.info / error / warn(self.sketch_id, {"message": "..."})` for general logs.
## Creating a new type — checklist
When you decide a new type is warranted:
1. **File**: `flowsint-types/src/flowsint_types/<snake_case>.py`.
2. **Class**: `PascalCase`, inherit from `FlowsintType`, decorate with `@flowsint_type`.
3. **Exactly one primary field**: `Field(..., json_schema_extra={"primary": True})`. Must uniquely identify the entity (used as Neo4j MERGE key).
4. **`compute_label`**: `@model_validator(mode='after')`, sets `self.nodeLabel`, returns `self`. Handle `None` for optional fields.
5. **Export in `__init__.py`**: add import + entry in `__all__`.
6. **Category** (optional but recommended): add a `("MyType", "primary_field_name", icon)` tuple in `_get_category_definitions()` in `type_registry_service.py`. Without this, the type works as an enricher I/O but doesn't show in the UI type picker.
7. **Reinstall**: `cd flowsint-types && poetry install` (or `make prod` from repo root).
8. **Test**: write a `tests/test_<name>.py` covering creation, primary uniqueness, `compute_label` with full/partial fields.
Full template + patterns: `docs/developers/managing-types.mdx`.
## Naming conventions (already-established, don't break)
- Enricher `name()`: `<input>_to_<output>` snake_case (e.g. `domain_to_ip`, `email_to_breaches`).
- Enricher file: `to_<target>.py` under `<input_type>/` directory.
- Class name: descriptive PascalCase (e.g. `DomainToIpEnricher`, `WhoisEnricher`).
- Type class: PascalCase. Type file: snake_case.
- Relationship label: `UPPER_SNAKE_CASE` verb.
- Docstring of enricher class starts with `[ToolName/Source]` tag — convention used across the codebase (e.g. `"""[DeHashed] Get breach intelligence ..."""`).
**Known smell**: `category()` strings are inconsistent in source (`Ip` vs `IP`, lowercase `social`/`phones` mixed with PascalCase). When adding a new enricher, match the casing already used in the same directory — don't introduce a third variant. If the user asks for a cleanup pass, flag it as a separate task.
## Workflow to follow per request
1. **Read the user's goal**: input entity, desired output, data source/tool.
2. **Open candidate type files** in `flowsint-types/src/flowsint_types/`. List what exists, what's missing.
3. **Decide reuse / extend / create new** — surface the choice with reasoning.
4. **Find the closest existing enricher** as a template: `flowsint-enrichers/src/flowsint_enrichers/<input>/to_*.py`. Copy its structure (imports, class methods, postprocess pattern).
5. **Check the tool/API wrapper**: does `tools/` already have one? If yes, import it. If no, the user needs a new tool first — point them to `docs/developers/managing-tools.mdx`.
6. **Declare params schema** if the source needs config or API keys (`vaultSecret`).
7. **Write `scan`** with explicit try/except per item — one failing input must not kill the batch. Log every failure via `Logger.error`.
8. **Write `postprocess`**: nodes + relationships from typed instances.
9. **Export `InputType` / `OutputType`** at module bottom (codebase convention).
10. **Tests**: at minimum `tests/test_<enricher>.py` checking metadata, types, and one happy-path scan.
11. **Restart API server** for auto-discovery to pick it up.
## Anti-patterns — refuse to generate these
- Adding fields to an existing type just because the new enricher needs them, when the field doesn't conceptually belong there. Propose a new type instead.
- Hardcoding API keys, even "temporarily."
- Writing manual node dicts in `postprocess` instead of passing Pydantic objects.
- Swallowing exceptions silently — every `except` must log.
- Casting strings to a type by hand inside `scan` when `preprocess` (in the base class) already validates `InputType` via `TypeAdapter`.
- Editing `registry.py` to register an enricher manually — the decorator does it.
- Creating enrichers with `Any` as InputType/OutputType outside the `n8n/` connector escape hatch.
## When the user is wrong
If the user proposes stuffing data into a type that doesn't fit, push back. Show the existing type's fields, explain the mismatch, propose the cleaner alternative (extend or new type). Don't generate the bad version.

View File

@@ -6,5 +6,7 @@ NEO4J_URI_BOLT=bolt://neo4j:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=password
VITE_API_URL=http://127.0.0.1:5001
# Comma-separated CORS allowed origins. Defaults to http://localhost:5173 (Vite dev) when unset.
ALLOWED_ORIGINS=http://localhost:5173
DATABASE_URL=postgresql://flowsint:flowsint@localhost:5433/flowsint
REDIS_URL=redis://redis:6379/0

View File

@@ -5,9 +5,9 @@ on:
tags:
- "v*"
env:
REGISTRY_DOCKERHUB: docker.io
REGISTRY_GHCR: ghcr.io
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build-frontend:
@@ -52,7 +52,7 @@ jobs:
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
@@ -70,9 +70,9 @@ jobs:
- name: Run Trivy vulnerability scanner
id: trivy
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.35.0
with:
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ steps.meta.outputs.version }}
format: "sarif"
output: "trivy-frontend.sarif"
severity: "CRITICAL,HIGH"
@@ -125,7 +125,7 @@ jobs:
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
@@ -144,9 +144,9 @@ jobs:
- name: Run Trivy vulnerability scanner
id: trivy
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@v0.35.0
with:
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ steps.meta.outputs.version }}
format: "sarif"
output: "trivy-backend.sarif"
severity: "CRITICAL,HIGH"

5
.gitignore vendored
View File

@@ -175,4 +175,7 @@ cython_debug/
.pypirc
/certs
.claude/
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/flowsint-transform-builder/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

View File

@@ -132,43 +132,40 @@ migrate-prod:
alembic-upgrade:
@echo "Running Alembic migrations (upgrade head)..."
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic upgrade head
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic upgrade head
alembic-downgrade:
@echo "Rolling back last Alembic migration..."
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic downgrade -1
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic downgrade -1
alembic-revision:
@if [ -z "$(m)" ]; then \
echo "Usage: make alembic-revision m=\"your migration message\""; exit 1; \
fi
@echo "Creating new Alembic migration: $(m)"
cd $(PROJECT_ROOT)/flowsint-api && poetry run alembic revision --autogenerate -m "$(m)"
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic revision --autogenerate -m "$(m)"
api:
cd $(PROJECT_ROOT)/flowsint-api && \
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
frontend:
cd $(PROJECT_ROOT)/flowsint-app && yarn dev
celery:
cd $(PROJECT_ROOT)/flowsint-api && \
poetry run celery -A flowsint_core.core.celery \
uv run celery -A flowsint_core.core.celery \
worker --loglevel=info --pool=threads --concurrency=10
test:
cd flowsint-types && poetry run pytest
cd flowsint-core && poetry run pytest
cd flowsint-enrichers && poetry run pytest
cd flowsint-types && uv run pytest
cd flowsint-core && uv run pytest
cd flowsint-enrichers && uv run pytest
install:
poetry config virtualenvs.in-project true --local
$(MAKE) infra-dev
poetry install
cd flowsint-core && poetry install
cd flowsint-enrichers && poetry install
cd flowsint-api && poetry install && poetry run alembic upgrade head
uv sync
cd flowsint-api && uv run alembic upgrade head
status:
@echo "=== DEV Containers ==="
@@ -190,9 +187,7 @@ clean:
-$(COMPOSE_PROD) down -v --rmi all --remove-orphans
-$(COMPOSE_DEPLOY) down -v --rmi all --remove-orphans
rm -rf flowsint-app/node_modules
rm -rf flowsint-core/.venv
rm -rf flowsint-enrichers/.venv
rm -rf flowsint-api/.venv
rm -rf .venv
regenerate-router:
@echo "Regenerating flowsint-app/src/routeTree.gen.ts"

View File

@@ -1,7 +1,11 @@
# Flowsint
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](./LICENSE)
[![License](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](./LICENSE)
[![Ethical Software](https://img.shields.io/badge/ethical-use-blue.svg)](./ETHICS.md)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/aST9HMQr)
Flowsint is an open-source OSINT graph exploration tool designed for ethical investigation, transparency, and verification.
@@ -206,19 +210,19 @@ Each module has its own (incomplete) test suite:
```bash
# Test core module
cd flowsint-core
poetry run pytest
uv run pytest
# Test types module
cd ../flowsint-types
poetry run pytest
uv run pytest
# Test enrichers module
cd ../flowsint-enrichers
poetry run pytest
uv run pytest
# Test API module
cd ../flowsint-api
poetry run pytest
uv run pytest
```
## Contributing
@@ -250,3 +254,7 @@ It was created to assist:
Any misuse of this software is strictly prohibited and goes against the ethical principles defined in [ETHICS.md](./ETHICS.md).
## ❤️ Support
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)

View File

@@ -122,6 +122,15 @@ services:
- MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
- REDIS_URL=redis://redis:6379/0
- SKIP_MIGRATIONS=true
- AUTH_SECRET=${AUTH_SECRET}
healthcheck:
# Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
# Use celery's own ping primitive instead.
test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
postgres:
condition: service_healthy

View File

@@ -122,6 +122,15 @@ services:
- MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
- REDIS_URL=redis://redis:6379/0
- SKIP_MIGRATIONS=true
- AUTH_SECRET=${AUTH_SECRET}
healthcheck:
# Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
# Use celery's own ping primitive instead.
test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
postgres:
condition: service_healthy

View File

@@ -0,0 +1,18 @@
---
title: "Getting started"
description: "This guide explains how you can contribute to Flowsint."
category: "Developers"
order: 7
author: "Flowsint Team"
tags: ["tutorial", "developers", "getting-started"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Contributing
Your contribution to Flowsint is highly encouraged !
You can create your own [Types](/docs/developers/managing-types), [Tools](/docs/developers/managing-tools), [Enrichers](/docs/developers/managing-enrichers) and Flows, and sharing them by proposing [pull requests](https://github.com/reconurge/flowsint/pulls).
Before diving in, you may also want to understand the [Graph format](/docs/developers/graph-format) to learn how nodes and edges are structured in the frontend visualization.

View File

@@ -0,0 +1,244 @@
---
title: "Graph format"
description: "Reference for the GraphNode and GraphEdge types used to represent entities and relationships in the Flowsint graph. Understanding these structures is essential for working with the frontend visualization and the graph database layer."
category: "Developers"
order: 11
author: "Flowsint Team"
tags: ["tutorial", "developers", "graph", "nodes", "edges"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Overview
Flowsint's graph visualization is built on two core data structures: **GraphNode** (representing entities) and **GraphEdge** (representing relationships between entities). These types are defined in `flowsint-app/src/types/graph.ts` and are used throughout the frontend for rendering, interaction, and data management.
Understanding these structures is important when:
- Building enrichers that create nodes and relationships
- Working on the frontend visualization
- Debugging graph rendering issues
- Extending the graph with new visual features
## GraphNode
A `GraphNode` represents a single entity in the graph (e.g., a domain, an IP address, a person). Every node created by an enricher's `create_node()` method ends up as a `GraphNode` in the frontend.
```typescript
type GraphNode = {
id: string
nodeType: string
nodeLabel: string
nodeProperties: NodeProperties
nodeSize: number
nodeColor: string | null
nodeIcon: keyof typeof LucideIcons | null
nodeImage: string | null
nodeFlag: flagColor | null
nodeShape: NodeShape | null
nodeMetadata: NodeMetadata
x: number
y: number
val?: number
neighbors?: any[]
links?: any[]
}
```
### Field reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier for the node. Derived from the type's primary field value. |
| `nodeType` | `string` | The entity type (e.g., `"Domain"`, `"Ip"`, `"Email"`). Maps to the Pydantic type class name. |
| `nodeLabel` | `string` | Human-readable label displayed on the node. Set by the type's `compute_label()` method. |
| `nodeProperties` | `NodeProperties` | All properties of the entity as key-value pairs. Contains the serialized fields from the Pydantic type. |
| `nodeSize` | `number` | Visual size of the node in the graph. |
| `nodeColor` | `string \| null` | Custom color override for the node (CSS color string), or `null` for default. |
| `nodeIcon` | `keyof LucideIcons \| null` | Icon to display on the node, from the [Lucide icon set](https://lucide.dev/icons/), or `null` for default. |
| `nodeImage` | `string \| null` | URL to an image to display on the node (e.g., a profile picture), or `null`. |
| `nodeFlag` | `flagColor \| null` | Color flag for visual tagging (see Flag colors below), or `null`. |
| `nodeShape` | `NodeShape \| null` | Shape of the node (see Node shapes below), or `null` for default circle. |
| `nodeMetadata` | `NodeMetadata` | Additional metadata as key-value pairs. Used for internal tracking and extended features. |
| `x` | `number` | X coordinate position in the graph canvas. |
| `y` | `number` | Y coordinate position in the graph canvas. |
| `val` | `number` (optional) | Value used by the force-graph engine for sizing calculations. |
| `neighbors` | `any[]` (optional) | Adjacent nodes, populated by the graph engine at runtime. |
| `links` | `any[]` (optional) | Connected edges, populated by the graph engine at runtime. |
### NodeProperties
```typescript
type NodeProperties = {
[key: string]: any
}
```
A flexible key-value object containing all the entity's properties. When a Pydantic type is serialized into a node, its fields become entries in `nodeProperties`. For example, a `Domain` node would have `nodeProperties.domain`, `nodeProperties.root`, etc.
### NodeMetadata
```typescript
type NodeMetadata = {
[key: string]: any
}
```
Similar to `nodeProperties` but used for system-level metadata rather than entity data. This can include information like creation timestamps, source enricher, or other tracking data.
### Node shapes
Nodes can be rendered in four shapes:
```typescript
type NodeShape = 'circle' | 'square' | 'hexagon' | 'triangle'
```
| Shape | Use case |
|-------|----------|
| `circle` | Default shape for most entity types |
| `square` | Often used for infrastructure entities |
| `hexagon` | Used for grouped or aggregate entities |
| `triangle` | Used for alert or warning-related entities |
### Flag colors
Flags provide a visual tagging system for nodes. Users can flag nodes to highlight them during an investigation.
```typescript
type flagColor = 'red' | 'orange' | 'blue' | 'green' | 'yellow'
```
Each flag color maps to specific Tailwind CSS classes for consistent styling:
| Flag | Style |
|------|-------|
| `red` | `text-red-400 fill-red-200` |
| `orange` | `text-orange-400 fill-orange-200` |
| `blue` | `text-blue-400 fill-blue-200` |
| `green` | `text-green-400 fill-green-200` |
| `yellow` | `text-yellow-400 fill-yellow-200` |
## GraphEdge
A `GraphEdge` represents a relationship between two nodes (e.g., "RESOLVES_TO", "HAS_SUBDOMAIN", "FOUND_IN_BREACH"). Every relationship created by an enricher's `create_relationship()` method ends up as a `GraphEdge` in the frontend.
```typescript
type GraphEdge = {
source: GraphNode['id']
target: GraphNode['id']
date?: string
id: string
label: string
caption?: string
type?: string
weight?: number
confidence_level?: number | string
}
```
### Field reference
| Field | Type | Description |
|-------|------|-------------|
| `source` | `string` | ID of the source node (the "from" node in the relationship). |
| `target` | `string` | ID of the target node (the "to" node in the relationship). |
| `id` | `string` | Unique identifier for this edge. |
| `label` | `string` | Display label for the edge (e.g., `"RESOLVES_TO"`, `"HAS_EMAIL"`). This is the relationship type string passed to `create_relationship()`. |
| `date` | `string` (optional) | Timestamp associated with the relationship (ISO 8601 format). |
| `caption` | `string` (optional) | Additional descriptive text displayed on or near the edge. |
| `type` | `string` (optional) | Relationship classification type for filtering or styling. |
| `weight` | `number` (optional) | Numerical weight of the relationship, can be used for visual thickness or importance ranking. |
| `confidence_level` | `number \| string` (optional) | Confidence score for the relationship, indicating how reliable the connection is. |
## How enrichers map to the graph
When you write an enricher and call graph methods in `postprocess()`, here's how the data flows:
### Creating nodes
```python
# In your enricher's postprocess method
self.create_node(domain) # domain is a Pydantic Domain object
```
The graph service automatically:
1. Extracts the **type name** from the Pydantic class (e.g., `"Domain"`) -> `nodeType`
2. Reads the **primary field** value (e.g., `domain.domain`) -> used for `id`
3. Reads the **nodeLabel** field (set by `compute_label()`) -> `nodeLabel`
4. Serializes all fields into `nodeProperties`
5. Sets defaults for visual properties (`nodeSize`, `nodeColor`, etc.)
### Creating relationships
```python
# In your enricher's postprocess method
self.create_relationship(domain, ip, "RESOLVES_TO")
```
The graph service automatically:
1. Identifies the **source node** from `domain`'s type and primary field -> `source`
2. Identifies the **target node** from `ip`'s type and primary field -> `target`
3. Uses the relationship label `"RESOLVES_TO"` -> `label`
4. Generates a unique `id` for the edge
### Relationship naming conventions
Relationship labels follow these conventions:
- Use **UPPER_SNAKE_CASE** (e.g., `"RESOLVES_TO"`, `"HAS_SUBDOMAIN"`)
- Use **verb phrases** that describe the relationship direction (source -> target)
- The default relationship label is `"IS_RELATED_TO"` if none is specified
- Common patterns:
- `HAS_*` for ownership/containment (e.g., `"HAS_EMAIL"`, `"HAS_SUBDOMAIN"`)
- `RESOLVES_TO` for DNS-type lookups
- `FOUND_IN_*` for breach/leak associations
- `REGISTERED_BY` for WHOIS data
## Graph settings
The graph visualization is configurable through settings types:
### ForceGraphSetting
Controls the physics simulation of the force-directed graph layout:
```typescript
type ForceGraphSetting = {
value: any
min?: number
max?: number
step?: number
type?: string
description?: string
}
```
### ExtendedSetting
General-purpose settings with richer type information:
```typescript
type ExtendedSetting = {
value: any
type: string
min?: number
max?: number
step?: number
options?: { value: string; label: string }[]
description?: string
}
```
These settings allow users to customize graph behavior like node repulsion, link distance, simulation speed, and visual preferences.
## Reserved properties
When creating nodes, certain property names are reserved by the system and should not be used as field names in your custom types:
- `id` - Node identifier
- `x`, `y` - Position coordinates
- `nodeLabel` - Display label (set by `compute_label()`)
- `label` - Legacy label field
- `nodeType`, `type` - Entity type identifiers
- `nodeImage`, `nodeIcon`, `nodeColor`, `nodeSize` - Visual properties
- `created_at` - Creation timestamp
- `sketch_id` - Investigation sketch association

View File

@@ -0,0 +1,715 @@
---
title: "Managing enrichers"
description: "Quick start guide to creating Enricher for your OSINT investigations."
category: "Developers"
order: 10
author: "Flowsint Team"
tags: ["tutorial", "developers", "creating-a-new-enricher"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Understanding Enrichers
Enrichers are the high-level business logic layer in Flowsint. While types define data structures and tools wrap external utilities, enrichers orchestrate the entire intelligence gathering workflow. An enricher takes input data of one type, processes it through various tools or APIs, validates and enriches the results, creates graph database nodes and relationships, and returns structured output.
Every enricher in Flowsint follows a two-phase execution model. The scan phase contains the core enriching logic where tools are executed, APIs are called, and data is gathered. Then, the postprocessing phase creates Neo4j graph nodes and relationships while returning the processed results. Input validation and normalization happens automatically through Pydantic type validation.
Enrichers differ from tools in several fundamental ways. Tools are low-level wrappers that return raw data without knowledge of the Flowsint ecosystem. Enrichers are high-level workflows that understand types, create graph nodes, handle parameters, and orchestrate multiple tools. When you want to add a new data source, you create a tool. When you want to add a new intelligence workflow, you create an enricher.
## Enricher architecture
The enricher system is built around an abstract base class that defines the interface and execution flow. Every enricher you create inherits from this base class and implements specific methods.
### The Enricher base class
The base class lives at `flowsint-core/src/flowsint_core/core/enricher_base.py` and provides the framework for all enrichers. Here's what a minimal enricher looks like:
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_types import Domain, Ip
@flowsint_enricher
class MyEnricher(Enricher):
"""Description of what this enricher does."""
# Define input and output types as base types (not lists)
InputType = Domain
OutputType = Ip
@classmethod
def name(cls) -> str:
"""Unique identifier for this enricher."""
return "domain_to_ip"
@classmethod
def category(cls) -> str:
"""Category this enricher belongs to."""
return "Domain"
@classmethod
def key(cls) -> str:
"""Primary key field name for this enricher."""
return "domain"
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""Core enriching logic."""
pass
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""Create graph nodes and relationships."""
pass
# Export types for easy access
InputType = MyEnricher.InputType
OutputType = MyEnricher.OutputType
```
The `InputType` and `OutputType` class attributes define what data the enricher accepts and returns. These should be Pydantic types from the `flowsint-types` package defined as base types (e.g., `Domain`, not `List[Domain]`). The base class uses these type definitions to automatically generate JSON schemas for the API and handle validation automatically.
At the end of the file, you should export the types for easy access by other modules.
### The two phases
Understanding the two execution phases is crucial for writing effective enrichers.
**Scanning** is where the real work happens. This async method receives validated input data as a list of `InputType` instances and executes your intelligence gathering logic. You might instantiate tools, call external APIs, process results, and build up your output data. This phase should focus purely on gathering and processing data without worrying about the graph database. The base class automatically handles input validation through Pydantic before the scan phase begins.
**Postprocessing** creates the graph database structure. After scanning completes, this method receives both the results and the original input. It creates Neo4j nodes for each entity, establishes relationships between them, and returns the final results. This separation keeps graph logic separate from business logic.
## Creating a simple enricher
Let's walk through creating a complete enricher from scratch. We'll build an enricher that converts domains to IP addresses using DNS resolution.
### Setting up the file structure
Enrichers are organized by their input type. Create a new file in the appropriate directory under `flowsint-enrichers/src/flowsint_enrichers/`:
```bash
cd flowsint-enrichers/src/flowsint_enrichers/domain/
touch to_ip.py
```
If you're creating an enricher for a new input type, you may need to create a new directory first.
### Implementing the basic structure
Start with the imports and class definition:
```python
import socket
from typing import List
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from flowsint_core.core.logger import Logger
from flowsint_types import Domain, Ip
@flowsint_enricher
class DomainToIpEnricher(Enricher):
"""Resolves domain names to their IP addresses using DNS."""
# Define types as base types (not lists)
InputType = Domain
OutputType = Ip
@classmethod
def name(cls) -> str:
return "domain_to_ip"
@classmethod
def category(cls) -> str:
return "Domain"
@classmethod
def key(cls) -> str:
return "domain"
@classmethod
def documentation(cls) -> str:
return """
This enricher resolves domain names to their IP addresses using
standard DNS queries. It accepts a list of domains and returns
the corresponding IP addresses.
"""
# Export types at the end of the file
InputType = DomainToIpEnricher.InputType
OutputType = DomainToIpEnricher.OutputType
```
The `name()` method returns a unique identifier for this enricher. Use lowercase with underscores, following the pattern `inputtype_to_outputtype`. The `category()` groups related enrichers together in the UI. The `key()` specifies which field serves as the primary identifier, typically matching the input type.
### Implementing the scan logic
The scan method contains your core intelligence gathering logic. It receives a list of validated `InputType` instances and returns a list of `OutputType` instances.
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Resolve each domain to its IP address.
Args:
data: List of Domain objects to resolve
Returns:
List of Ip objects
"""
results: List[OutputType] = []
for domain in data:
try:
# Perform DNS resolution
ip_address = socket.gethostbyname(domain.domain)
# Create IP object
ip = Ip(address=ip_address)
results.append(ip)
# Log successful resolution
Logger.info(
self.sketch_id,
{"message": f"Resolved {domain.domain} to {ip_address}"}
)
except socket.gaierror as e:
# DNS resolution failed
Logger.info(
self.sketch_id,
{"message": f"Failed to resolve {domain.domain}: {e}"}
)
continue
except Exception as e:
# Unexpected error
Logger.error(
self.sketch_id,
{"message": f"Error resolving {domain.domain}: {e}"}
)
continue
return results
```
This implementation iterates through each domain, performs DNS resolution, creates an IP object for successful resolutions, and logs both successes and failures. The error handling ensures that failures don't crash the entire enricher, which is important when processing many domains.
The input data has already been validated by Pydantic before reaching the scan method, so you can trust that all items are proper `Domain` objects.
### Implementing postprocessing
The postprocess method creates graph database nodes and relationships using the new simplified API:
```python
def postprocess(self, results: List[OutputType], input_data: List[InputType] = None) -> List[OutputType]:
"""
Create graph nodes and relationships.
Args:
results: IP objects from scan phase
input_data: Original Domain objects (preprocessed input)
Returns:
IP objects (unchanged)
"""
# Create nodes and relationships
for domain, ip in zip(input_data, results):
# Create nodes by passing Pydantic objects directly
self.create_node(domain)
self.create_node(ip)
# Create relationship by passing Pydantic objects directly
self.create_relationship(domain, ip, "RESOLVES_TO")
# Log the operation
self.log_graph_message(
f"IP found for domain {domain.domain} -> {ip.address}"
)
return results
```
You can pass Pydantic objects directly to `create_node()` and `create_relationship()`. The methods automatically infer the node types, primary keys, and property values from the Pydantic models.
The `create_node()` method accepts a Pydantic object and automatically creates a Neo4j node with the correct label and properties. The `create_relationship()` method takes two Pydantic objects and a relationship type string, inferring all necessary information from the objects.
## Creating an enricher with tools
Most enrichers use external tools for data gathering. Let's create an enricher that uses the Subfinder tool for subdomain enumeration.
### Importing the tool
Start by importing the tool along with your other dependencies:
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.logger import Logger
from flowsint_types import Domain
from tools.network.subfinder import SubfinderTool
@flowsint_enricher
class SubdomainEnricher(Enricher):
"""Enumerates subdomains for given domains using Subfinder."""
# Define types as base types
InputType = Domain
OutputType = Domain
@classmethod
def name(cls) -> str:
return "domain_to_subdomains"
@classmethod
def category(cls) -> str:
return "Domain"
@classmethod
def key(cls) -> str:
return "domain"
# Export types
InputType = SubdomainEnricher.InputType
OutputType = SubdomainEnricher.OutputType
```
### Using the tool in scan
The scan method instantiates and uses the tool:
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Find subdomains using Subfinder tool.
Args:
data: List of Domain objects
Returns:
List of discovered subdomain Domain objects
"""
results: List[OutputType] = []
# Instantiate the tool
subfinder = SubfinderTool()
for domain in data:
Logger.info(
self.sketch_id,
{"message": f"Enumerating subdomains for {domain.domain}"}
)
try:
# Launch the tool
subdomains = subfinder.launch(domain.domain)
# Convert strings to Domain objects
for subdomain in subdomains:
results.append(Domain(domain=subdomain, root=False))
Logger.info(
self.sketch_id,
{"message": f"Found {len(subdomains)} subdomains for {domain.domain}"}
)
except Exception as e:
Logger.error(
self.sketch_id,
{"message": f"Error enumerating subdomains for {domain.domain}: {e}"}
)
continue
return results
```
Notice how the tool returns raw strings, and the enricher converts them into proper Domain objects. This separation of concerns keeps tools simple while enrichers handle type conversion.
### Creating graph nodes and relationships
The postprocess phase creates parent-child relationships between domains and subdomains:
```python
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""
Create graph nodes and relationships for domains and subdomains.
Args:
results: Discovered subdomain Domain objects
input_data: Original parent Domain objects
Returns:
Subdomain Domain objects
"""
# Create nodes for parent domains
for domain in input_data:
self.create_node(domain)
# Create nodes for subdomains and relationships
for subdomain in results:
self.create_node(subdomain)
# Extract parent domain name and create relationship
parent_domain_name = self._extract_parent_domain(subdomain.domain)
parent_domain = Domain(domain=parent_domain_name)
# Create relationship using Pydantic objects
self.create_relationship(parent_domain, subdomain, "HAS_SUBDOMAIN")
# Log the operation
self.log_graph_message(
f"Subdomain found: {parent_domain_name} -> {subdomain.domain}"
)
return results
def _extract_parent_domain(self, subdomain: str) -> str:
"""Extract parent domain from subdomain."""
parts = subdomain.split('.')
if len(parts) >= 2:
return '.'.join(parts[-2:])
return subdomain
```
## Adding parameters to enrichers
Many enrichers need user-configurable parameters. Let's create an enricher that scans ports with configurable options.
### Defining the parameter schema
The `get_params_schema()` class method defines what parameters your enricher accepts:
```python
from typing import List, Dict, Any, Optional
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from flowsint_types import Ip, Port
from tools.network.naabu import NaabuTool
@flowsint_enricher
class IpToPortsEnricher(Enricher):
"""Scans IP addresses for open ports."""
# Define types as base types
InputType = Ip
OutputType = Port
@classmethod
def name(cls) -> str:
return "ip_to_ports"
@classmethod
def category(cls) -> str:
return "IP"
@classmethod
def key(cls) -> str:
return "address"
@classmethod
def get_params_schema(cls) -> List[Dict[str, Any]]:
"""Define configurable parameters for this enricher."""
return [
{
"name": "mode",
"type": "select",
"description": "Scan mode: active scanning or passive enumeration",
"required": True,
"default": "passive",
"options": [
{"label": "Passive", "value": "passive"},
{"label": "Active", "value": "active"},
],
},
{
"name": "port_range",
"type": "string",
"description": "Port range to scan (e.g., '1-1000' or '80,443,8080')",
"required": False,
},
{
"name": "top_ports",
"type": "select",
"description": "Scan only the most common ports",
"required": False,
"options": [
{"label": "Top 100", "value": "100"},
{"label": "Top 1000", "value": "1000"},
],
},
{
"name": "PDCP_API_KEY",
"type": "vaultSecret",
"description": "ProjectDiscovery Cloud Platform API key for passive mode",
"required": False,
},
]
# Export types
InputType = IpToPortsEnricher.InputType
OutputType = IpToPortsEnricher.OutputType
```
The parameter schema defines the type, description, whether it's required, default values, and for select parameters, the available options. The `vaultSecret` type integrates with Flowsint's encrypted credential storage.
### Using parameters in your enricher
Parameters are accessed through `self.params` in your scan method:
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Scan IPs for open ports using configured parameters.
Args:
data: List of Ip objects to scan
Returns:
List of Port objects
"""
results: List[OutputType] = []
# Extract parameters
mode = self.params.get("mode", "passive")
port_range = self.params.get("port_range")
top_ports = self.params.get("top_ports")
api_key = self.get_secret("PDCP_API_KEY")
# Instantiate tool
naabu = NaabuTool()
for ip in data:
Logger.info(
self.sketch_id,
{"message": f"Scanning {ip.address} in {mode} mode"}
)
try:
# Launch tool with parameters
scan_results = naabu.launch(
target=ip.address,
mode=mode,
port_range=port_range,
top_ports=top_ports,
api_key=api_key
)
# Convert tool results to Port objects
for result in scan_results:
port = Port(
number=result.get("port"),
protocol=result.get("protocol", "tcp").upper(),
state="open",
service=result.get("service"),
banner=result.get("version")
)
results.append(port)
except Exception as e:
Logger.error(
self.sketch_id,
{"message": f"Error scanning {ip.address}: {e}"}
)
continue
return results
```
## Handling multiple output types
Some enrichers produce multiple types of results. You can define a custom return type using Pydantic:
```python
from pydantic import BaseModel
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from typing import List
from flowsint_types import Website, Email, Phone
class CrawlerResults(BaseModel):
"""Results from web crawler including multiple entity types."""
website: Website
emails: List[Email] = []
phones: List[Phone] = []
@flowsint_enricher
class WebsiteToCrawlerEnricher(Enricher):
"""Crawls websites to extract emails and phone numbers."""
# Define types as base types
InputType = Website
OutputType = CrawlerResults
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""Crawl websites and extract contact information."""
from tools.network.reconcrawl import ReconCrawlTool
results: List[OutputType] = []
crawler_tool = ReconCrawlTool()
for website in data:
try:
# Launch crawler
crawl_data = crawler_tool.launch(website.url)
# Extract entities
emails = [Email(email=e) for e in crawl_data.get("emails", [])]
phones = [Phone(number=p) for p in crawl_data.get("phones", [])]
# Create result object
result = CrawlerResults(
website=website,
emails=emails,
phones=phones
)
results.append(result)
except Exception as e:
Logger.error(self.sketch_id, {"message": f"Crawl error: {e}"})
return results
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""Create nodes for all discovered entities."""
for result in results:
# Create website node using Pydantic object
self.create_node(result.website)
# Create email nodes and relationships
for email in result.emails:
self.create_node(email)
self.create_relationship(result.website, email, "HAS_EMAIL")
# Create phone nodes and relationships
for phone in result.phones:
self.create_node(phone)
self.create_relationship(result.website, phone, "HAS_PHONE")
self.log_graph_message(
f"Processed {len(result.emails)} emails and {len(result.phones)} phones from {result.website.url}"
)
return results
# Export types
InputType = WebsiteToCrawlerEnricher.InputType
OutputType = WebsiteToCrawlerEnricher.OutputType
```
## Registering your enricher
You don't need to register your enricher anywhere, adding the decorator `@flowsint_enricher` to your enricher class triggers the auto discovery.
```python
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
@flowsint_enricher
class MyEnricher(Enricher):
...
```
## Testing your enricher
Creating tests helps ensure your enricher works correctly and makes debugging easier. Create a test file in `flowsint-enrichers/tests/`:
```python
# tests/test_domain_to_ip.py
import pytest
from flowsint_enrichers.domain.to_ip import DomainToIpEnricher
from flowsint_types import Domain, Ip
@pytest.mark.asyncio
async def test_enricher_metadata():
"""Test enricher metadata is correctly defined."""
assert DomainToIpEnricher.name() == "domain_to_ip"
assert DomainToIpEnricher.category() == "Domain"
assert DomainToIpEnricher.key() == "domain"
@pytest.mark.asyncio
async def test_type_definitions():
"""Test InputType and OutputType are correctly defined."""
assert DomainToIpEnricher.InputType == Domain
assert DomainToIpEnricher.OutputType == Ip
@pytest.mark.asyncio
async def test_scan():
"""Test DNS resolution works."""
enricher = DomainToIpEnricher(sketch_id="test", scan_id="test")
input_data = [Domain(domain="example.com")]
results = await enricher.scan(input_data)
assert len(results) > 0
assert isinstance(results[0], Ip)
assert results[0].address # Should have an IP address
```
These tests verify that your enricher's metadata is correct, type definitions are properly set, and the scan logic produces expected results. Input validation is handled automatically by Pydantic, so you don't need to test preprocessing separately.
## Best practices
When creating enrichers, think carefully about error handling. Intelligence gathering involves many external systems that can fail in unpredictable ways. Your enricher should handle errors gracefully, log failures clearly, and continue processing remaining items rather than crashing entirely.
Always use the Logger utility for tracking progress and errors. The logger integrates with Flowsint's monitoring system and helps users understand what's happening during long-running enrichers. Log successful operations at the info level and errors at the error level.
Define InputType and OutputType as base types (e.g., `Domain`, not `List[Domain]`). The base class automatically handles the list wrapping and validation. This makes the type definitions cleaner and more intuitive.
Always export your types at the end of the file using:
```python
InputType = YourEnricher.InputType
OutputType = YourEnricher.OutputType
```
Use the simplified graph API by passing Pydantic objects directly to `create_node()` and `create_relationship()`. This eliminates boilerplate code and reduces errors. The methods automatically infer node types, primary keys, and properties from your Pydantic models.
Separate concerns between the two phases. Scanning should focus on gathering and processing data. Postprocessing should create graph structures. Input validation happens automatically through Pydantic, so you don't need to handle it manually.
Use type hints everywhere. They provide automatic validation, better IDE support, and serve as inline documentation. The InputType and OutputType class attributes should always be Pydantic types from the flowsint-types package.
When working with tools, remember that they return raw data structures. Your enricher is responsible for converting tool output into proper Flowsint types. This type conversion is typically done in the scan phase.
Document your enricher thoroughly. The class docstring, documentation method, and parameter descriptions all appear in the UI. Clear documentation helps users understand what your enricher does and how to configure it.
### Handling API Rate Limits
When working with rate-limited APIs, add delays between requests:
```python
async def scan(self, data: InputType) -> OutputType:
"""Scan with rate limiting to respect API limits."""
import asyncio
results = []
delay_seconds = 1 # Delay between requests
for item in data:
result = await self._query_api(item)
if result:
results.append(result)
# Respect rate limits
await asyncio.sleep(delay_seconds)
return results
```
### Fallback data sources
Implement fallback logic when primary sources fail:
```python
async def scan(self, data: InputType) -> OutputType:
"""Try multiple data sources with fallback logic."""
results = []
for domain in data:
# Try primary source
result = self._query_primary_source(domain)
if not result:
# Fall back to secondary source
Logger.info(
self.sketch_id,
{"message": f"Primary source failed for {domain}, trying fallback"}
)
result = self._query_fallback_source(domain)
if result:
results.append(result)
return results
```
## Troubleshooting
If your enricher doesn't appear in the API, verify that you've applied the `@flowsint_enricher` decorator to your class, that the file lives under `flowsint-enrichers/src/flowsint_enrichers/`, and that you've restarted the API server. Auto-discovery via `load_all_enrichers()` runs at startup, so file additions require a restart.
For import errors, make sure all your dependencies are installed and the enricher file has no syntax errors. Check that you're importing from the correct packages.
If the scan method isn't finding any results, add logging statements to debug what's happening. Verify that tools are installed and accessible, API keys are valid if required, and input data is in the expected format.
When graph relationships aren't appearing, check that you're creating both nodes and relationships in the postprocess method. Verify that the relationship type name is correct and that you're passing the right node objects to `create_relationship()`.
## Next steps
Once you've created and registered your enricher, it becomes available through the API for users to run. Enrichers can be chained together into Flows where the output of one enricher feeds into the input of another. This enables complex intelligence gathering sequences.
Remember that enrichers are the heart of Flowsint's intelligence gathering capabilities. Well-designed enrichers that handle errors gracefully, log progress clearly, and create meaningful graph relationships make the entire platform more powerful and user-friendly.
If you create new enrichers that you think can help the community, it's highly appreciated that you open-source them. Help the community as much as it helps you !

View File

@@ -0,0 +1,512 @@
---
title: "Managing tools"
description: "This guide explains how to create a new tool in the Flowsint ecosystem. Tools are low-level wrappers around external utilities, Docker containers, and APIs that enrichers use to gather intelligence. Understanding the tool architecture will help you extend Flowsint's capabilities with new data sources and reconnaissance utilities."
category: "Developers"
order: 9
author: "Flowsint Team"
tags: ["tutorial", "developers", "creating-a-new-tool"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Understanding tools
Tools in Flowsint serve as abstraction layers between enrichers and external systems. They provide a consistent interface for executing Docker containers, calling APIs, or running python libraries. While enrichers handle high-level orchestration and graph database operations, tools focus exclusively on executing external commands and returning raw results.
Every tool implements a basic interface with methods for naming, categorization, and execution. Tools don't know anything about Pydantic types, Neo4j graphs, or the broader Flowsint architecture. They just wrap external functionality and return data.
Flowsint currently includes tools for subdomain enumeration, port scanning, DNS queries, WHOIS lookups, web crawling, and business intelligence.
## Tool architecture
The tool system has a two-tier inheritance structure. At the base level, you have the abstract `Tool` class that defines the interface every tool must implement. For tools that run in Docker containers, there's an intermediate `DockerTool` class that handles all the container lifecycle management.
### The Tool base class
Every tool inherits from the abstract `Tool` class, which lives at `flowsint-enrichers/src/tools/base.py`. Here's what it looks like:
```python
from abc import ABC, abstractmethod
from typing import Any
class Tool(ABC):
"""Abstract base class for all tools."""
@classmethod
@abstractmethod
def name(cls) -> str:
"""Return the tool name."""
pass
@classmethod
@abstractmethod
def category(cls) -> str:
"""Return the tool category."""
pass
@classmethod
@abstractmethod
def description(cls) -> str:
"""Return a description of what the tool does."""
pass
@classmethod
@abstractmethod
def version(cls) -> str:
"""Return the tool version."""
pass
@abstractmethod
def launch(self, value: str, *args, **kwargs) -> Any:
"""Execute the tool and return results."""
pass
```
Any tool you create must implement these five methods. The first four are class methods that provide metadata about the tool. The `launch` method is where the actual work happens.
### The DockerTool class
Most security and reconnaissance tools run in Docker containers for isolation and portability. The `DockerTool` class at `flowsint-enrichers/src/tools/dockertool.py` provides all the infrastructure for running containerized tools.
When you inherit from `DockerTool`, you get automatic image management, container execution, volume mounting, environment variable handling, and cleanup. You just specify the Docker image name and implement how to construct the command.
Here's a simplified view of what `DockerTool` provides:
```python
class DockerTool(Tool):
"""Base class for tools that run in Docker containers."""
def __init__(self, image: str, default_tag: str = "latest"):
"""Initialize with Docker image information."""
self.image = image
self.default_tag = default_tag
self.docker_client = docker.from_env()
def install(self) -> None:
"""Pull the Docker image if not already present."""
# Pulls image from Docker Hub
pass
def is_installed(self) -> bool:
"""Check if the Docker image exists locally."""
# Checks local images
pass
def launch(self, command: str, volumes: dict = None,
timeout: int = 30, environment: dict = None) -> Any:
"""Run a command in the Docker container."""
# Executes container and returns output
pass
```
The `launch` method in `DockerTool` handles container execution. It sets up the environment, mounts volumes if needed, runs the container, captures output, and cleans up afterward.
## Creating a simple API-based tool
Let's start with the simpler case of creating a tool that calls an external API. We'll create a hypothetical tool for querying a threat intelligence service.
### File structure
Create a new python file in the appropriate category directory under `flowsint-enrichers/src/tools/`. For a security-related tool, you might use `tools/security/`:
```bash
cd flowsint-enrichers/src/tools/security/
touch threat_intel.py
```
If the category directory doesn't exist, create it first and add an `__init__.py` file to make it a python package.
### Basic implementation
Here's a complete example of an API-based tool:
```python
from tools.base import Tool
from typing import Any, Dict, List, Optional
import requests
class ThreatIntelTool(Tool):
"""Query threat intelligence data from an external API."""
api_endpoint = "https://api.threatintel.example.com/v1"
@classmethod
def name(cls) -> str:
"""Return the tool name."""
return "threatintel"
@classmethod
def category(cls) -> str:
"""Return the category this tool belongs to."""
return "Threat Intelligence"
@classmethod
def description(cls) -> str:
"""Return a description of what this tool does."""
return "Queries threat intelligence data for IPs, domains, and hashes"
@classmethod
def version(cls) -> str:
"""Return the tool version."""
return "1.0.0"
def launch(
self,
indicator: str,
indicator_type: str = "ip",
api_key: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Query the threat intelligence API.
Args:
indicator: The indicator to query (IP, domain, hash, etc.)
indicator_type: Type of indicator (ip, domain, hash)
api_key: API key for authentication
Returns:
List of threat intelligence records
"""
if not api_key:
raise ValueError("API key is required")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
params = {
"indicator": indicator,
"type": indicator_type
}
try:
response = requests.get(
f"{self.api_endpoint}/query",
headers=headers,
params=params,
timeout=30
)
response.raise_for_status()
return response.json().get("results", [])
except requests.exceptions.RequestException as e:
print(f"Error querying threat intel API: {e}")
return []
```
This tool follows a straightforward pattern. The class methods provide metadata that enrichers and the registry use. The `launch` method implements the actual API interaction, handling authentication, making the request, and returning structured data.
Notice how the tool returns simple python data structures like lists and dictionaries. Tools don't know about Pydantic types or Flowsint models. That's the enricher's job.
## Creating a docker-based tool
Docker-based tools are more common in Flowsint because most reconnaissance utilities need specific dependencies and isolated environments. Let's walk through creating a tool that wraps a hypothetical Docker-based subdomain scanner.
### Setting up the class
Start by inheriting from `DockerTool` and providing the Docker image information:
```python
from tools.dockertool import DockerTool
from typing import List, Optional, Any
class MySubdomainTool(DockerTool):
"""Wrapper for a Docker-based subdomain enumeration tool."""
image = "org/subdomain-scanner"
default_tag = "latest"
def __init__(self):
"""Initialize the tool with Docker image information."""
super().__init__(self.image, self.default_tag)
```
The `image` and `default_tag` class attributes tell `DockerTool` which Docker image to use. When you instantiate the tool, it will automatically connect to the Docker daemon.
### Implementing the launch method
The `launch` method needs to construct the command that runs inside the container and handle the results:
```python
def launch(
self,
domain: str,
timeout: int = 300,
wordlist: Optional[str] = None
) -> List[str]:
"""
Enumerate subdomains for a given domain.
Args:
domain: Target domain to enumerate
timeout: Maximum execution time in seconds
wordlist: Optional path to custom wordlist file
Returns:
List of discovered subdomain strings
"""
# Ensure the Docker image is available
if not self.is_installed():
self.install()
# Build the command that runs inside the container
command = f"-d {domain}"
if wordlist:
command += f" -w {wordlist}"
# Add JSON output flag for easier parsing
command += " -json"
# Execute the container
try:
result = super().launch(
command=command,
timeout=timeout
)
# Parse the output
subdomains = self._parse_output(result)
return subdomains
except Exception as e:
print(f"Error running subdomain scanner: {e}")
return []
def _parse_output(self, output: str) -> List[str]:
"""Parse the tool output and extract subdomains."""
import json
subdomains = []
for line in output.strip().split('\n'):
if not line:
continue
try:
data = json.loads(line)
if 'subdomain' in data:
subdomains.append(data['subdomain'])
except json.JSONDecodeError:
continue
return list(set(subdomains)) # Remove duplicates
```
This implementation shows several important patterns. First, it checks if the Docker image is installed and pulls it if necessary. Second, it constructs the command string that will run inside the container. Third, it calls the parent class's `launch` method to handle the actual container execution. Finally, it parses the output into a clean python data structure.
### Handling volumes
Some tools need access to files on the host system. You can mount volumes when calling the parent's `launch` method:
```python
def launch(self, domain: str, wordlist_path: str = None) -> List[str]:
"""Run the tool with optional wordlist file."""
command = f"-d {domain}"
volumes = None
if wordlist_path:
# Mount the wordlist file into the container
volumes = {
wordlist_path: {
'bind': '/wordlist.txt',
'mode': 'ro' # read-only
}
}
command += " -w /wordlist.txt"
result = super().launch(
command=command,
volumes=volumes
)
return self._parse_output(result)
```
The volumes dictionary maps host paths to container paths. You can specify the mount mode as 'ro' for read-only or 'rw' for read-write.
### Using environment variables
For tools that need API keys or configuration through environment variables:
```python
def launch(self, domain: str, api_key: Optional[str] = None) -> List[str]:
"""Run the tool with optional API key for enhanced scanning."""
command = f"-d {domain}"
environment = {}
if api_key:
environment['API_KEY'] = api_key
result = super().launch(
command=command,
environment=environment
)
return self._parse_output(result)
```
## Testing your tool
Creating tests for your tool helps ensure it works correctly and makes it easier to catch regressions. Create a test file in `flowsint-enrichers/tests/tools/` that mirrors your tool's location:
```python
# tests/tools/security/test_threat_intel.py
from tools.security.threat_intel import ThreatIntelTool
import pytest
def test_tool_metadata():
"""Test that tool metadata is correctly defined."""
assert ThreatIntelTool.name() == "threatintel"
assert ThreatIntelTool.category() == "Threat Intelligence"
assert "threat" in ThreatIntelTool.description().lower()
def test_tool_launch_requires_api_key():
"""Test that launch method requires an API key."""
tool = ThreatIntelTool()
with pytest.raises(ValueError):
tool.launch("192.0.2.1")
def test_tool_launch_with_api_key(monkeypatch):
"""Test successful API query with mocked response."""
tool = ThreatIntelTool()
# Mock the requests.get call
def mock_get(*args, **kwargs):
class MockResponse:
def raise_for_status(self):
pass
def json(self):
return {"results": [{"indicator": "192.0.2.1", "threat_level": "high"}]}
return MockResponse()
monkeypatch.setattr("requests.get", mock_get)
results = tool.launch("192.0.2.1", api_key="test_key")
assert len(results) == 1
assert results[0]["indicator"] == "192.0.2.1"
```
For docker-based tools, your tests need Docker to be running:
```python
# tests/tools/network/test_my_subdomain_tool.py
from tools.network.my_subdomain_tool import MySubdomainTool
import pytest
@pytest.mark.docker
def test_tool_install():
"""Test that the Docker image can be pulled."""
tool = MySubdomainTool()
tool.install()
assert tool.is_installed()
@pytest.mark.docker
def test_tool_launch():
"""Test running the tool against a domain."""
tool = MySubdomainTool()
results = tool.launch("example.com")
assert isinstance(results, list)
```
The `@pytest.mark.docker` decorator helps you separate tests that require Docker from those that don't.
## Best practices
When creating tools, focus on simplicity and single responsibility. Each tool should wrap exactly one external utility or API. Don't try to combine multiple data sources in a single tool. That's what enrichers are for.
Always handle errors gracefully. Network requests fail, Docker containers crash, and APIs return unexpected data. Your tool should catch these errors, log them appropriately, and return empty results or raise clear exceptions rather than crashing.
Return simple data structures from the `launch` method. Use lists, dictionaries, strings, and numbers. Don't return Pydantic models or other complex objects. Remember that tools are low-level utilities that enrichers build upon.
For Docker tools, always check if the image is installed before running it. The pattern of checking `is_installed()` and calling `install()` if necessary ensures the tool works even on fresh installations.
When parsing tool output, be defensive. External tools can return unexpected formats, partial results, or garbage data. Validate and clean the output before returning it. Use try-except blocks around parsing logic.
Document your tool thoroughly. The docstrings and parameter descriptions help other developers understand how to use your tool. Future enrichers will rely on this documentation.
## Integrating your tool
Unlike types, tools don't need to be explicitly registered in a central registry. Enrichers import and use them directly. When you create an enricher that uses your new tool, you simply import it:
```python
# In an enricher file
from tools.security.threat_intel import ThreatIntelTool
class IpToThreatIntelEnricher(Enricher):
async def scan(self, data: List[Ip]) -> List[ThreatReport]:
tool = ThreatIntelTool()
results = []
for ip in data:
intel = tool.launch(
indicator=ip.address,
indicator_type="ip",
api_key=api_key
)
# Process results...
return results
```
The enricher instantiates your tool, calls its `launch` method with appropriate parameters, and processes the results into Flowsint types.
## Common patterns
Several patterns appear frequently in Flowsint tools. Understanding these will help you write tools that fit naturally into the ecosystem.
### The install-check pattern
Most Docker tools follow this pattern at the start of `launch`:
```python
def launch(self, ...):
if not self.is_installed():
self.install()
# Continue with execution
```
This ensures the docker image is available before trying to run it.
### The command builder pattern
Complex tools often build commands incrementally based on parameters:
```python
def launch(self, target: str, mode: str = "fast", verbose: bool = False):
command = f"-target {target}"
if mode == "thorough":
command += " --thorough"
if verbose:
command += " -v"
result = super().launch(command)
```
### The output parser pattern
Many tools separate execution from parsing:
```python
def launch(self, ...):
raw_output = super().launch(command)
return self._parse_output(raw_output)
def _parse_output(self, output: str) -> List[Dict]:
"""Parse raw tool output into structured data."""
# Parsing logic here
```
This separation makes the code easier to test and maintain.
## Next steps
Once you've created your tool and tested it, you can build enrichers that use it. Enrichers orchestrate one or more tools to gather intelligence, validate the results, convert them to Flowsint types, and create graph database nodes and relationships.
If your tool requires API keys or other secrets, enrichers can access them through the vault system. When you implement an enricher that uses your tool, you can define parameters of type `vaultSecret` that pull credentials from the user's encrypted vault.
Remember that tools are just one layer in the Flowsint architecture. They provide the raw capabilities, but enrichers provide the intelligence and graph-building logic that makes the platform powerful.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
---
title: "Enrichers"
description: "Quick start guide to using Enrichers for your OSINT investigations."
category: "Getting started"
order: 4
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "enrichers"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What is an Enricher?
An enricher is an operation that, starting from an input element A (source entity), produces one or more elements B (target entities) by applying a search or correlation method called a pivot.
Example:
```bash
A = my.domain.com (domain name)
p = “DNS resolution” (pivot)
B = 12.23.34.45 (IP address)
```
That said, a pivot is the method or technical process used to derive B from A. The pivot defines how the transformation obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
Example:
```bash
DNS Resolution → domain → IP
WHOIS Lookup → IP → owner
Reverse Image Search → image → web pages containing that image
```
Flowsint comes with a bunch of prebuilt enrichers, divided into multiple categories. Those enrichers can use standard pivots that your machine can support by default (DNS resolution, WHOIS request, etc.) and some other that depend on external tools.
Those can be :
- Native : DNS resolutions, Whois, etc.
- Docker tools: [subfinder](https://github.com/projectdiscovery/subfinder), [asnmap](https://github.com/projectdiscovery/asnmap), etc.
- Python tools: [sherlock](https://github.com/sherlock-project/sherlock), [maigret](https://github.com/soxoj/maigret), [reconurge/recontrack](https://github.com/reconurge/recontrack), [reconurge/reconcrawl](https://github.com/reconurge/reconcrawl), [reconurge/reconspread](https://github.com/reconurge/reconspread), etc.
- External services (paid or free): [shodan](https://www.shodan.io/), [whoxy](https://www.whoxy.com/), [whoisxmlapi](https://www.whoisxmlapi.com), etc.
### Making your own enrichers
Creating your own enrichers invloves multiple steps, but is not that trivial.
If you plan on writting your own enrichers and think they could help the community, please contribute by making a pull request !
Please refer to [this section](/docs/developers/managing-enrichers) to start building your own enrichers.

View File

@@ -0,0 +1,159 @@
---
title: "Flows"
description: "Quick start guide to using Flows for your OSINT investigations."
category: "Getting started"
order: 5
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "flows"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What are Flows?
Flows are the chaining of multiple enrichers, where the output of one becomes the input of the next, allowing an investigation to be broadened or deepened.
Some enrichers can be chained together, to that they can provide a reproductible and scalable research flow that can be re-applied to other entities of the same type.
### Getting started with Flows
The best way to get started with flows is to go to [http://localhost:5173/dashboard/flows](http://localhost:5173/dashboard/flows) and create a new flow.
From that, you can start building your first flow; a flow consists of **one** input type, and multiple chained enrichers.
Start by drag & dropping your input type to the canva and start using the "**+**" button to add more enrichers to your flow.
Once you're satisfied with the flow, you can start viewing the execution order by pressing the "**compute**" button.
Make sure you give it a descriptive name and save ! (`crtl + s` or presse the save button).
Once it's done, go back to one of your sketches, right click on an item of the same type of the flow, and you should be able to launch it from the "flows" section.
### Flow schema
Every time a flow is launched, a log file is created at `flowsint_core/enricher_logs`.
```json
{
"sketch_id": "6aa808e4-1360-4c4a-b94f-4bed6c914836",
"scan_id": "52ba38a2-3f2c-4c8b-a7a5-42656f8fb845",
"created_at": "2025-10-26T15:57:52.243549",
"updated_at": "2025-10-26T15:57:52.424052",
"status": "completed",
"enricher_branches": [
{
"id": "branch-0",
"name": "Main Flow",
"steps": [
{
"nodeId": "Domain-1761469976169",
"params": {},
"type": "type",
"inputs": {},
"outputs": {
"domain": [
"example.com"
]
},
"status": "pending",
"branchId": "branch-0",
"depth": 0
},
{
"nodeId": "domain_to_ip-1761469978018",
"params": {},
"type": "enricher",
"inputs": {
"Domain": null
},
"outputs": {
"address": "example.com",
"latitude": "example.com",
"longitude": "example.com",
"country": "example.com",
"city": "example.com",
"isp": "example.com"
},
"status": "pending",
"branchId": "branch-0",
"depth": 1
}
]
}
],
"execution_log": [
{
"step_id": "branch-0_domain_to_ip-1761469978018",
"branch_id": "branch-0",
"branch_name": "Main Flow",
"node_id": "domain_to_ip-1761469978018",
"enricher_name": "domain_to_ip",
"inputs": [
"example.com"
],
"outputs": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
],
"status": "completed",
"error": null,
"timestamp": "2025-10-26T15:57:52.274147",
"execution_time_ms": 144,
"cache_hit": false
}
],
"summary": {
"total_steps": 1,
"completed_steps": 1,
"failed_steps": 0,
"total_execution_time_ms": 144
},
"final_results": {
"initial_values": [
"example.com"
],
"branches": [
{
"id": "branch-0",
"name": "Main Flow",
"steps": [
{
"nodeId": "domain_to_ip-1761469978018",
"enricher": "domain_to_ip",
"status": "completed",
"outputs": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
]
}
]
}
],
"results": {
"domain_to_ip-1761469978018": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
]
},
"reference_mapping": {}
}
}
```

View File

@@ -0,0 +1,42 @@
---
title: "Installation"
description: "Quick start guide to using Flowsint for your OSINT investigations."
category: "Getting started"
order: 3
author: "Flowsint Team"
tags: ["tutorial", "quickstart", "installation"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### Prerequisites
Before installing Flowsint, ensure you have the following installed on your system:
- **Docker** and **Docker Compose**
- **Make** (for build automation)
- **Git**
### Installation
Clone the repo and run the start command.
```bash
git clone https://github.com/reconurge/flowsint.git
cd flowsint
make prod
```
Some enrichers require API keys. Check out [this section](/docs/getting-started/enrichers#api-keys) to learn more.
The application should automatically open at http://localhost:5173.
### Create your first investigation
Start by logging in or registering from the home page. From your dashboard, create a new investigation by clicking "New investigation." Add your first entity—such as a domain name—to the canvas. Right-click the entity to open the context menu and run an enricher. As results appear, explore the newly discovered entities and relationships directly in the graph.
### Running your first enricher
You could start by discovering subdomains for a domain for example.
Add a domain entity like `example.com` to your investigation, then right-click the domain node and select the "domain_to_subdomains" enricher. Wait for the enricher to complete; newly discovered subdomains will be added to your graph for you to review.

View File

@@ -0,0 +1,58 @@
---
title: "Vault"
description: "Quick start guide to using the Vault to secure your services API keys and secrets."
category: "Getting started"
order: 6
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "vault"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## What is the Vault
A good amount of the tools you'll be using in Flowsint require third party API keys.
The **Vault** (*"Coffre fort"* in french) is the place to centralize and securely store those API keys.
Weither you have a local instance of Flowsint or one fully deployed on a distributed system, you need to have your keys securely stored.
## Adding a key
In the Flowsint enricher ecosystem, the API keys follow a specific format, being in uppercase letters, and with a declarative name that follows `<service>_API_KEY`.
## Current limitations
For now, we cannot match a particular key from the Vault to an enricher **directly from the UI**. The enricher declares the API key variable name it requires, like the following in the core of the Enricher:
```python
@classmethod
def get_params_schema(cls) -> List[Dict[str, Any]]:
"""Declare required parameters for this enricher"""
return [
{
"name": "PDCP_API_KEY",
"type": "vaultSecret",
"description": "The ProjectDiscovery Cloud Platform API key for asnmap.",
"required": True,
},
]
```
This is a known limitation and we are working on improving this.
In the meanwhile, here is a list of the needed keys to run Flowsint at it's full potential:
```bash
# for enrichers
WHOXY_API_KEY # Whoxy domain search engine [WHOXY]
PDCP_API_KEY # ProjectDiscovery Cloud Platform [ASNMAP], [NAABU] etc
HIBP_API_KEY # HaveIBeenPwned API key [HIBP]
ETHERSCAN_API_KEY # Etherscan crypto API key [ETHERSCAN]
# for Flo, AI assistant
MISTRAL_API_KEY
# but other providers will be supported soon (ChatGPT, etc.)
```
There are also some other tools that could need a bunch of other API keys like [Subfinder](https://github.com/projectdiscovery/subfinder). Configuring them is not possible for now, but will be soon.
Stay tuned for updates as those mechanisms may vary in the future, as the goal is to keep the user experience as smooth as possible.

59
docs/overview.mdx Normal file
View File

@@ -0,0 +1,59 @@
---
title: "Welcome to Flowsint"
description: "Complete documentation for Flowsint - a modular OSINT investigation platform."
category: "Overview"
order: 1
author: "Flowsint Team"
tags: ["documentation", "overview", "getting-started"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What is Flowsint?
Flowsint is a modular investigation and reconnaissance platform focused on OSINT (Open Source Intelligence). It provides:
- Graph-based visualization of entity relationships
- 30+ automated enrichers for intelligence gathering
- Modular architecture with clean separation of concerns
- Privacy-first design with local data storage
- Extensible platform for custom enrichers
- Automated search flows (more on that [here](/docs/getting-started/flows))
<Alert variant="warning">
<AlertTitle>Disclaimer !</AlertTitle>
<AlertDescription>
The author(s) and contributor(s) of this tool assume no responsibility or liability for any damages, losses, or consequences that may result from the use or misuse of this software.
By using this tool, you acknowledge and agree that:
- You are solely responsible for your use of this software
- You will use this tool in compliance with all applicable laws and regulations
- You will obtain proper authorization before conducting any security testing or reconnaissance activities
- You understand the potential risks and legal implications of using security tools
</AlertDescription>
</Alert>
### Why Flowsint?
If you've already practiced some OSINT, you know that analysts often rely on a multitude of research tools: scripts, third-party services, specialized applications. But these tools often work **in silos** and quickly become obsolete if they're not maintained: a service disappears, an API changes, an access point closes. The analyst juggles with unstable tools and sometimes has to **manually adapt their data** to continue their investigation.
OSINT tools, for most, are **consumables**: they evolve, appear, and disappear. Research methods change, security mechanisms evolve. However, the fundamental need always remains the same: **to see, exploit, and analyze data in a clear and understandable way**. Analysts need to be able to list, centralize, and visualize connections to have a clear understanding of their investigation.
This is exactly what Flowsint was built for: a solid and durable foundation on which your investigations rest. Tools become simple extensions that plug in and unplug easily. A new tool comes out, and with a few manipulations it can be integrated into your investigation workflow.
**Flowsint is the stable infrastructure that allows you to stay agile in the face of constant evolution of methods and sources.**
Flowsint is a local tool, running on your machine only. This ensures a great level of confidentiality, but comes with responsabilities.
<Alert variant="warning">
<AlertTitle>At your own risk</AlertTitle>
<AlertDescription>
All enrichers run locally on your machine. This means you can get banned from some services or get flagged from infrastructures if you start making thousands of requests to the same service.
You need to always know what you are doing, and understand that gathering can go very fast in scale.
For example, you can very easily happen to be making DNS resolution requests for 10 000 IPs.
Know you infrastructure and your limits. Know how the tools used in the enrichers actually work. You can have a list of the available enrichers and tools [here](/docs/sources/available-enrichers).
</AlertDescription>
</Alert>
If all those points are clear for you, let's start investigating !

View File

@@ -0,0 +1,163 @@
---
title: "Enrichers catalog"
description: "Quick start guide to using Enrichers for your OSINT investigations."
category: "Sources"
order: 11
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "enrichers"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### ASN
**asn_to_cidrs**: Given an ASN, enumerate its announced CIDR ranges.
Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
### CIDR
**cidr_to_ips**: Expand a CIDR to IPs by PTR enumeration heuristics.
Tools/Pivots: [dnsx](https://github.com/projectdiscovery/dnsx) (CLI)
### Crypto
**cryptowallet_to_transactions**: Fetch ETH wallet transactions and map wallet-to-wallet relationships.
Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
**cryptowallet_to_nfts**: Fetch ERC-721/1155 NFT transfers for a wallet.
Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
### Domain
**domain_to_ip**: Resolve domains to IPv4 addresses.
Tools/Pivots: DNS resolution (socket)
**domain_to_subdomains**: Discover subdomains for a domain.
Tools/APIs: [subfinder](https://github.com/projectdiscovery/subfinder) (CLI), fallback to [crt.sh JSON API](https://crt.sh/?output=json)
**domain_to_whois**: Retrieve WHOIS registration data for a domain.
Tools/APIs: [python-whois](https://pypi.org/project/python-whois/)
**domain_to_asn**: Map a domain to its ASN by resolving and querying ASN data.
Tools/Pivots: system DNS, [asnmap](https://github.com/projectdiscovery/asnmap) (CLI)
**domain_to_root_domain**: Convert a subdomain to its registrable root.
Tools/Pivots: internal domain utils
**domain_to_history**: Retrieve historical WHOIS records and extract related entities (individuals, organizations, emails, phones, locations).
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**domain_to_website**: Convert a domain to a reachable website URL (HTTP/HTTPS), following redirects.
Tools/Pivots: HTTP HEAD requests
**domain_to_tls**: Retrieve TLS/SSL certificate information for a domain.
Tools/Pivots: [httpx](https://github.com/projectdiscovery/httpx) (CLI)
**domain_to_whois_history**: Retrieve historical WHOIS records for a domain and extract related entities (individuals, organizations, emails, locations).
Tools/APIs: [WhoisXML API](https://whois.whoisxmlapi.com/)
**domain_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a domain.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
### Email
**email_to_breaches**: Check whether an email appears in known breaches.
Tools/APIs: [Have I Been Pwned API](https://haveibeenpwned.com/API/v3)
**email_to_gravatar**: Check Gravatar existence and profile for an email (via MD5 hash).
Tools/APIs: [Gravatar endpoints](https://en.gravatar.com/site/implement/images/)
**email_to_domain**: Extract the domain part of an email address.
Tools/Pivots: internal email parser
**email_to_domains**: Find domains registered by a given email address; extract related contacts and entities.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**email_to_username**: Extract the local-part of an email as a Username entity.
Tools/Pivots: internal email parser
**email_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an email.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
**email_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with an email.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Individual
**individual_to_domains**: Find domains registered by a specific person; extract related contacts and attributes.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**individual_to_organization**: Find organizations related to a person in French registries.
Tools/APIs: SIRENE (via internal SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
### IP
**ip_to_domain**: Reverse-resolve IPs to domains via PTR and Certificate Transparency pivots.
Tools/APIs: DNS PTR (socket), [crt.sh JSON API](https://crt.sh/?output=json)
**ip_to_infos**: Enrich IPs with geolocation and ISP data.
Tools/APIs: [ip-api.com](https://ip-api.com/)
**ip_to_asn**: Map IPs to their ASN.
Tools/Pivots: AsnmapTool ([asnmap](https://github.com/projectdiscovery/asnmap))
**ip_to_ports**: Scan an IP for open ports and services.
Tools/Pivots: [naabu](https://github.com/projectdiscovery/naabu) (CLI)
**ip_to_fraudscore**: Compute a fraud risk score for an IP address.
Tools/APIs: [Scamalytics API](https://scamalytics.com/ip-api)
**ip_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an IP.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
### Organization
**org_to_domains**: Find domains registered by an organization; extract contacts and related entities.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**org_to_infos**: Enrich organizations with French registry data and leaders.
Tools/APIs: SIRENE (SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
**org_to_asn**: Find ASNs associated with an organization name.
Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
### Phone
**phone_to_infos**: Probe phone footprint across services (demo modules) and normalize number.
Tools/APIs: ignorant modules (Amazon, Snapchat, Instagram), [httpx](https://github.com/projectdiscovery/httpx)
**phone_to_carrier**: Look up carrier, country, and validity metadata for a phone number.
Tools/APIs: [Veriphone API](https://veriphone.io/)
**phone_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a phone number.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Social
**username_to_socials_sherlock**: Enumerate social accounts for a username using Sherlock.
Tools/Pivots: [sherlock](https://github.com/sherlock-project/sherlock) (CLI)
**username_to_socials_maigret**: Enumerate social accounts for a username using Maigret and parse rich metadata.
Tools/Pivots: [maigret](https://github.com/soxoj/maigret) (CLI)
**username_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a username.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
**username_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a username.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Website
**website_to_crawler**: Crawl a website to extract emails and phone numbers.
Tools/APIs: ReconCrawlTool (`reconcrawl`)
**website_to_domain**: Extract the domain name from a website URL.
Tools/Pivots: internal URL parser
**website_to_subdomains**: Find subdomains of a website's domain via external scan.
Tools/APIs: [c99.nl API](https://api.c99.nl/)
**website_to_links**: Crawl a website and collect internal/external links and domains.
Tools/APIs: reconspread Crawler
**website_to_text**: Fetch and extract visible text from a webpage.
Tools/APIs: HTTP GET, [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)
**website_to_webtrackers**: Extract analytics/ads tracking codes from a website.
Tools/APIs: recontrack TrackingCodeExtractor
---
Notes
- Some enrichers optionally depend on docker binaries: `subfinder`, `asnmap`, `dnsx`, `naabu`, `httpx`, and `jq` which are installed in the docker container.
- API-keyed enrichers read keys from params or environment (e.g., `HIBP_API_KEY`, `ETHERSCAN_API_KEY`, `WHOXY_API_KEY`, `WHOISXML_API_KEY`, `DEHASHED_API_KEY`, `SCAMALYTICS_API_KEY`, `VERIPHONE_API_KEY`, `C99_API_KEY`).
- Internal/test enrichers (`domain_to_dummy`, `ip_to_dummy_domains`, `n8n_connector`) are not listed here — they exist in the codebase but are not part of the public catalog.

73
docs/syllabus.mdx Normal file
View File

@@ -0,0 +1,73 @@
---
title: "Syllabus"
description: "Syllabus, just to make sure we speak the same language. Those definitions apply in the context of Flowsint platform."
category: "Overview"
order: 2
author: "Flowsint Team"
tags: ["documentation", "overview", "syllabus"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### OSINT
Open Source Intelligence consists of collecting, analyzing, and exploiting **freely** and **openly** available information from search engines, images, social networks, public archives, etc.
### Investigation
A structured process aimed at collecting, correlating, and analyzing information from different sources and enrichers, in order to answer a question or solve a problem. An investigation can be **exploratory** (discovering unknown elements) or **targeted** (validating a hypothesis). An investigation can contain multiple **sketches** (each representing a different view or stage of the analysis) and one or more **analyses**.
### Sketch
Visual result produced by executing one or more enrichers on one or more entities. A sketch represents the current state of the graph derived from collected data at a given moment in the investigation. Multiple sketches can exist for the same investigation to capture different perspectives or stages.
### Analysis
Set of processing, interpretations, and verifications performed on data collected during the investigation. Analyses aim to identify trends, confirm or refute hypotheses, and produce actionable conclusions. They can be **quantitative** (measurements, statistics) or **qualitative** (contextual assessments, behavioral patterns).
### Enricher
An **enricher** is an operation that, from an input element **A** (*source entity*), allows obtaining one or more elements **B** (*target entities*) by applying a search or correlation method called a **pivot**.
> Example:
>
>
> A = `my.domain.com` (*domain name*)
>
> p = "DNS resolution" (*pivot*)
>
> B = `12.23.34.45` (*IP address*).
>
### Pivot
A **pivot** is the method or technical process used to derive **B** from **A**. The pivot defines **how** the enricher obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
> Examples of pivots:
>
> DNS Resolution → domain → IP
> WHOIS Lookup → IP → owner
> Reverse Image Search → image → web pages containing this image
### Tool
A tool generally refers to a script, program, or service providing a **pivot**, i.e., a means to retrieve or enricher information from an input element.
### Entity
An identifiable object or element manipulated by enrichers (e.g., IP address, domain, email address, user identifier, file hash, etc.). An entity is always associated with a **Sketch**. In the graph, entities are represented as **nodes** (see [Graph format](/docs/developers/graph-format) for technical details).
### Relationship
Defines a link between two entities. This link is generally named (in uppercase) and can be unidirectional or bidirectional.
> Examples of relationships:
>
>
> A = `my.domain.com` → `RESOLVES_TO` → `12.23.34.45`
>
A relationship is always associated between a **source** node (*from*) and a **target** node (*to*). In the graph, relationships are represented as **edges** (see [Graph format](/docs/developers/graph-format) for technical details).
### Flow
The chaining of multiple enrichers, where the output of one becomes the input of the next, allowing to expand or deepen an investigation.

View File

@@ -2,17 +2,12 @@ FROM python:3.12-slim AS builder
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1 \
POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH"
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# build deps
# build deps + uv
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
@@ -20,23 +15,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
pkg-config \
libcairo2-dev \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh
# poetry
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
COPY flowsint-core/pyproject.toml flowsint-core/poetry.lock* ./flowsint-core/
COPY flowsint-types/pyproject.toml flowsint-types/poetry.lock* ./flowsint-types/
COPY flowsint-enrichers/pyproject.toml flowsint-enrichers/poetry.lock* ./flowsint-enrichers/
COPY flowsint-api/pyproject.toml flowsint-api/poetry.lock* ./flowsint-api/
# Copy workspace config
COPY pyproject.toml uv.lock ./
COPY flowsint-core ./flowsint-core
# Copy all workspace members
COPY flowsint-types ./flowsint-types
COPY flowsint-core ./flowsint-core
COPY flowsint-enrichers ./flowsint-enrichers
COPY flowsint-api ./flowsint-api
WORKDIR /app/flowsint-api
RUN poetry install --no-root
RUN uv sync --frozen --no-dev
# DEV
FROM python:3.12-slim AS dev
@@ -44,7 +37,7 @@ FROM python:3.12-slim AS dev
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_ENV=development \
PATH="/app/flowsint-api/.venv/bin:$PATH"
PATH="/app/.venv/bin:$PATH"
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -56,7 +49,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# Copy virtual environment from builder
COPY --from=builder /app/flowsint-api/.venv ./flowsint-api/.venv
COPY --from=builder /app/.venv ./.venv
# Copy application code
COPY flowsint-core ./flowsint-core
@@ -86,7 +79,7 @@ LABEL org.opencontainers.image.licenses="Apache-2.0"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_ENV=production \
PATH="/app/flowsint-api/.venv/bin:$PATH"
PATH="/app/.venv/bin:$PATH"
# Install runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -102,8 +95,8 @@ RUN groupadd -g 1001 flowsint && \
WORKDIR /app
# Copy virtual environment from builder (production deps only would require separate install)
COPY --from=builder --chown=flowsint:flowsint /app/flowsint-api/.venv ./flowsint-api/.venv
# Copy virtual environment from builder
COPY --from=builder --chown=flowsint:flowsint /app/.venv ./.venv
# Copy application code
COPY --chown=flowsint:flowsint flowsint-core ./flowsint-core
@@ -117,7 +110,7 @@ WORKDIR /app/flowsint-api
RUN chmod +x entrypoint.sh
# Switch to non-root user
# USER flowsint
USER flowsint
EXPOSE 5001
@@ -127,4 +120,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
ENTRYPOINT ["./entrypoint.sh"]
# Production command (no reload)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]

View File

@@ -5,14 +5,14 @@
1. Install Python dependencies:
2.
```bash
poetry install
uv sync
```
## Run
```bash
# dev
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
# prod
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001
```

View File

@@ -0,0 +1,59 @@
"""backfill owner roles for existing investigations
Revision ID: a1f2b3c4d5e6
Revises: bac5764d4496
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from uuid import uuid4
from alembic import op
import sqlalchemy as sa
import json
# revision identifiers, used by Alembic.
revision: str = "a1f2b3c4d5e6"
down_revision: Union[str, None] = "bac5764d4496"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Insert OWNER role entry for every investigation that lacks one."""
conn = op.get_bind()
# Find investigations with no entry in investigation_user_roles
rows = conn.execute(
sa.text(
"""
SELECT i.id, i.owner_id
FROM investigations i
LEFT JOIN investigation_user_roles r
ON r.investigation_id = i.id AND r.user_id = i.owner_id
WHERE r.id IS NULL
AND i.owner_id IS NOT NULL
"""
)
).fetchall()
for inv_id, owner_id in rows:
conn.execute(
sa.text(
"""
INSERT INTO investigation_user_roles (id, user_id, investigation_id, roles)
VALUES (:id, :user_id, :investigation_id, :roles)
"""
),
{
"id": str(uuid4()),
"user_id": str(owner_id),
"investigation_id": str(inv_id),
"roles": json.dumps(["owner"]),
},
)
def downgrade() -> None:
"""No-op: we don't remove the backfilled rows."""
pass

View File

@@ -2,19 +2,11 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from flowsint_core.core.auth import ALGORITHM
from flowsint_core.core.auth import ALGORITHM, AUTH_SECRET
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Profile
from typing import Optional
import os
from dotenv import load_dotenv
# Remplace avec ton URL de BDD
AUTH_SECRET = os.getenv("AUTH_SECRET")
load_dotenv()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
from typing import List
from flowsint_core.core.services import (
create_auth_service,
@@ -9,8 +10,10 @@ from flowsint_core.core.services import (
ConflictError,
DatabaseError,
)
from app.api.schemas.profile import ProfileCreate
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from app.api.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate
from app.api.deps import get_current_user
router = APIRouter()
@@ -39,3 +42,40 @@ def register(user: ProfileCreate, db: Session = Depends(get_db)):
except (DatabaseError, SQLAlchemyError) as e:
print(f"[ERROR] DB error during registration: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/me", response_model=ProfileRead)
def get_me(current_user: Profile = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=ProfileRead)
def update_me(
payload: ProfileUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(current_user, key, value)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/users/search", response_model=List[ProfileRead])
def search_users(
q: str = Query(..., min_length=1),
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Search users by email prefix for the share dialog autocomplete."""
results = (
db.query(Profile)
.filter(
Profile.email.ilike(f"{q}%"),
Profile.id != current_user.id,
)
.limit(5)
.all()
)
return results

View File

@@ -10,6 +10,7 @@ from flowsint_core.core.services import (
create_investigation_service,
NotFoundError,
PermissionDeniedError,
ConflictError,
DatabaseError,
)
from app.api.deps import get_current_user
@@ -17,12 +18,24 @@ from app.api.schemas.investigation import (
InvestigationRead,
InvestigationCreate,
InvestigationUpdate,
CollaboratorAdd,
CollaboratorUpdate,
CollaboratorRead,
)
from app.api.schemas.sketch import SketchRead
router = APIRouter()
def _inject_current_user_role(service, investigation, user_id) -> InvestigationRead:
"""Build InvestigationRead with the current user's role attached."""
result = InvestigationRead.model_validate(investigation)
role_entry = service.get_user_role_for_investigation(user_id, investigation.id)
if role_entry and role_entry.roles:
result.current_user_role = role_entry.roles[0].value
return result
@router.get("", response_model=List[InvestigationRead])
def get_investigations(
db: Session = Depends(get_db),
@@ -30,10 +43,14 @@ def get_investigations(
):
"""Get all investigations accessible to the user based on their roles."""
service = create_investigation_service(db)
allowed_roles = [Role.OWNER, Role.EDITOR, Role.VIEWER]
return service.get_accessible_investigations(
allowed_roles = [Role.OWNER, Role.ADMIN, Role.EDITOR, Role.VIEWER]
investigations = service.get_accessible_investigations(
user_id=current_user.id, allowed_roles=allowed_roles
)
return [
_inject_current_user_role(service, inv, current_user.id)
for inv in investigations
]
@router.post(
@@ -45,11 +62,12 @@ def create_investigation(
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
return service.create(
investigation = service.create(
name=payload.name,
description=payload.description,
owner_id=current_user.id,
)
return _inject_current_user_role(service, investigation, current_user.id)
@router.get("/{investigation_id}", response_model=InvestigationRead)
@@ -60,7 +78,8 @@ def get_investigation_by_id(
):
service = create_investigation_service(db)
try:
return service.get_by_id(investigation_id, current_user.id)
investigation = service.get_by_id(investigation_id, current_user.id)
return _inject_current_user_role(service, investigation, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Investigation not found")
except PermissionDeniedError:
@@ -93,13 +112,14 @@ def update_investigation(
):
service = create_investigation_service(db)
try:
return service.update(
investigation = service.update(
investigation_id=investigation_id,
user_id=current_user.id,
name=payload.name,
description=payload.description,
status=payload.status,
)
return _inject_current_user_role(service, investigation, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Investigation not found")
except PermissionDeniedError:
@@ -122,3 +142,126 @@ def delete_investigation(
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
# ── Collaborator endpoints ───────────────────────────────────────────
@router.get(
"/{investigation_id}/collaborators", response_model=List[CollaboratorRead]
)
def get_collaborators(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
entries = service.get_collaborators(investigation_id, current_user.id)
return [
CollaboratorRead(
id=e.id,
user_id=e.user_id,
roles=[r.value for r in e.roles],
user=e.user,
)
for e in entries
]
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.post(
"/{investigation_id}/collaborators",
response_model=CollaboratorRead,
status_code=status.HTTP_201_CREATED,
)
def add_collaborator(
investigation_id: UUID,
payload: CollaboratorAdd,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
role = Role(payload.role.lower())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid role")
try:
entry = service.add_collaborator(
investigation_id=investigation_id,
user_id=current_user.id,
target_email=payload.email,
role=role,
)
return CollaboratorRead(
id=entry.id,
user_id=entry.user_id,
roles=[r.value for r in entry.roles],
user=entry.user,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e.message))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ConflictError:
raise HTTPException(status_code=409, detail="User is already a collaborator")
@router.put(
"/{investigation_id}/collaborators/{user_id}",
response_model=CollaboratorRead,
)
def update_collaborator_role(
investigation_id: UUID,
user_id: UUID,
payload: CollaboratorUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
role = Role(payload.role.lower())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid role")
try:
entry = service.update_collaborator_role(
investigation_id=investigation_id,
user_id=current_user.id,
target_user_id=user_id,
role=role,
)
return CollaboratorRead(
id=entry.id,
user_id=entry.user_id,
roles=[r.value for r in entry.roles],
user=entry.user,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Collaborator not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.delete(
"/{investigation_id}/collaborators/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_collaborator(
investigation_id: UUID,
user_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
service.remove_collaborator(
investigation_id=investigation_id,
user_id=current_user.id,
target_user_id=user_id,
)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Collaborator not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")

View File

@@ -25,20 +25,7 @@ class InvestigationRead(ORMBase):
owner: Optional[ProfileRead] = None
sketches: list[SketchRead] = []
analyses: list[AnalysisRead] = []
class InvestigationProfileCreate(BaseModel):
investigation_id: UUID4
profile_id: UUID4
role: Optional[str] = "member"
class InvestigationProfileRead(ORMBase):
id: int
created_at: datetime
investigation_id: UUID4
profile_id: UUID4
role: str
current_user_role: Optional[str] = None
class InvestigationUpdate(BaseModel):
@@ -46,3 +33,22 @@ class InvestigationUpdate(BaseModel):
description: Optional[str] = None
last_updated_at: datetime
status: str
# ── Collaborator schemas ─────────────────────────────────────────────
class CollaboratorAdd(BaseModel):
email: str
role: str # "admin", "editor", "viewer"
class CollaboratorUpdate(BaseModel):
role: str # "admin", "editor", "viewer"
class CollaboratorRead(ORMBase):
id: UUID4
user_id: UUID4
roles: list[str] = []
user: Optional[ProfileRead] = None

View File

@@ -1,5 +1,5 @@
from .base import ORMBase
from pydantic import UUID4, BaseModel, EmailStr
from pydantic import UUID4, BaseModel, ConfigDict, EmailStr
from typing import Optional
@@ -13,3 +13,12 @@ class ProfileRead(ORMBase):
first_name: Optional[str]
last_name: Optional[str]
avatar_url: Optional[str]
email: Optional[str] = None
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
first_name: Optional[str] = None
last_name: Optional[str] = None
avatar_url: Optional[str] = None

View File

@@ -8,7 +8,7 @@ class ScanCreate(BaseModel):
values: Optional[List[str]] = None
sketch_id: Optional[UUID4] = None
status: Optional[str] = None
results: Optional[Any] = None
details: Optional[Any] = None
class ScanRead(ORMBase):

View File

@@ -1,3 +1,5 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -16,8 +18,12 @@ from app.api.routes import types
from app.api.routes import custom_types
from app.api.routes import enricher_templates
# Comma-separated list of allowed origins, e.g. "https://app.example.com,https://staging.example.com"
# Falls back to localhost dev origin when unset. Never use "*" with allow_credentials=True.
origins = [
"*",
o.strip()
for o in os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")
if o.strip()
]

6581
flowsint-api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,57 @@
[tool.poetry]
[project]
name = "flowsint-api"
version = "1.0.0"
license = "Apache-2.0"
version = "1.2.8"
description = "API server for flowsint"
authors = ["dextmorgn <contact@flowsint.io>"]
packages = [{ include = "app" }]
license = "Apache-2.0"
authors = [{ name = "dextmorgn", email = "contact@flowsint.io" }]
requires-python = ">=3.12,<4.0"
dependencies = [
"flowsint-core",
"flowsint-types",
"flowsint-enrichers",
"fastapi[standard]>=0.115.0,<0.116.0",
"uvicorn>=0.32.0,<0.33.0",
"redis>=5.0,<6.0",
"celery>=5.3,<6.0",
"python-dotenv>=1.0,<2.0",
"python-jose[cryptography]>=3.4,<4.0",
"requests>=2.31,<3.0",
"pydantic>=2.0,<3.0",
"neo4j>=5.0,<6.0",
"sqlalchemy>=2.0,<3.0",
"psycopg2-binary>=2.9,<3.0",
"asyncpg>=0.30,<0.31",
"alembic==1.13.0",
"passlib[bcrypt]>=1.7,<2.0",
"bcrypt>=4.0.0,<5.0.0",
"sse-starlette>=1.8,<2.0",
"networkx>=2.6.3,<3.0.0",
"email-validator>=2.2.0,<3.0.0",
"mistralai>=1.9.3,<2.0.0",
"python-multipart>=0.0.20,<0.0.21",
"openpyxl>=3.1.2,<4.0.0",
"jsonschema>=4.25.1,<5.0.0",
]
[tool.poetry.dependencies]
python = ">=3.12,<4.0"
flowsint-core = { path = "../flowsint-core", develop = true }
flowsint-types = { path = "../flowsint-types", develop = true }
flowsint-enrichers = { path = "../flowsint-enrichers", develop = true }
fastapi = {version = "^0.115.0", extras = ["standard"]}
uvicorn = "^0.32.0"
redis = "^5.0"
celery = "^5.3"
python-dotenv = "^1.0"
python-jose = {extras = ["cryptography"], version = "^3.4"}
requests = "^2.31"
pydantic = "^2.0"
neo4j = "^5.0"
sqlalchemy = "^2.0"
psycopg2-binary = "^2.9"
asyncpg = "^0.30"
alembic = "1.13.0"
passlib = {extras = ["bcrypt"], version = "^1.7"}
bcrypt = ">=4.0.0,<5.0.0"
sse-starlette = "^1.8"
networkx = "^2.6.3"
email-validator = "^2.2.0"
mistralai = "^1.9.3"
python-multipart = "^0.0.20"
openpyxl = "^3.1.2"
jsonschema = "^4.25.1"
[tool.poetry.group.dev.dependencies]
black = "^23.0"
isort = "^5.12"
flake8 = "^6.0"
mypy = "^1.5"
[dependency-groups]
dev = [
"black>=25.0,<26.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.uv.sources]
flowsint-core = { workspace = true }
flowsint-types = { workspace = true }
flowsint-enrichers = { workspace = true }
[tool.black]
line-length = 88

View File

@@ -49,6 +49,17 @@ export const authService = {
},
getCurrentUser: async () => {
return fetchWithAuth('/api/users/me')
return fetchWithAuth('/api/auth/me')
},
updateProfile: async (data: { first_name?: string; last_name?: string; avatar_url?: string }) => {
return fetchWithAuth('/api/auth/me', {
method: 'PUT',
body: JSON.stringify(data)
})
},
searchUsers: async (query: string) => {
return fetchWithAuth(`/api/auth/users/search?q=${encodeURIComponent(query)}`)
}
}

View File

@@ -21,5 +21,29 @@ export const investigationService = {
return fetchWithAuth(`/api/investigations/${investigationId}`, {
method: 'DELETE'
})
},
// Collaborator management
getCollaborators: async (investigationId: string): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators`, {
method: 'GET'
})
},
addCollaborator: async (investigationId: string, body: { email: string; role: string }): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators`, {
method: 'POST',
body: JSON.stringify(body)
})
},
updateCollaboratorRole: async (investigationId: string, userId: string, body: { role: string }): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators/${userId}`, {
method: 'PUT',
body: JSON.stringify(body)
})
},
removeCollaborator: async (investigationId: string, userId: string): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators/${userId}`, {
method: 'DELETE'
})
}
}

View File

@@ -13,6 +13,7 @@ export const queryKeys = {
sketches: (investigationId: string) => [investigationId, 'sketches'],
analyses: (investigationId: string) => [investigationId, 'analyses'],
flows: (investigationId: string) => [investigationId, 'flows'],
collaborators: (investigationId: string) => ['investigations', investigationId, 'collaborators'],
dashboard: ['investigations', 'dashboard'],
selector: (investigationId: string) => ['dashboard', 'selector', investigationId]
},

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { queryKeys } from '@/api/query-keys'
import ErrorState from '../shared/error-state'
import { usePermissions } from '@/hooks/use-can'
const AnalysisItem = ({ analysis, active }: { analysis: Analysis; active: boolean }) => {
return (
@@ -46,6 +47,7 @@ const AnalysisItem = ({ analysis, active }: { analysis: Analysis; active: boolea
}
const AnalysisList = () => {
const { canEdit } = usePermissions()
const { investigationId, id, type } = useParams({ strict: false })
const queryClient = useQueryClient()
const navigate = useNavigate()
@@ -112,16 +114,18 @@ const AnalysisList = () => {
return (
<div className="w-full h-full bg-card flex flex-col overflow-hidden">
<div className="p-2 flex items-center h-11 gap-2 border-b shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
title="Create New analysis"
>
<PlusIcon className="h-4 w-4" />
</Button>
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
title="Create New analysis"
>
<PlusIcon className="h-4 w-4" />
</Button>
)}
<Input
type="search"
className="!border border-border h-7"

View File

@@ -19,6 +19,7 @@ import { useConfirm } from '../use-confirm-dialog'
import { Editor } from '@tiptap/core'
import { Link, useParams } from '@tanstack/react-router'
import { useLayoutStore } from '@/stores/layout-store'
import { usePermissions } from '@/hooks/use-can'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
DropdownMenu,
@@ -78,6 +79,7 @@ export const AnalysisEditor = ({
showToolbar = false
}: AnalysisEditorProps) => {
const { confirm } = useConfirm()
const { canEdit } = usePermissions()
const toggleAnalysis = useLayoutStore((s) => s.toggleAnalysis)
const { investigationId: routeInvestigationId, type } = useParams({ strict: false }) as {
investigationId: string
@@ -318,7 +320,7 @@ export const AnalysisEditor = ({
{/* Title section */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{isEditingTitle ? (
{canEdit && isEditingTitle ? (
<input
className="text-md font-medium bg-transparent outline-none border-none p-0 m-0 w-full"
value={titleValue}
@@ -340,8 +342,8 @@ export const AnalysisEditor = ({
/>
) : (
<span
className={`text-md font-medium truncate min-w-0 flex-1 ${'cursor-pointer hover:text-primary'}`}
onClick={() => setIsEditingTitle(true)}
className={`text-md font-medium truncate min-w-0 flex-1 ${canEdit ? 'cursor-pointer hover:text-primary' : ''}`}
onClick={canEdit ? () => setIsEditingTitle(true) : undefined}
>
{titleValue || 'Untitled Analysis'}
</span>
@@ -350,7 +352,7 @@ export const AnalysisEditor = ({
</div>
{/* Action buttons */}
{showActions && (
{showActions && canEdit && (
<div className="flex items-center gap-1">
<SaveStatusBadge status={saveStatus} />
<DropdownMenu>
@@ -426,29 +428,32 @@ export const AnalysisEditor = ({
}
return content || ''
})()}
onChange={handleEditorChange}
onChange={canEdit ? handleEditorChange : undefined}
className="w-full h-full"
editorContentClassName="p-5 min-h-[300px]"
output="json"
placeholder={'Enter your analysis...'}
autofocus={true}
showToolbar={showToolbar}
placeholder={canEdit ? 'Enter your analysis...' : ''}
autofocus={canEdit}
showToolbar={canEdit && showToolbar}
editorClassName="focus:outline-hidden"
onEditorReady={setEditor}
editable={canEdit}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground space-y-3">
<div>No analysis selected.</div>
<Button
className="shadow-none"
variant="outline"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<PlusIcon className="w-4 h-4 mr-2" strokeWidth={1.5} />
Create your first analysis
</Button>
{canEdit && (
<Button
className="shadow-none"
variant="outline"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<PlusIcon className="w-4 h-4 mr-2" strokeWidth={1.5} />
Create your first analysis
</Button>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,246 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
Pilcrow,
Link as LinkIcon,
Highlighter,
ChevronDown
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { LinkEditBlock } from '../link/link-edit-block'
import { cn } from '@/lib/utils'
interface TextBubbleMenuProps {
editor: Editor
}
const prevent = (e: React.MouseEvent) => e.preventDefault()
const BubbleButton = ({
active,
onClick,
disabled,
children,
}: {
active?: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) => (
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-sm text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
active && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50'
)}
onMouseDown={prevent}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
)
export const TextBubbleMenu: React.FC<TextBubbleMenuProps> = ({ editor }) => {
const [showLinkEditor, setShowLinkEditor] = React.useState(false)
const shouldShow = React.useCallback(
({ editor: ed, from, to }: { editor: Editor; from: number; to: number }) => {
if (from === to) return false
if (!ed.isEditable) return false
if (ed.isActive('codeBlock')) return false
if (ed.isActive('link')) return false
return true
},
[]
)
const handleSetLink = React.useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
if (text) {
editor
.chain()
.focus()
.insertContent({
type: 'text',
text,
marks: [
{
type: 'link',
attrs: { href: url, target: openInNewTab ? '_blank' : '' }
}
]
})
.run()
} else {
editor
.chain()
.focus()
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
.run()
}
setShowLinkEditor(false)
},
[editor]
)
const activeHeading = editor.isActive('heading', { level: 1 })
? 'H1'
: editor.isActive('heading', { level: 2 })
? 'H2'
: editor.isActive('heading', { level: 3 })
? 'H3'
: 'P'
return (
<BubbleMenu
editor={editor}
pluginKey="textBubbleMenu"
shouldShow={shouldShow}
options={{
placement: 'top',
offset: 8
}}
>
<div
className="flex items-center gap-0.5 rounded-lg border bg-popover p-1 shadow-md"
onMouseDown={prevent}
>
<BubbleButton
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
>
<Bold className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('underline')}
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={!editor.can().chain().focus().toggleUnderline().run()}
>
<Underline className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('strike')}
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('code')}
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
>
<Code className="size-4" />
</BubbleButton>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Heading dropdown */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-7 items-center gap-0.5 rounded-sm px-2 text-xs font-semibold hover:bg-accent hover:text-accent-foreground"
onMouseDown={prevent}
>
{activeHeading}
<ChevronDown className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-1" align="start">
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="size-4" />
Paragraph
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 1 }).run()}
>
<Heading1 className="size-4" />
Heading 1
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 2 }).run()}
>
<Heading2 className="size-4" />
Heading 2
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 3 }).run()}
>
<Heading3 className="size-4" />
Heading 3
</button>
</div>
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Link */}
<Popover open={showLinkEditor} onOpenChange={setShowLinkEditor}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent hover:text-accent-foreground',
editor.isActive('link') && 'bg-accent text-accent-foreground'
)}
onMouseDown={prevent}
>
<LinkIcon className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-80 p-0" align="start">
<LinkEditBlock onSave={handleSetLink} className="p-4" />
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Highlight */}
<BubbleButton
active={editor.isActive('highlight')}
onClick={() => editor.chain().focus().toggleHighlight().run()}
>
<Highlighter className="size-4" />
</BubbleButton>
</div>
</BubbleMenu>
)
}

View File

@@ -0,0 +1,242 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
const dragHandleKey = new PluginKey("dragHandle");
function findBlockAt(
view: any,
y: number,
): { pos: number; dom: HTMLElement } | null {
const editorRect = view.dom.getBoundingClientRect();
const paddingLeft = parseFloat(getComputedStyle(view.dom).paddingLeft) || 0;
const coords = { left: editorRect.left + paddingLeft + 1, top: y };
const posInfo = view.posAtCoords(coords);
if (!posInfo) return null;
const $pos = view.state.doc.resolve(posInfo.pos);
const wrapperTypes = new Set([
"bulletList", "orderedList", "taskList",
]);
for (let d = $pos.depth; d >= 1; d--) {
const node = $pos.node(d);
if (!node.isBlock) continue;
if (d === 1 && $pos.before(1) === 0) continue;
if (wrapperTypes.has(node.type.name)) continue;
if (node.isTextblock && d > 1) {
const parent = $pos.node(d - 1);
if (parent.type.name === "listItem" || parent.type.name === "taskItem") {
continue;
}
}
const pos = $pos.before(d);
const dom = view.nodeDOM(pos);
if (dom instanceof HTMLElement) {
return { pos, dom };
}
}
return null;
}
export const DragHandle = Extension.create({
name: "dragHandle",
addProseMirrorPlugins() {
const handle = document.createElement("div");
handle.className = "flowsint-drag-handle";
handle.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><circle cx="4" cy="2" r="1.5"/><circle cx="10" cy="2" r="1.5"/><circle cx="4" cy="7" r="1.5"/><circle cx="10" cy="7" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="10" cy="12" r="1.5"/></svg>`;
const indicator = document.createElement("div");
indicator.className = "flowsint-drop-indicator";
let currentBlockPos: number | null = null;
let dragging: { pos: number; ghost: HTMLElement } | null = null;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
return [
new Plugin({
key: dragHandleKey,
view(view) {
const wrapper = view.dom.parentElement!;
wrapper.style.position = "relative";
wrapper.appendChild(handle);
wrapper.appendChild(indicator);
const positionHandle = (blockDom: HTMLElement, pos: number) => {
if (hideTimeout) clearTimeout(hideTimeout);
currentBlockPos = pos;
const wr = wrapper.getBoundingClientRect();
const br = blockDom.getBoundingClientRect();
handle.style.top = `${br.top - wr.top}px`;
handle.style.left = `${br.left - wr.left - 24}px`;
handle.style.height = `${br.height}px`;
handle.style.display = "flex";
};
const hideAll = () => {
handle.style.display = "none";
indicator.style.display = "none";
currentBlockPos = null;
};
const scheduleHide = () => {
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(hideAll, 150);
};
const cancelHide = () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
};
const onEditorMouseMove = (e: MouseEvent) => {
if (dragging || e.buttons > 0) return;
const block = findBlockAt(view, e.clientY);
if (block) {
if (block.pos !== currentBlockPos) {
positionHandle(block.dom, block.pos);
} else {
cancelHide();
}
} else {
scheduleHide();
}
};
const onEditorLeave = () => {
if (!dragging) scheduleHide();
};
const onHandleEnter = () => cancelHide();
const onHandleLeave = () => {
if (!dragging) scheduleHide();
};
const onHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
if (currentBlockPos === null) return;
const pos = currentBlockPos;
const blockDom = view.nodeDOM(pos) as HTMLElement;
if (!blockDom) return;
const ghost = blockDom.cloneNode(true) as HTMLElement;
ghost.className = "flowsint-drag-ghost";
ghost.style.width = `${blockDom.offsetWidth}px`;
ghost.style.left = `${e.clientX}px`;
ghost.style.top = `${e.clientY}px`;
document.body.appendChild(ghost);
blockDom.classList.add("flowsint-dragging-source");
dragging = { pos, ghost };
hideAll();
const onMouseMove = (ev: MouseEvent) => {
if (!dragging) return;
dragging.ghost.style.left = `${ev.clientX}px`;
dragging.ghost.style.top = `${ev.clientY}px`;
const block = findBlockAt(view, ev.clientY);
if (block) {
const wr = wrapper.getBoundingClientRect();
const br = block.dom.getBoundingClientRect();
const above = ev.clientY < br.top + br.height / 2;
indicator.style.top = `${(above ? br.top : br.bottom) - wr.top - 1}px`;
indicator.style.left = `${br.left - wr.left}px`;
indicator.style.width = `${br.width}px`;
indicator.style.display = "block";
} else {
indicator.style.display = "none";
}
};
const onMouseUp = (ev: MouseEvent) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!dragging) return;
dragging.ghost.remove();
blockDom.classList.remove("flowsint-dragging-source");
indicator.style.display = "none";
const originPos = dragging.pos;
dragging = null;
const originNode = view.state.doc.nodeAt(originPos);
if (!originNode) return;
const target = findBlockAt(view, ev.clientY);
if (!target) return;
if (
originPos < target.pos &&
originPos + originNode.nodeSize > target.pos
)
return;
const targetNode = view.state.doc.nodeAt(target.pos);
if (!targetNode) return;
const targetRect = target.dom.getBoundingClientRect();
const above = ev.clientY < targetRect.top + targetRect.height / 2;
const insertPos = above
? target.pos
: target.pos + targetNode.nodeSize;
if (
insertPos === originPos ||
insertPos === originPos + originNode.nodeSize
)
return;
const tr = view.state.tr;
if (originPos < insertPos) {
const adjusted = insertPos - originNode.nodeSize;
tr.delete(originPos, originPos + originNode.nodeSize);
tr.insert(adjusted, originNode);
} else {
tr.insert(insertPos, originNode);
tr.delete(
originPos + originNode.nodeSize,
originPos + originNode.nodeSize * 2,
);
}
view.dispatch(tr);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
view.dom.addEventListener("mousemove", onEditorMouseMove);
view.dom.addEventListener("mouseleave", onEditorLeave);
handle.addEventListener("mouseenter", onHandleEnter);
handle.addEventListener("mouseleave", onHandleLeave);
handle.addEventListener("mousedown", onHandleMouseDown);
return {
destroy() {
if (hideTimeout) clearTimeout(hideTimeout);
view.dom.removeEventListener("mousemove", onEditorMouseMove);
view.dom.removeEventListener("mouseleave", onEditorLeave);
handle.removeEventListener("mouseenter", onHandleEnter);
handle.removeEventListener("mouseleave", onHandleLeave);
handle.removeEventListener("mousedown", onHandleMouseDown);
handle.remove();
indicator.remove();
if (dragging) {
dragging.ghost.remove();
dragging = null;
}
},
};
},
}),
];
},
});

View File

@@ -0,0 +1 @@
export { DragHandle } from './drag-handle'

View File

@@ -0,0 +1 @@
export { PasteMarkdown } from './markdown-paste'

View File

@@ -0,0 +1,62 @@
import { Extension } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { MarkdownManager } from '@tiptap/markdown'
function looksLikeMarkdown(text: string): boolean {
return (
/^#{1,6}\s/m.test(text) ||
/\*\*[^*]+\*\*/.test(text) ||
/\[.+\]\(.+\)/.test(text) ||
/^[-*+]\s/m.test(text) ||
/^\d+\.\s/m.test(text) ||
/^>\s/m.test(text) ||
/^```/m.test(text) ||
/^---$/m.test(text) ||
/!\[.*\]\(.*\)/.test(text) ||
/^- \[[ x]\]/m.test(text)
)
}
export const PasteMarkdown = Extension.create({
name: 'pasteMarkdown',
addStorage() {
return {
markdownManager: null as MarkdownManager | null
}
},
onCreate() {
this.storage.markdownManager = new MarkdownManager({
extensions: this.editor.extensionManager.baseExtensions
})
},
addProseMirrorPlugins() {
const { editor } = this
const storage = this.storage
return [
new Plugin({
props: {
handlePaste(_view, event) {
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
if (storage.markdownManager && looksLikeMarkdown(text)) {
try {
const json = storage.markdownManager.parse(text)
editor.chain().focus().insertContent(json).run()
return true
} catch (e) {
console.error('[PasteMarkdown]', e)
return false
}
}
return false
}
}
})
]
}
})

View File

@@ -0,0 +1,2 @@
export { SlashCommand } from './slash-command'
export type { SlashCommandItem } from './slash-command'

View File

@@ -0,0 +1,131 @@
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react'
import type { SlashCommandItem } from './slash-command'
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
Quote,
Code,
Minus,
ImageIcon
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const iconMap: Record<string, LucideIcon> = {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
Quote,
Code,
Minus,
ImageIcon
}
export interface SlashCommandListProps {
items: SlashCommandItem[]
command: (item: SlashCommandItem) => void
}
export interface SlashCommandListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
const SlashCommandList = forwardRef<SlashCommandListRef, SlashCommandListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = props.items[index]
if (item) {
props.command(item)
}
},
[props]
)
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % props.items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
}
}))
// Group items by category
const grouped = props.items.reduce<Record<string, SlashCommandItem[]>>((acc, item) => {
if (!acc[item.category]) acc[item.category] = []
acc[item.category].push(item)
return acc
}, {})
// Flat index tracking
let flatIndex = -1
if (!props.items.length) {
return (
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
</div>
)
}
return (
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<div className="overflow-y-auto max-h-[300px] p-1">
{Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{category}</div>
{items.map((item) => {
flatIndex++
const currentIndex = flatIndex
const Icon = iconMap[item.icon]
return (
<button
key={item.title}
className={`relative flex w-full cursor-pointer select-none items-center gap-3 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground ${
currentIndex === selectedIndex ? 'bg-accent text-accent-foreground' : ''
}`}
onClick={() => selectItem(currentIndex)}
type="button"
>
{Icon && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-background">
<Icon size={16} />
</div>
)}
<div className="flex flex-col text-left">
<span className="font-medium">{item.title}</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</div>
</button>
)
})}
</div>
))}
</div>
</div>
)
})
SlashCommandList.displayName = 'SlashCommandList'
export { SlashCommandList }
export default SlashCommandList

View File

@@ -0,0 +1,211 @@
import { Extension } from '@tiptap/core'
import { computePosition, flip, shift } from '@floating-ui/dom'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import Suggestion from '@tiptap/suggestion'
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
import { SlashCommandList } from './slash-command-list'
import type { SlashCommandListRef } from './slash-command-list'
export interface SlashCommandItem {
title: string
description: string
category: string
icon: string
command: (editor: Editor) => void
}
const updatePosition = (editor: Editor, element: HTMLElement) => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()]
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
const slashCommandItems: SlashCommandItem[] = [
{
title: 'Heading 1',
description: 'Large section heading',
category: 'Hierarchy',
icon: 'Heading1',
command: (editor) => editor.chain().focus().setHeading({ level: 1 }).run()
},
{
title: 'Heading 2',
description: 'Medium section heading',
category: 'Hierarchy',
icon: 'Heading2',
command: (editor) => editor.chain().focus().setHeading({ level: 2 }).run()
},
{
title: 'Heading 3',
description: 'Small section heading',
category: 'Hierarchy',
icon: 'Heading3',
command: (editor) => editor.chain().focus().setHeading({ level: 3 }).run()
},
{
title: 'Bullet List',
description: 'Create a simple bullet list',
category: 'Lists',
icon: 'List',
command: (editor) => editor.chain().focus().toggleBulletList().run()
},
{
title: 'Numbered List',
description: 'Create a numbered list',
category: 'Lists',
icon: 'ListOrdered',
command: (editor) => editor.chain().focus().toggleOrderedList().run()
},
{
title: 'Task List',
description: 'Create a task checklist',
category: 'Lists',
icon: 'ListTodo',
command: (editor) => editor.chain().focus().toggleTaskList().run()
},
{
title: 'Blockquote',
description: 'Add a quote block',
category: 'Blocks',
icon: 'Quote',
command: (editor) => editor.chain().focus().toggleBlockquote().run()
},
{
title: 'Code Block',
description: 'Add a code snippet',
category: 'Blocks',
icon: 'Code',
command: (editor) => editor.chain().focus().toggleCodeBlock().run()
},
{
title: 'Divider',
description: 'Add a horizontal divider',
category: 'Blocks',
icon: 'Minus',
command: (editor) => editor.chain().focus().setHorizontalRule().run()
},
{
title: 'Image',
description: 'Upload or embed an image',
category: 'Media',
icon: 'ImageIcon',
command: (editor) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const file = input.files?.[0]
if (file) {
const blobUrl = URL.createObjectURL(file)
editor.commands.insertContent({
type: 'image',
attrs: {
src: blobUrl,
alt: file.name,
title: file.name
}
})
}
}
input.click()
}
}
]
export const SlashCommand = Extension.create({
name: 'slashCommand',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: false,
items: ({ query }: { query: string }): SlashCommandItem[] => {
return slashCommandItems.filter(
(item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.category.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component: ReactRenderer<SlashCommandListRef> | undefined
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(SlashCommandList, {
props,
editor: props.editor
})
if (!props.clientRect) return
const element = component.element as HTMLElement
element.style.position = 'absolute'
element.style.zIndex = '9999'
document.body.appendChild(element)
updatePosition(props.editor, element)
},
onUpdate(props: SuggestionProps) {
if (!component) return
component.updateProps(props)
if (!props.clientRect) return
const element = component.element as HTMLElement
updatePosition(props.editor, element)
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === 'Escape') {
component?.destroy()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
if (!component) return
component.element.remove()
component.destroy()
}
}
},
command: ({
editor,
range,
props
}: {
editor: Editor
range: { from: number; to: number }
props: SlashCommandItem
}) => {
editor.chain().focus().deleteRange(range).run()
props.command(editor)
}
} as Partial<SuggestionOptions>
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
]
}
})
export default SlashCommand

View File

@@ -5,6 +5,7 @@ import NewAnalysis from "../analyses/new-analysis"
interface EmptyStateProps {
onAction?: () => void
canCreate?: boolean
}
export function EmptyInvestigations({ onAction }: EmptyStateProps) {
@@ -70,7 +71,7 @@ export function EmptySketches({ onAction }: EmptyStateProps) {
)
}
export function EmptyAnalyses({ onAction }: EmptyStateProps) {
export function EmptyAnalyses({ onAction, canCreate = true }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-10 px-4">
{/* Minimal document illustration */}
@@ -93,12 +94,14 @@ export function EmptyAnalyses({ onAction }: EmptyStateProps) {
<p className="text-xs text-muted-foreground text-center max-w-[200px] mb-4">
Document findings, patterns, and conclusions from your investigation
</p>
<NewAnalysis>
<Button onClick={onAction} variant="ghost" size="sm" className="h-7 text-xs gap-1.5">
<Plus className="w-3.5 h-3.5" />
Write analysis
</Button>
</NewAnalysis>
{canCreate && (
<NewAnalysis>
<Button onClick={onAction} variant="ghost" size="sm" className="h-7 text-xs gap-1.5">
<Plus className="w-3.5 h-3.5" />
Write analysis
</Button>
</NewAnalysis>
)}
</div>
)
}

View File

@@ -8,24 +8,27 @@ import { Link } from "@tanstack/react-router"
interface AnalysesSectionProps {
analyses: Analysis[]
canCreate?: boolean
}
export function AnalysesSection({ analyses }: AnalysesSectionProps) {
export function AnalysesSection({ analyses, canCreate = true }: AnalysesSectionProps) {
return (
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium text-foreground">Analyses</h2>
<NewAnalysis>
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
<Plus className="w-3.5 h-3.5" />
New
</Button>
</NewAnalysis>
{canCreate && (
<NewAnalysis>
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
<Plus className="w-3.5 h-3.5" />
New
</Button>
</NewAnalysis>
)}
</div>
{analyses.length === 0 ?
<div className="border border-dashed rounded-md">
<EmptyAnalyses />
<EmptyAnalyses canCreate={canCreate} />
</div> :
<div className="space-y-1">
{analyses.map((analysis) => (

View File

@@ -1,23 +1,34 @@
import type React from "react"
import { Button } from "@/components/ui/button"
import type React from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { MoreHorizontal, Star, Share, Trash2, Clock } from "lucide-react"
import { Investigation } from "@/types"
import { formatDistanceToNow } from "date-fns"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useQueryClient } from "@tanstack/react-query"
import { investigationService } from "@/api/investigation-service"
import { useConfirm } from "@/components/use-confirm-dialog"
import { queryKeys } from "@/api/query-keys"
import { toast } from "sonner"
import { Link, useRouter } from "@tanstack/react-router"
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Star, Share, Trash2, Clock, Crown, Shield, Pencil, Eye } from 'lucide-react'
import { Investigation, Collaborator } from '@/types'
import { formatDistanceToNow } from 'date-fns'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { AvatarGroup } from '@/components/ui/avatar'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { investigationService } from '@/api/investigation-service'
import { useConfirm } from '@/components/use-confirm-dialog'
import { queryKeys } from '@/api/query-keys'
import { toast } from 'sonner'
import { Link, useRouter } from '@tanstack/react-router'
import { usePermissions } from '@/hooks/use-can'
import type { InvestigationRole } from '@/types'
import { ShareDialog } from './share-dialog'
const ROLE_CONFIG: Record<InvestigationRole, { label: string; icon: typeof Eye; className: string }> = {
owner: { label: 'Owner', icon: Crown, className: 'bg-amber-500/15 text-amber-700 border-amber-500/30 dark:text-amber-400' },
admin: { label: 'Admin', icon: Shield, className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400' },
editor: { label: 'Editor', icon: Pencil, className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400' },
viewer: { label: 'Viewer', icon: Eye, className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400' },
}
type CaseOverviewPageProps = {
investigation: Investigation
@@ -28,10 +39,16 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
addSuffix: true
})
const router = useRouter()
const { canDelete, canManage, role } = usePermissions()
const { confirm } = useConfirm()
const queryClient = useQueryClient()
const { data: collaborators = [] } = useQuery<Collaborator[]>({
queryKey: queryKeys.investigations.collaborators(investigation.id),
queryFn: () => investigationService.getCollaborators(investigation.id)
})
const handleDeleteInvestigation = async () => {
const confirmed = await confirm({
title: 'Delete Investigation',
@@ -41,13 +58,9 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
if (confirmed) {
const deletePromise = () =>
investigationService.delete(investigation.id).then(() => {
''
// Invalidate the investigations list
queryClient.invalidateQueries({
queryKey: queryKeys.investigations.list
})
// Also remove related data from cache
queryClient.removeQueries({
queryKey: queryKeys.investigations.detail(investigation.id)
})
@@ -60,7 +73,7 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
queryClient.removeQueries({
queryKey: queryKeys.investigations.flows(investigation.id)
})
router.navigate({ to: "/dashboard" })
router.navigate({ to: '/dashboard' })
})
toast.promise(deletePromise, {
@@ -73,9 +86,11 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
return (
<div className="space-y-6 pb-6 border-b border-border">
{/* Breadcrumb - subtle */}
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to={"/dashboard"} className="hover:text-foreground cursor-pointer transition-colors">Cases</Link>
<Link to={'/dashboard'} className="hover:text-foreground cursor-pointer transition-colors">
Cases
</Link>
<span>/</span>
<span className="text-foreground">{investigation.name}</span>
</div>
@@ -84,63 +99,76 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-foreground tracking-tight">{investigation.name}</h1>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{investigation.name}
</h1>
<FavoriteButton />
</div>
{/* Inline properties - Notion style */}
{/* Role badge */}
{role && (() => {
const config = ROLE_CONFIG[role]
const Icon = config.icon
return (
<Badge className={cn('gap-1 text-xs font-medium shadow-none', config.className)}>
<Icon className="w-3 h-3" />
{config.label}
</Badge>
)
})()}
{/* Inline properties */}
<div className="flex items-center gap-4 text-sm">
<PropertyPill label="Status" value={investigation.status} valueClass="text-success" />
<PropertyPill label="Priority" value="Medium" valueClass="text-primary" />
<PropertyPill label="Updated" value={lastUpdated} icon={<Clock className="w-3 h-3" />} />
<PropertyPill
label="Updated"
value={lastUpdated}
icon={<Clock className="w-3 h-3" />}
/>
</div>
</div>
{/* Actions - minimal */}
{/* Actions */}
<div className="flex items-center gap-1">
{/* <Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
<Share className="w-4 h-4" />
</Button> */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{canManage && (
<ShareDialog investigationId={investigation.id}>
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
<MoreHorizontal className="w-4 h-4" />
<Share className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* <DropdownMenuItem>Export as PDF</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem> */}
{/* <DropdownMenuSeparator /> */}
<DropdownMenuItem onClick={handleDeleteInvestigation} className="text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ShareDialog>
)}
{canDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleDeleteInvestigation} className="text-destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Tags row */}
{/* <div className="flex items-center gap-2 flex-wrap">
<Tag>APT</Tag>
<Tag>Nation-State</Tag>
<Tag>Supply Chain</Tag>
<Tag>SolarWinds</Tag>
<button className="text-xs text-muted-foreground hover:text-foreground transition-colors">+ Add tag</button>
</div> */}
{/* Team */}
<div className="flex items-center gap-2">
<div className="flex -space-x-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src="https://cherry.img.pmdstatic.net/fit/https.3A.2F.2Fimg.2Egamesider.2Ecom.2Fs3.2Ffrgsg.2F1280.2Fthe-last-of-us.2Fdefault_2023-11-27_291826c8-5b2b-4928-a167-259dd0b18a7c.2Ejpeg/1200x675/quality/80/the-last-of-us-saison-2-mauvaise-nouvelle-pedro-pascal.jpg" />
<AvatarFallback>U</AvatarFallback>
</Avatar>
{/* <Avatar initials="SK" />
<Avatar initials="MR" /> */}
</div>
<span className="text-sm text-muted-foreground">1 investigator</span>
{/* <button disabled className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-1">+ Invite</button> */}
<AvatarGroup users={collaborators.map((c) => c.user)} size="md" />
<span className="text-sm text-muted-foreground">
{collaborators.length} investigator{collaborators.length !== 1 ? 's' : ''}
</span>
{canManage && (
<ShareDialog investigationId={investigation.id}>
<button className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-1">
+ Invite
</button>
</ShareDialog>
)}
</div>
</div>
)
@@ -149,8 +177,8 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
function PropertyPill({
label,
value,
valueClass = "text-foreground",
icon,
valueClass = 'text-foreground',
icon
}: {
label: string
value: string
@@ -172,12 +200,14 @@ function PropertyPill({
// return <span className="px-2 py-0.5 rounded bg-secondary text-xs text-secondary-foreground">{children}</span>
// }
const FavoriteButton = () => {
const [fav, setFav] = useState<boolean>(false)
return (
<button onClick={() => setFav(!fav)} className={cn("text-muted-foreground hover:text-warning transition-colors")}>
<Star className={cn("w-4 h-4", fav && "text-warning fill-warning")} />
<button
onClick={() => setFav(!fav)}
className={cn('text-muted-foreground hover:text-warning transition-colors')}
>
<Star className={cn('w-4 h-4', fav && 'text-warning fill-warning')} />
</button>
)
}
}

View File

@@ -1,36 +1,24 @@
import { CaseHeader } from "./case-header"
// import { MetricsGrid } from "./metrics-grid"
// import { ActivityTimeline } from "./activity-timeline"
import { SketchesSection } from "./sketches-section"
import { AnalysesSection } from "./analyses-section"
// import { EvidenceSection } from "./evidence-section"
// import { TasksSection } from "./tasks-section"
import { Investigation } from "@/types"
import { usePermissions } from "@/hooks/use-can"
type CaseOverviewPageProps = {
investigation: Investigation
}
export function CaseOverviewPage({ investigation }: CaseOverviewPageProps) {
const { canCreate } = usePermissions()
return (
<main className="flex-1 h-full overflow-auto">
<div className="max-w-7xl mx-auto px-8 py-8">
<CaseHeader investigation={investigation} />
{/* <MetricsGrid investigation={investigation} /> */}
<div className="space-y-8">
<SketchesSection sketches={investigation.sketches} />
<AnalysesSection analyses={investigation.analyses} />
{/* Two column for smaller sections */}
{/* <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<EvidenceSection />
<div className="space-y-8">
<TasksSection />
<ActivityTimeline />
</div>
</div> */}
<SketchesSection sketches={investigation.sketches} canCreate={canCreate} />
<AnalysesSection analyses={investigation.analyses} canCreate={canCreate} />
</div>
</div>
</main>

View File

@@ -126,7 +126,8 @@ export function InvestigationsList({
</table>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div style={{ containerType: 'inline-size' }}>
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 gap-3">
{filteredInvestigations.length === 0 && <div>No investigation found.</div>}
{filteredInvestigations.map((inv) => (
<Link
@@ -168,6 +169,7 @@ export function InvestigationsList({
</Link>
))}
</div>
</div>
)}
</div>
)

View File

@@ -1,7 +1,6 @@
import { Network, FileText, Users, Shield, Paperclip, CheckCircle } from "lucide-react"
import { Network, FileText, Users, Shield } from "lucide-react"
import { Investigation } from "@/types"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
type CaseOverviewPageProps = {
investigation: Investigation
@@ -19,7 +18,8 @@ export function MetricsGrid({ investigation }: CaseOverviewPageProps) {
], [sketchCount, analysisCount])
return (
<div className={cn("grid border grid-cols-3 gap-px bg-border rounded-lg overflow-hidden my-6", `md:grid-cols-${metrics.length}`)} >
<div className="my-6" style={{ containerType: 'inline-size' }}>
<div className="grid border grid-cols-2 cq-sm:grid-cols-4 gap-px bg-border rounded-lg overflow-hidden">
{
metrics.map((metric) => (
<div
@@ -32,6 +32,7 @@ export function MetricsGrid({ investigation }: CaseOverviewPageProps) {
</div>
))
}
</ div>
</div>
</div>
)
}

View File

@@ -0,0 +1,405 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { investigationService } from '@/api/investigation-service'
import { authService } from '@/api/auth-service'
import { queryKeys } from '@/api/query-keys'
import { toast } from 'sonner'
import type { Collaborator, InvestigationRole, Profile } from '@/types'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { UserAvatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { X, UserPlus, Crown, Shield, Pencil, Eye, Users, Check } from 'lucide-react'
import { getDisplayName } from '@/lib/user-display'
import { cn } from '@/lib/utils'
const ROLE_OPTIONS: {
value: InvestigationRole
label: string
icon: typeof Eye
className: string
}[] = [
{
value: 'admin',
label: 'Admin',
icon: Shield,
className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400'
},
{
value: 'editor',
label: 'Editor',
icon: Pencil,
className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400'
},
{
value: 'viewer',
label: 'Viewer',
icon: Eye,
className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400'
}
]
const ROLE_BADGE: Record<
InvestigationRole,
{ label: string; icon: typeof Eye; className: string }
> = {
owner: {
label: 'Owner',
icon: Crown,
className: 'bg-amber-500/15 text-amber-700 border-amber-500/30 dark:text-amber-400'
},
admin: {
label: 'Admin',
icon: Shield,
className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400'
},
editor: {
label: 'Editor',
icon: Pencil,
className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400'
},
viewer: {
label: 'Viewer',
icon: Eye,
className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400'
}
}
function getRoleFromCollaborator(collab: Collaborator): InvestigationRole {
return (collab.roles[0] ?? 'viewer') as InvestigationRole
}
const RoleBadge = ({
role,
className,
...props
}: { role: InvestigationRole; className?: string } & React.ComponentProps<'div'>) => {
const config = ROLE_BADGE[role]
const Icon = config.icon
return (
<Badge
variant="outline"
className={cn(
'gap-1 text-[11px] font-medium shadow-none px-1.5 py-0 cursor-default',
config.className,
className
)}
{...props}
>
<Icon className="w-3 h-3" />
{config.label}
</Badge>
)
}
interface ShareDialogProps {
investigationId: string
children: React.ReactNode
}
export function ShareDialog({ investigationId, children }: ShareDialogProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedEmail, setSelectedEmail] = useState('')
const [role, setRole] = useState<string>('editor')
const [suggestions, setSuggestions] = useState<Profile[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const queryClient = useQueryClient()
const { data: collaborators = [], isLoading } = useQuery<Collaborator[]>({
queryKey: queryKeys.investigations.collaborators(investigationId),
queryFn: () => investigationService.getCollaborators(investigationId),
enabled: open
})
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
setSelectedEmail(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
if (value.length < 2) {
setSuggestions([])
setShowSuggestions(false)
return
}
debounceRef.current = setTimeout(async () => {
try {
const results = await authService.searchUsers(value)
setSuggestions(results)
setShowSuggestions(results.length > 0)
} catch {
setSuggestions([])
setShowSuggestions(false)
}
}, 300)
}, [])
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [])
const handleSelectUser = (user: Profile) => {
setSelectedEmail(user.email ?? '')
setQuery(getDisplayName(user))
setShowSuggestions(false)
setSuggestions([])
}
const addMutation = useMutation({
mutationFn: (body: { email: string; role: string }) =>
investigationService.addCollaborator(investigationId, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.investigations.collaborators(investigationId)
})
setQuery('')
setSelectedEmail('')
toast.success('Collaborator added')
},
onError: (error: any) => {
const message = error?.message || 'Failed to add collaborator'
toast.error(message)
}
})
const updateMutation = useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
investigationService.updateCollaboratorRole(investigationId, userId, { role }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.investigations.collaborators(investigationId)
})
toast.success('Role updated')
},
onError: () => toast.error('Failed to update role')
})
const removeMutation = useMutation({
mutationFn: (userId: string) =>
investigationService.removeCollaborator(investigationId, userId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.investigations.collaborators(investigationId)
})
toast.success('Collaborator removed')
},
onError: () => toast.error('Failed to remove collaborator')
})
const handleInvite = () => {
if (!selectedEmail.trim()) return
addMutation.mutate({ email: selectedEmail.trim(), role })
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-xl gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4">
<DialogTitle className="text-base">Share investigation</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Invite collaborators and manage access.
</DialogDescription>
</DialogHeader>
{/* Invite form */}
<div className="px-5 pb-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
placeholder="Search by name or email..."
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleInvite()
setShowSuggestions(false)
}
if (e.key === 'Escape') setShowSuggestions(false)
}}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
className="h-9"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
{suggestions.map((user) => (
<button
key={user.id}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent text-left transition-colors"
onMouseDown={(e) => {
e.preventDefault()
handleSelectUser(user)
}}
>
<UserAvatar user={user} size="sm" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{getDisplayName(user)}</p>
{user.email && (
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
)}
</div>
</button>
))}
</div>
)}
</div>
<Select value={role} onValueChange={setRole}>
<SelectTrigger className="w-[100px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((opt) => {
const Icon = opt.icon
return (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 opacity-60" />
{opt.label}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<Button
size="sm"
className="h-9 px-3"
onClick={handleInvite}
disabled={!selectedEmail.trim() || addMutation.isPending}
>
<UserPlus className="w-4 h-4 mr-1.5" />
Invite
</Button>
</div>
</div>
<Separator />
{/* Collaborators list */}
<div className="max-h-72 overflow-y-auto">
{isLoading ? (
<div className="p-5 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-36" />
</div>
<Skeleton className="h-5 w-14 rounded-full" />
</div>
))}
</div>
) : collaborators.length === 0 ? (
<div className="py-10 flex flex-col items-center gap-2 text-center">
<Users className="w-8 h-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No collaborators yet</p>
</div>
) : (
<div className="p-2">
{collaborators.map((collab) => {
const collabRole = getRoleFromCollaborator(collab)
const isOwner = collabRole === 'owner'
return (
<div
key={collab.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors group"
>
<UserAvatar user={collab.user} size="md" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{getDisplayName(collab.user)}</p>
{collab.user?.email && (
<p className="text-xs text-muted-foreground truncate">
{collab.user.email}
</p>
)}
</div>
{isOwner ? (
<RoleBadge role="owner" />
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button>
<RoleBadge role={collabRole} className="cursor-pointer hover:opacity-80 transition-opacity" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
{ROLE_OPTIONS.map((opt) => {
const Icon = opt.icon
const isActive = opt.value === collabRole
return (
<DropdownMenuItem
key={opt.value}
onClick={() => updateMutation.mutate({ userId: collab.user_id, role: opt.value })}
className="flex items-center gap-2"
>
<Icon className="w-3.5 h-3.5 opacity-60" />
{opt.label}
{isActive && <Check className="w-3.5 h-3.5 ml-auto" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 shrink-0',
isOwner
? 'invisible'
: 'text-muted-foreground/50 hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity'
)}
onClick={() => !isOwner && removeMutation.mutate(collab.user_id)}
disabled={isOwner}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
{collaborators.length > 0 && (
<>
<Separator />
<div className="px-5 py-3">
<p className="text-xs text-muted-foreground">
{collaborators.length} member{collaborators.length !== 1 ? 's' : ''} have access
</p>
</div>
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -9,16 +9,17 @@ import NewSketch from "@/components/sketches/new-sketch"
interface SketchesSectionProps {
sketches: Sketch[]
canCreate?: boolean
}
export function SketchesSection({ sketches }: SketchesSectionProps) {
export function SketchesSection({ sketches, canCreate = true }: SketchesSectionProps) {
const isEmpty = sketches.length === 0
return (
<section className="my-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium text-foreground">Sketches</h2>
{!isEmpty && (
{!isEmpty && canCreate && (
<NewSketch>
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
<Plus className="w-3.5 h-3.5" />
@@ -29,9 +30,10 @@ export function SketchesSection({ sketches }: SketchesSectionProps) {
</div>
{isEmpty ? (
<EmptySketches onAction={() => console.log("Create sketch")} />
canCreate ? <EmptySketches onAction={() => console.log("Create sketch")} /> : <div className="text-sm text-muted-foreground py-4">No sketches yet.</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div style={{ containerType: 'inline-size' }}>
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 cq-lg:grid-cols-4 gap-3">
{sketches.map((sketch) => (
<Link
to="/dashboard/investigations/$investigationId/$type/$id"
@@ -74,6 +76,7 @@ export function SketchesSection({ sketches }: SketchesSectionProps) {
</Link>
))}
</div>
</div>
)
}
</section >

View File

@@ -16,7 +16,8 @@ export function DashboardStats({ casesCount, activeCasesCount }: DashboardStatsP
], [casesCount, activeCasesCount])
return (
<div className="grid grid-cols-4 gap-4 mt-8">
<div className="mt-8" style={{ containerType: 'inline-size' }}>
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<div key={stat.label} className="px-4 py-3 border border-border rounded-lg bg-card/30">
<div className="flex items-center gap-3">
@@ -31,5 +32,6 @@ export function DashboardStats({ casesCount, activeCasesCount }: DashboardStatsP
</div>
))}
</div>
</div>
)
}

View File

@@ -3,10 +3,15 @@ import { Link, useNavigate, useParams } from '@tanstack/react-router'
import InvestigationSelector from './investigation-selector'
import SketchSelector from './sketch-selector'
import { memo, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Switch } from '../ui/switch'
import { Label } from '../ui/label'
import { useLayoutStore } from '@/stores/layout-store'
import { Button } from '@/components/ui/button'
import { AvatarGroup } from '@/components/ui/avatar'
import { investigationService } from '@/api/investigation-service'
import { queryKeys } from '@/api/query-keys'
import type { Collaborator } from '@/types'
import { ImportSheet } from '../sketches/import-sheet'
import {
DropdownMenu,
@@ -22,16 +27,24 @@ import { Settings2, Upload } from 'lucide-react'
import { isMac } from '@/lib/utils'
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { useMutation } from '@tanstack/react-query'
import { Separator } from '../ui/separator'
import { useConfirm } from '../use-confirm-dialog'
import { sketchService } from '@/api/sketch-service'
import { toast } from 'sonner'
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'
import { usePermissions } from '@/hooks/use-can'
export const TopNavbar = memo(() => {
const { investigationId, id, type } = useParams({ strict: false })
const toggleAnalysis = useLayoutStore((s) => s.toggleAnalysis)
const isOpenAnalysis = useLayoutStore((s) => s.isOpenAnalysis)
const { data: collaborators = [] } = useQuery<Collaborator[]>({
queryKey: queryKeys.investigations.collaborators(investigationId!),
queryFn: () => investigationService.getCollaborators(investigationId!),
enabled: !!investigationId
})
const handleToggleAnalysis = useCallback(() => toggleAnalysis(), [toggleAnalysis])
return (
@@ -59,7 +72,13 @@ export const TopNavbar = memo(() => {
<Command />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{investigationId && collaborators.length > 0 && (
<>
<AvatarGroup users={collaborators.map((c) => c.user)} size="sm" max={5} />
<Separator orientation="vertical" className="h-5" />
</>
)}
<div className="flex items-center space-x-2">
{type === 'graph' && (
<>
@@ -85,6 +104,7 @@ export function InvestigationMenu({
investigationId?: string
sketchId: string
}) {
const { canEdit } = usePermissions()
const toggleSettingsModal = useGraphSettingsStore((s) => s.toggleSettingsModal)
const toggleKeyboardShortcutsModal = useGraphSettingsStore((s) => s.toggleKeyboardShortcutsModal)
const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen)
@@ -175,17 +195,20 @@ export function InvestigationMenu({
</a>
</DropdownMenuItem>
<DropdownMenuItem disabled>API</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setImportModalOpen(true)}>
<Upload /> Import entities
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} variant="destructive">
Delete sketch
{/* <DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut> */}
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setImportModalOpen(true)}>
<Upload /> Import entities
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} variant="destructive">
Delete sketch
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
<ImportSheet sketchId={sketchId} />
{canEdit && <ImportSheet sketchId={sketchId} />}
</DropdownMenu>
)
}

View File

@@ -1,4 +1,4 @@
import { LogOut } from 'lucide-react'
import { LogOut, UserIcon } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,11 +11,16 @@ import { Button } from './ui/button'
import { ModeToggle } from './mode-toggle'
import { authService } from '@/api/auth-service'
import { useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Avatar, AvatarImage, AvatarFallback } from './ui/avatar'
import { Link, useNavigate } from '@tanstack/react-router'
import { UserAvatar } from './ui/avatar'
import { useAuthStore } from '@/stores/auth-store'
import { getDisplayName } from '@/lib/user-display'
export function NavUser() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const displayName = getDisplayName(user)
const logout = useCallback(() => {
authService.logout()
navigate({ to: '/login' })
@@ -26,10 +31,7 @@ export function NavUser() {
<DropdownMenuTrigger asChild>
<div className="h-auto flex items-center justify-center">
<Button size="lg" className="p-0 h-auto rounded-full cursor-pointer">
<Avatar>
<AvatarImage src="https://cherry.img.pmdstatic.net/fit/https.3A.2F.2Fimg.2Egamesider.2Ecom.2Fs3.2Ffrgsg.2F1280.2Fthe-last-of-us.2Fdefault_2023-11-27_291826c8-5b2b-4928-a167-259dd0b18a7c.2Ejpeg/1200x675/quality/80/the-last-of-us-saison-2-mauvaise-nouvelle-pedro-pascal.jpg" />
<AvatarFallback>U</AvatarFallback>
</Avatar>
<UserAvatar user={user} />
</Button>
</div>
</DropdownMenuTrigger>
@@ -40,11 +42,11 @@ export function NavUser() {
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar>
<AvatarImage src="https://cherry.img.pmdstatic.net/fit/https.3A.2F.2Fimg.2Egamesider.2Ecom.2Fs3.2Ffrgsg.2F1280.2Fthe-last-of-us.2Fdefault_2023-11-27_291826c8-5b2b-4928-a167-259dd0b18a7c.2Ejpeg/1200x675/quality/80/the-last-of-us-saison-2-mauvaise-nouvelle-pedro-pascal.jpg" />
<AvatarFallback>U</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"></div>
<UserAvatar user={user} />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
{user?.email && <span className="truncate text-xs text-muted-foreground">{user.email}</span>}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs font-light opacity-60">Preferences</DropdownMenuLabel>
@@ -53,6 +55,12 @@ export function NavUser() {
<ModeToggle />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/dashboard/profile">
<UserIcon />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={logout}>
<LogOut />
Log out

View File

@@ -10,6 +10,7 @@ import {
} from '@/components/ui/popover'
import { memo, useState, useEffect, useCallback, useRef } from 'react'
import { cn } from '@/lib/utils'
import { FieldType, FormField, findActionItemByKey } from '@/lib/action-items'
import { CopyButton } from '@/components/copy'
import {
Rocket,
@@ -22,8 +23,10 @@ import {
import LaunchFlow from '../launch-enricher'
import NodeActions from '../graph/node/actions/node-actions'
import { useParams } from '@tanstack/react-router'
import { usePermissions } from '@/hooks/use-can'
import { useGraphStore } from '@/stores/graph-store'
import { useIcon } from '@/hooks/use-icon'
import { useActionItems } from '@/hooks/use-action-items'
import { Switch } from '@/components/ui/switch'
import type { GraphNode, NodeProperties, NodeMetadata, NodeShape } from '@/types'
import { sketchService } from '@/api/sketch-service'
@@ -37,6 +40,7 @@ import { Circle, Square, Triangle, Hexagon } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { TagsInput } from '@/components/ui/tags-input'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { MinimalTiptapEditor } from '@/components/analyses/editor/minimal-tiptap'
import type { Content } from '@tiptap/react'
@@ -262,12 +266,21 @@ StatusCodeBadge.displayName = 'StatusCodeBadge'
const DetailsPanel = memo(() => {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const nodesLength = useGraphStore((s) => s.nodesLength)
const node = useGraphStore((s) => s.getCurrentNode())
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
const updateNode = useGraphStore((s) => s.updateNode)
const [openIconPicker, setOpenIconPicker] = useState(false)
const { actionItems } = useActionItems()
const currentNodeType = findActionItemByKey(node!.nodeType, actionItems)
const getNodePropertyType = (propertyName: string): FieldType | undefined => {
const field = currentNodeType?.fields.find((f: FormField) => f.name === propertyName);
return field?.type;
}
const [formData, setFormData] = useState<FormData>({
nodeLabel: '',
nodeColor: null,
@@ -410,7 +423,7 @@ const DetailsPanel = memo(() => {
)
const handlePropertyBlur = useCallback(
(key: string, value: string | boolean) => {
(key: string, value: string | string[] | boolean) => {
const newFd = {
...formDataRef.current,
nodeProperties: { ...formDataRef.current.nodeProperties, [key]: value }
@@ -452,6 +465,15 @@ const DetailsPanel = memo(() => {
[saveState]
)
const copyField = (value: any): string | undefined => {
if (typeof value === 'string')
return value
else if (Array.isArray(value) && value.length > 0)
return value.join(',')
else
return undefined
}
// ── Empty state ─────────────────────────────────────────────────────────────
if (!node) {
@@ -473,13 +495,21 @@ const DetailsPanel = memo(() => {
{/* Hero */}
<div className="px-6 pt-5 pb-4 space-y-3 shrink-0">
<div className="flex items-start gap-3">
<button
onClick={() => setOpenIconPicker(true)}
className="shrink-0 mt-1.5 hover:opacity-70 transition-opacity"
>
<IconComponent size={24} />
</button>
<TitleInput value={formData.nodeLabel} onChange={handleLabelCommit} />
{canEdit ? (
<button
onClick={() => setOpenIconPicker(true)}
className="shrink-0 mt-1.5 hover:opacity-70 transition-opacity"
>
<IconComponent size={24} />
</button>
) : (
<div className="shrink-0 mt-1.5"><IconComponent size={24} /></div>
)}
{canEdit ? (
<TitleInput value={formData.nodeLabel} onChange={handleLabelCommit} />
) : (
<span className="min-w-0 flex-1 text-2xl font-bold truncate">{formData.nodeLabel}</span>
)}
<div className="shrink-0 mt-0.5">
<NodeActions node={node} />
</div>
@@ -490,14 +520,16 @@ const DetailsPanel = memo(() => {
</div>
{/* Enrich CTA */}
<div className="px-6 pb-4 shrink-0">
<LaunchFlow values={[node.id]} type={node.nodeType}>
<Button className="rounded-full h-8 gap-1.5 px-4 text-sm" size="sm">
<Rocket className="size-3.5" strokeWidth={1.7} />
Enrich
</Button>
</LaunchFlow>
</div>
{canEdit && (
<div className="px-6 pb-4 shrink-0">
<LaunchFlow values={[node.id]} type={node.nodeType}>
<Button className="rounded-full h-8 gap-1.5 px-4 text-sm" size="sm">
<Rocket className="size-3.5" strokeWidth={1.7} />
Enrich
</Button>
</LaunchFlow>
</div>
)}
{/* Tabs */}
<Tabs defaultValue="properties" className="flex-1 min-h-0 flex flex-col overflow-hidden gap-0">
@@ -521,12 +553,14 @@ const DetailsPanel = memo(() => {
>
Relations
</TabsTrigger>
<TabsTrigger
value="appearance"
className="flex-1 rounded-none border-0 border-b-2 border-transparent data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none text-xs h-full"
>
Style
</TabsTrigger>
{canEdit && (
<TabsTrigger
value="appearance"
className="flex-1 rounded-none border-0 border-b-2 border-transparent data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none text-xs h-full"
>
Style
</TabsTrigger>
)}
</TabsList>
</div>
@@ -538,14 +572,18 @@ const DetailsPanel = memo(() => {
<PropertyRow
key={key}
label={key}
copyValue={typeof value === 'string' ? value : undefined}
copyValue={copyField(value)}
>
{typeof value === 'boolean' ? (
<Switch
checked={value}
onCheckedChange={(checked) => handlePropertyBlur(key, checked)}
className="scale-75"
/>
canEdit ? (
<Switch
checked={value}
onCheckedChange={(checked) => handlePropertyBlur(key, checked)}
className="scale-75"
/>
) : (
<span className="text-muted-foreground">{value ? 'Yes' : 'No'}</span>
)
) : typeof value === 'number' ? (
<StatusCodeBadge statusCode={value} />
) : typeof value === 'string' && value.startsWith('https://') ? (
@@ -560,11 +598,24 @@ const DetailsPanel = memo(() => {
</a>
) : value && typeof value === 'object' && value.constructor === Object ? (
<PopoverProperty label={key} property={value as object} />
) : (
) : getNodePropertyType(key) === 'list' ? (
canEdit ? (
<TagsInput
value={value || []}
onChange={(tags) => handlePropertyBlur(key, tags)}
orientation='vertical'
placeholder={value?.length === 0 ? "Empty" : `Enter ${key.toLowerCase()}`}
/>
) : (
<span className="text-muted-foreground truncate">{Array.isArray(value) ? value.join(', ') : formatValue(value)}</span>
)
) : canEdit ? (
<PropertyInput
value={String(value || '')}
onBlur={(val) => handlePropertyBlur(key, val)}
/>
) : (
<span className="text-muted-foreground truncate">{formatValue(value)}</span>
)}
</PropertyRow>
))
@@ -577,12 +628,13 @@ const DetailsPanel = memo(() => {
<div className="min-h-[120px]">
<MinimalTiptapEditor
value={formData.notes}
onChange={handleNotesChange}
onChange={canEdit ? handleNotesChange : undefined}
output="html"
placeholder="Write something..."
placeholder={canEdit ? "Write something..." : ""}
showToolbar={false}
editorContentClassName="px-6 py-3 !max-w-none prose-sm"
immediatelyRender={false}
editable={canEdit}
/>
</div>
</CollapsibleSection>

View File

@@ -6,6 +6,7 @@ import { useParams } from '@tanstack/react-router'
import { toast } from 'sonner'
import { useIcon } from '@/hooks/use-icon'
import { useConfirm } from '@/components/use-confirm-dialog'
import { usePermissions } from '@/hooks/use-can'
import { sketchService } from '@/api/sketch-service'
import { CopyButton } from '@/components/copy'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -14,6 +15,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
const EdgeDetailsPanel = memo(() => {
const { canEdit } = usePermissions()
const { id: sketchId } = useParams({ strict: false })
const nodes = useGraphStore((s) => s.nodes)
const setCurrentNodeId = useGraphStore((s) => s.setCurrentNodeId)
@@ -146,29 +148,32 @@ const EdgeDetailsPanel = memo(() => {
defaultValue={edge.label || 'Relationship'}
onChange={handleInputChange}
onFocus={(e) => e.stopPropagation()}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onBlur={canEdit ? handleBlur : undefined}
onKeyDown={canEdit ? handleKeyDown : undefined}
readOnly={!canEdit}
/>
<div className="grow" />
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
className="h-6 w-6 p-0 hover:bg-muted opacity-70 hover:opacity-100 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete relationship</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{canEdit && (
<div className="flex items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
className="h-6 w-6 p-0 hover:bg-muted opacity-70 hover:opacity-100 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete relationship</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
{/* Content */}

View File

@@ -9,6 +9,7 @@ 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 { TagsInput } from '@/components/ui/tags-input'
import {
Select,
SelectContent,
@@ -120,6 +121,11 @@ export function DynamicForm({
})
: z.record(z.string(), z.string()).optional()
break
case 'list':
schemaMap[field.name] = field.required
? z.array(z.string()).min(1, { message: `${field.label} is required` })
: z.array(z.string()).optional().default([])
break
default:
schemaMap[field.name] = field.required
? z.string().min(1, { message: `${field.label} is required` })
@@ -167,6 +173,8 @@ export function DynamicForm({
defaults[field.name] = ''
} else if (field.type === 'metadata') {
defaults[field.name] = {}
} else if (field.type === 'list') {
defaults[field.name] = []
} else {
defaults[field.name] = ''
}
@@ -244,6 +252,36 @@ export function DynamicForm({
</div>
)
case 'list':
return (
<div className="space-y-2" key={field.name}>
<div className="flex items-center">
<Label htmlFor={field.name} className="text-sm font-medium">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{FieldDescription}
</div>
<Controller
control={control}
name={field.name}
defaultValue={Array.isArray(initialData?.[field.name]) ? initialData?.[field.name] : []}
render={({ field: { onChange, value, ref } }) => (
<TagsInput
value={Array.isArray(value) ? value : []}
onChange={(tags) => onChange(tags)}
placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`}
orientation="vertical"
variant='full'
/>
)}
/>
{errors[field.name] && (
<p className="text-xs text-destructive">{String(errors[field.name]?.message)}</p>
)}
</div>
)
case 'textarea':
return (
<div className="space-y-2" key={field.name}>

View File

@@ -10,6 +10,7 @@ import { type GraphNode, GraphEdge } from '@/types'
import { useLinkCreation } from '../hooks/use-link-creation'
import { useQuickAdd } from '../hooks/use-quick-add'
import { QuickAddOverlay } from './quick-add-overlay'
import { usePermissions } from '@/hooks/use-can'
type BaseContextMenuProps = {
rawTop: number
@@ -39,6 +40,7 @@ type BackgroundContextMenuProps = BaseContextMenuProps & {
const GraphMain = () => {
const { id: sketchId } = useParams({ strict: false })
const { canEdit, canCreate } = usePermissions()
const filteredNodes = useGraphStore((s) => s.filteredNodes)
const filteredEdges = useGraphStore((s) => s.filteredEdges)
const toggleNodeSelection = useGraphStore((s) => s.toggleNodeSelection)
@@ -89,7 +91,7 @@ const GraphMain = () => {
const now = Date.now()
if (event && now - lastBgClickRef.current < 400) {
lastBgClickRef.current = 0
if (graphRef.current && containerRef.current) {
if (canCreate && graphRef.current && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
const sx = event.clientX - rect.left
const sy = event.clientY - rect.top
@@ -232,14 +234,14 @@ const GraphMain = () => {
)
const linkCreationProp = useMemo(
() => ({
() => canCreate ? ({
shiftHeld,
sourceNode: linkCreation.sourceNode,
onStartLinking: startLinking,
onCompleteLinking: handleCompleteLinking,
onCancel: cancelLinkCreation
}),
[shiftHeld, linkCreation.sourceNode, startLinking, handleCompleteLinking, cancelLinkCreation]
}) : undefined,
[canCreate, shiftHeld, linkCreation.sourceNode, startLinking, handleCompleteLinking, cancelLinkCreation]
)
const handleGraphRef = useCallback((ref: any) => {
@@ -259,7 +261,7 @@ const GraphMain = () => {
showLabels={true}
showIcons={true}
onGraphRef={handleGraphRef}
enableNodeDrag={!shiftHeld}
enableNodeDrag={canEdit && !shiftHeld}
allowLasso
sketchId={sketchId}
linkCreation={linkCreationProp}

View File

@@ -30,6 +30,7 @@ import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { useConfirm } from '@/components/use-confirm-dialog'
import { toast } from 'sonner'
import { sketchService } from '@/api/sketch-service'
import { usePermissions } from '@/hooks/use-can'
interface GraphContextMenuProps {
nodes: GraphNode[]
@@ -61,6 +62,7 @@ export default function BackgroundContextMenu({
...props
}: GraphContextMenuProps) {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const [activeTab, setActiveTab] = useState('enrichers')
const [enrichersSearchQuery, setEnrichersSearchQuery] = useState('')
const [flowsSearchQuery, setFlowsSearchQuery] = useState('')
@@ -167,26 +169,28 @@ export default function BackgroundContextMenu({
{isSameType && <span className="block">- {sharedType}</span>}
</div>
<div className="grow" />
<div>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="sm"
className="h-6 p-0 hover:bg-muted opacity-70 hover:opacity-100 text-destructive hover:text-destructive"
onClick={handleDeleteNodes}
>
<Trash2 className="h-3 w-3" strokeWidth={1.5} /> Delete{' '}
{selectedNodeIds.length}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete {selectedNodeIds.length} node(s)</p>
</TooltipContent>
</Tooltip>
</div>
{canEdit && (
<div>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="sm"
className="h-6 p-0 hover:bg-muted opacity-70 hover:opacity-100 text-destructive hover:text-destructive"
onClick={handleDeleteNodes}
>
<Trash2 className="h-3 w-3" strokeWidth={1.5} /> Delete{' '}
{selectedNodeIds.length}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete {selectedNodeIds.length} node(s)</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
{/* Tabs */}
@@ -377,6 +381,7 @@ export default function BackgroundContextMenu({
}
const DefaultBackgroundMenu = memo(() => {
const { canCreate } = usePermissions()
const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog)
const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen)
@@ -388,6 +393,14 @@ const DefaultBackgroundMenu = memo(() => {
setImportModalOpen(true)
}, [setImportModalOpen])
if (!canCreate) {
return (
<div className="flex-1 grow text-sm overflow-hidden min-h-0 p-3">
<p className="text-muted-foreground text-xs">View only</p>
</div>
)
}
return (
<div className="flex-1 grow text-sm overflow-hidden min-h-0">
<button

View File

@@ -10,6 +10,7 @@ import { useGraphStore } from '@/stores/graph-store'
import { useConfirm } from '@/components/use-confirm-dialog'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { usePermissions } from '@/hooks/use-can'
interface EdgeContextMenuProps {
edge?: GraphEdge
@@ -44,6 +45,7 @@ export default function EdgeContextMenu({
...props
}: EdgeContextMenuProps) {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const removeEdges = useGraphStore((s) => s.removeEdges)
const updateEdge = useGraphStore((s) => s.updateEdge)
const clearSelectedEdges = useGraphStore((s) => s.clearSelectedEdges)
@@ -173,8 +175,9 @@ export default function EdgeContextMenu({
e.stopPropagation()
if (onSubmitNew) e.currentTarget.select()
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onBlur={canEdit ? handleBlur : undefined}
onKeyDown={canEdit ? handleKeyDown : undefined}
readOnly={!canEdit}
autoFocus
/>
</div>
@@ -184,9 +187,11 @@ export default function EdgeContextMenu({
{!isMultiSelect && edge && (
<CopyButton size="icon" content={edge.label} className="h-7 w-7" />
)}
<Button onClick={handleDelete} size="icon" variant="ghost" className="h-7 w-7">
<Trash2 className="h-3.5! w-3.5! opacity-50" />
</Button>
{canEdit && (
<Button onClick={handleDelete} size="icon" variant="ghost" className="h-7 w-7">
<Trash2 className="h-3.5! w-3.5! opacity-50" />
</Button>
)}
</div>
</div>
</BaseContextMenu>

View File

@@ -1,4 +1,4 @@
import React, { memo, useCallback, useMemo, useState } from 'react'
import React, { memo, useCallback, useState } from 'react'
import { enricherService } from '@/api/enricher-service'
import { flowService } from '@/api/flow-service'
import { useQuery } from '@tanstack/react-query'
@@ -25,6 +25,7 @@ import NodeActions from '../node/actions/node-actions'
import BaseContextMenu from '@/components/xyflow/context-menu'
import { useGraphStore } from '@/stores/graph-store'
import { CopyButton } from '@/components/copy'
import { usePermissions } from '@/hooks/use-can'
interface GraphContextMenuProps {
node: GraphNode
@@ -56,6 +57,7 @@ export default function ContextMenu({
...props
}: GraphContextMenuProps) {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const [activeTab, setActiveTab] = useState('enrichers')
const [enrichersSearchQuery, setEnrichersSearchQuery] = useState('')
const [flowsSearchQuery, setFlowsSearchQuery] = useState('')
@@ -133,24 +135,26 @@ export default function ContextMenu({
</div>
</div>
{/* Tabs */}
<div className="px-3 py-2 border-b border-border shrink-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full h-9">
<TabsTrigger value="enrichers" className="flex-1" onClick={(e) => e.stopPropagation()}>
<Zap className="h-3 w-3 mr-1" />
Enrichers
</TabsTrigger>
<TabsTrigger value="flows" className="flex-1" onClick={(e) => e.stopPropagation()}>
<FileCode2 className="h-3 w-3 mr-1" />
Flows
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Tabs - editor+ only */}
{canEdit && (
<div className="px-3 py-2 border-b border-border shrink-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full h-9">
<TabsTrigger value="enrichers" className="flex-1" onClick={(e) => e.stopPropagation()}>
<Zap className="h-3 w-3 mr-1" />
Enrichers
</TabsTrigger>
<TabsTrigger value="flows" className="flex-1" onClick={(e) => e.stopPropagation()}>
<FileCode2 className="h-3 w-3 mr-1" />
Flows
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{/* Tab Content */}
<Tabs value={activeTab} className="flex-1 flex flex-col min-h-0">
{canEdit && <Tabs value={activeTab} className="flex-1 flex flex-col min-h-0">
{/* Enrichers Tab */}
<TabsContent value="enrichers" className="flex-1 flex flex-col min-h-0 mt-0">
{/* Enrichers Search */}
@@ -302,7 +306,7 @@ export default function ContextMenu({
)}
</div>
</TabsContent>
</Tabs>
</Tabs>}
</BaseContextMenu>
)
}

View File

@@ -202,8 +202,12 @@ export const renderLink = ({
textAngle = Math.atan2(sdy, sdx)
}
if (textAngle > CONSTANTS.HALF_PI || textAngle < -CONSTANTS.HALF_PI) {
textAngle += textAngle > 0 ? -CONSTANTS.PI : CONSTANTS.PI
const linkLabelHorizontal = forceSettings?.linkLabelHorizontal?.value ?? false
if (!linkLabelHorizontal) {
if (textAngle > CONSTANTS.HALF_PI || textAngle < -CONSTANTS.HALF_PI) {
textAngle += textAngle > 0 ? -CONSTANTS.PI : CONSTANTS.PI
}
}
const linkLabelSetting = forceSettings?.linkLabelFontSize?.value ?? 50
@@ -221,7 +225,9 @@ export const renderLink = ({
ctx.save()
ctx.translate(tempPos.x, tempPos.y)
ctx.rotate(textAngle)
if (!linkLabelHorizontal) {
ctx.rotate(textAngle)
}
const borderRadius = linkFontSize * 0.1
ctx.beginPath()

View File

@@ -20,6 +20,7 @@ import { useLayoutStore } from '@/stores/layout-store'
import { GraphNode } from '@/types'
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/use-can'
const flagColors = {
red: 'text-red-400 fill-red-200',
@@ -34,6 +35,7 @@ type FlagColor = keyof typeof flagColors
const NodeActions = memo(
({ node, setMenu }: { node: GraphNode; setMenu?: (menu: any | null) => void }) => {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const { confirm } = useConfirm()
const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog)
const setRelatedNodeToAdd = useGraphStore((state) => state.setRelatedNodeToAdd)
@@ -106,6 +108,8 @@ const NodeActions = memo(
[node.id, flagValue, updateNode, sketchId]
)
if (!canEdit) return null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -6,8 +6,10 @@ import { ItemsPanel } from './items-panel'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { useLayoutStore } from '@/stores/layout-store'
import SelectedItemsPanel from './selected-items-panel'
import { usePermissions } from '@/hooks/use-can'
const GraphNavigation = () => {
const { canEdit } = usePermissions()
const nodes = useGraphStore((s) => s.nodes)
const activeTab = useLayoutStore((s) => s.activeTab)
const setActiveTab = useLayoutStore((s) => s.setActiveTab)
@@ -25,9 +27,11 @@ const GraphNavigation = () => {
<TabsTrigger value="entities">
<Users className="h-3 w-3 opacity-60" /> Entities
</TabsTrigger>
<TabsTrigger value="items">
<UserPlus className="h-3 w-3 opacity-60" /> Add
</TabsTrigger>
{canEdit && (
<TabsTrigger value="items">
<UserPlus className="h-3 w-3 opacity-60" /> Add
</TabsTrigger>
)}
</TabsList>
<TabsContent
value="entities"
@@ -61,9 +65,11 @@ const GraphNavigation = () => {
</ResizablePanel>
</ResizablePanelGroup>
</TabsContent>
<TabsContent value="items" className="my-0 grow h-full overflow-hidden min-h-0">
<ItemsPanel />
</TabsContent>
{canEdit && (
<TabsContent value="items" className="my-0 grow h-full overflow-hidden min-h-0">
<ItemsPanel />
</TabsContent>
)}
</Tabs>
</div>
)

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { GraphNode } from '@/types'
import { Checkbox } from '@/components/ui/checkbox'
import { usePermissions } from '@/hooks/use-can'
const ITEM_HEIGHT = 40
// Mémoiser le composant NodeRenderer
@@ -31,7 +32,8 @@ const NodeRenderer = memo(
isNodeChecked,
isCurrent,
centerOnNode,
autoZoomOnCurrentNode
autoZoomOnCurrentNode,
canEdit
}: {
node: GraphNode
setCurrentNodeId: (nodeId: string | null) => void
@@ -40,6 +42,7 @@ const NodeRenderer = memo(
isCurrent: (nodeId: string) => boolean
centerOnNode: (x: number, y: number) => void
autoZoomOnCurrentNode: boolean
canEdit: boolean
}) => {
const handleClick = useCallback(() => {
setCurrentNodeId(node.id)
@@ -65,13 +68,15 @@ const NodeRenderer = memo(
isCurrent(node.id) && 'bg-muted/70'
)}
>
<div className="pl-2">
<Checkbox
checked={isNodeChecked(node.id)}
onCheckedChange={handleCheckboxChange}
className="mr-1 border-border"
/>
</div>
{canEdit && (
<div className="pl-2">
<Checkbox
checked={isNodeChecked(node.id)}
onCheckedChange={handleCheckboxChange}
className="mr-1 border-border"
/>
</div>
)}
<button
className={cn(
'flex-1 flex truncate mt-0 text-sm font-medium overflow-hidden duration-0 items-center justify-start p-4 !py-5 rounded-none text-left h-full'
@@ -95,7 +100,8 @@ const VirtualizedItem = memo(
isNodeChecked,
isCurrent,
centerOnNode,
autoZoomOnCurrentNode
autoZoomOnCurrentNode,
canEdit
}: {
index: number
node: GraphNode
@@ -105,6 +111,7 @@ const VirtualizedItem = memo(
isCurrent: (nodeId: string) => boolean
centerOnNode: (x: number, y: number) => void
autoZoomOnCurrentNode: boolean
canEdit: boolean
}) => {
return (
<NodeRenderer
@@ -115,12 +122,14 @@ const VirtualizedItem = memo(
isNodeChecked={isNodeChecked}
centerOnNode={centerOnNode}
autoZoomOnCurrentNode={autoZoomOnCurrentNode}
canEdit={canEdit}
/>
)
}
)
const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?: boolean }) => {
const { canEdit } = usePermissions()
const currentNodeId = useGraphStore((state) => state.currentNodeId)
const setCurrentNodeId = useGraphStore((state) => state.setCurrentNodeId)
const setSelectedNodes = useGraphStore((state) => state.setSelectedNodes)
@@ -231,9 +240,11 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?:
{/* Header fixe */}
<div className="sticky border-b top-0 p-2 bg-card z-10 shrink-0">
<div className="flex items-center gap-2">
<div>
<Checkbox onCheckedChange={handleCheckAll} className="mr-1" />
</div>
{canEdit && (
<div>
<Checkbox onCheckedChange={handleCheckAll} className="mr-1" />
</div>
)}
<Badge className="h-7" variant={'outline'}>
<span className="font-semibold">
{filters?.length || searchQuery
@@ -369,6 +380,7 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?:
isNodeChecked={isNodeChecked}
centerOnNode={centerOnNode}
autoZoomOnCurrentNode={autoZoomOnCurrentNode}
canEdit={canEdit}
/>
</div>
)

View File

@@ -15,6 +15,8 @@ import LaunchFlow from '../../launch-enricher'
import { TypeBadge } from '@/components/type-badge'
import { useParams } from '@tanstack/react-router'
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { usePermissions } from '@/hooks/use-can'
const SelectedItemsPanel = () => {
const selectedNodes = useGraphStore((s) => s.selectedNodes)
@@ -76,6 +78,7 @@ export const SelectedList = () => {
const ActionBar = () => {
const { id: sketchId } = useParams({ strict: false })
const { canEdit } = usePermissions()
const { confirm } = useConfirm()
const selectedNodes = useGraphStore((s) => s.selectedNodes)
const removeNodes = useGraphStore((s) => s.removeNodes)
@@ -129,7 +132,7 @@ const ActionBar = () => {
return (
<div className="flex items-center gap-1">
<TooltipProvider>
{Boolean(settings?.general?.showFlow?.value) &&
{canEdit && Boolean(settings?.general?.showFlow?.value) &&
<Tooltip>
<TooltipTrigger asChild>
<div>
@@ -147,47 +150,51 @@ const ActionBar = () => {
<p>Ask AI</p>
</TooltipContent>
</Tooltip>}
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="sm"
disabled={selectedNodes.length === 0}
className="h-6 w-6 p-0 relative text-destructive hover:text-destructive"
onClick={handleDeleteNodes}
>
<Trash2 className="h-3 w-3" strokeWidth={1.5} />
{selectedNodes.length > 0 && (
<span className="absolute -top-2 -right-2 z-50 bg-primary text-white text-[10px] rounded-full w-auto min-w-4.5 h-4.5 p-1 flex items-center justify-center">
{selectedNodes.length}
</span>
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete {selectedNodes.length} nodes</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="ml-2">
<LaunchFlow disabled={!shareSameType} values={values} type={type}>
<Button disabled={!shareSameType} size={'sm'} className="rounded-full h-7">
<Rocket className="h-3 w-3" strokeWidth={2} />({selectedNodes.length})
{canEdit && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="sm"
disabled={selectedNodes.length === 0}
className="h-6 w-6 p-0 relative text-destructive hover:text-destructive"
onClick={handleDeleteNodes}
>
<Trash2 className="h-3 w-3" strokeWidth={1.5} />
{selectedNodes.length > 0 && (
<span className="absolute -top-2 -right-2 z-50 bg-primary text-white text-[10px] rounded-full w-auto min-w-4.5 h-4.5 p-1 flex items-center justify-center">
{selectedNodes.length}
</span>
)}
</Button>
</LaunchFlow>
</div>
</TooltipTrigger>
<TooltipContent>
{!shareSameType ? (
<p>All selected items are not the same type</p>
) : (
<p>Launch enricher</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete {selectedNodes.length} nodes</p>
</TooltipContent>
</Tooltip>
)}
{canEdit && (
<Tooltip>
<TooltipTrigger asChild>
<div className="ml-2">
<LaunchFlow disabled={!shareSameType} values={values} type={type}>
<Button disabled={!shareSameType} size={'sm'} className="rounded-full h-7">
<Rocket className="h-3 w-3" strokeWidth={2} />({selectedNodes.length})
</Button>
</LaunchFlow>
</div>
</TooltipTrigger>
<TooltipContent>
{!shareSameType ? (
<p>All selected items are not the same type</p>
) : (
<p>Launch enricher</p>
)}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div>

View File

@@ -19,6 +19,7 @@ import Settings, { KeyboardShortcuts } from './settings/settings'
import { type GraphNode, type GraphEdge } from '@/types'
import { MergeDialog } from './graph/actions/merge-nodes'
import { useGraphRefresh } from '@/hooks/use-graph-refresh'
import { usePermissions } from '@/hooks/use-can'
const RelationshipsTable = lazy(() => import('@/components/table/relationships-view'))
// Separate component for the drag overlay
@@ -43,6 +44,7 @@ interface GraphPanelProps {
}
const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => {
const { canCreate } = usePermissions()
const handleOpenFormModal = useGraphStore((s) => s.handleOpenFormModal)
const view = useGraphControls((s) => s.view)
const updateGraphData = useGraphStore((s) => s.updateGraphData)
@@ -120,10 +122,10 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => {
return (
<div
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragOver={canCreate ? handleDragOver : undefined}
onDragEnter={canCreate ? handleDragEnter : undefined}
onDragLeave={canCreate ? handleDragLeave : undefined}
onDrop={canCreate ? handleDrop : undefined}
className="h-full w-full flex flex-col relative outline-2 outline-transparent bg-background"
>
<Toolbar isLoading={isLoading} />

View File

@@ -34,6 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNodesDisplaySettings, ITEM_TYPES, ItemType } from '@/stores/node-display-settings'
import IconPicker, { NodeIconTrigger } from '@/components/shared/icon-picker'
import { ColorPicker } from '@/components/shared/color-picker'
import { usePermissions } from '@/hooks/use-can'
// SettingItem Components
interface SettingItemProps {
@@ -421,6 +422,7 @@ function DynamicSection({ categoryId, category, title, description }: DynamicSec
}
export default function GlobalSettings() {
const { canEdit } = usePermissions()
const settingsModalOpen = useGraphSettingsStore((s) => s.settingsModalOpen)
const setSettingsModalOpen = useGraphSettingsStore((s) => s.setSettingsModalOpen)
const settings = useGraphSettingsStore((s) => s.settings)
@@ -567,7 +569,7 @@ export default function GlobalSettings() {
label="Title"
description="The name of your sketch"
value={formData.title}
onValueChange={(value) => handleInputChange('title', value)}
onValueChange={canEdit ? (value) => handleInputChange('title', value) : () => {}}
placeholder="Enter sketch title"
/>
@@ -575,7 +577,7 @@ export default function GlobalSettings() {
label="Description"
description="A brief description of what this sketch represents"
value={formData.description}
onValueChange={(value) => handleInputChange('description', value)}
onValueChange={canEdit ? (value) => handleInputChange('description', value) : () => {}}
placeholder="Enter sketch description"
rows={4}
/>
@@ -584,7 +586,7 @@ export default function GlobalSettings() {
label="Status"
description="The current status of this sketch"
value={formData.status}
onValueChange={(value) => handleInputChange('status', value)}
onValueChange={canEdit ? (value) => handleInputChange('status', value) : () => {}}
options={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
@@ -594,21 +596,23 @@ export default function GlobalSettings() {
/>
</div>
<div className="flex gap-3 pt-6">
<SheetClose asChild>
<Button variant="outline" type="button" className="flex-1 shadow-none">
Cancel
{canEdit && (
<div className="flex gap-3 pt-6">
<SheetClose asChild>
<Button variant="outline" type="button" className="flex-1 shadow-none">
Cancel
</Button>
</SheetClose>
<Button
type="submit"
disabled={updateMutation.isPending}
onClick={handleSubmit}
className="flex-1 shadow-none"
>
{updateMutation.isPending ? 'Saving...' : 'Save changes'}
</Button>
</SheetClose>
<Button
type="submit"
disabled={updateMutation.isPending}
onClick={handleSubmit}
className="flex-1 shadow-none"
>
{updateMutation.isPending ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -27,6 +27,7 @@ import { Separator } from '../ui/separator'
import { ViewToggle } from './view-toggle'
import { NetworkIcon } from '../icons/network'
import { useKeyboard } from '@/hooks/use-keyboard'
import { usePermissions } from '@/hooks/use-can'
import {
DropdownMenu,
DropdownMenuContent,
@@ -105,6 +106,7 @@ const FloatingBar = ({
export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean }) {
const { confirm } = useConfirm()
const { canEdit } = usePermissions()
const { id: sketchId } = useParams({ strict: false })
const view = useGraphControls((s) => s.view)
const setView = useGraphControls((s) => s.setView)
@@ -328,14 +330,14 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
icon={<GitPullRequestArrow className="h-4 w-4 opacity-70" />}
tooltip="Connect"
onClick={handleOpenAddRelationDialog}
disabled={!areExactlyTwoSelected}
disabled={!canEdit || !areExactlyTwoSelected}
badge={areExactlyTwoSelected ? 2 : null}
/>
<ToolbarButton
icon={<Merge className="h-4 w-4 opacity-70" />}
tooltip="Merge"
onClick={handleOpenMergeDialog}
disabled={!areMergeable}
disabled={!canEdit || !areMergeable}
badge={areMergeable ? selectedNodes.length : null}
/>
<PathFinder />
@@ -344,13 +346,13 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean
icon={<NetworkIcon className="h-4 w-4 opacity-70" />}
tooltip="Force layout"
onClick={handleApplyForceLayout}
disabled={isLoading || view !== 'graph'}
disabled={!canEdit || isLoading || view !== 'graph'}
/>
<ToolbarButton
icon={<GitFork strokeWidth={1.4} className="h-4 w-4 opacity-70 rotate-180" />}
tooltip="Hierarchy layout"
onClick={handleApplyHierarchyLayout}
disabled={isLoading || view !== 'graph'}
disabled={!canEdit || isLoading || view !== 'graph'}
/>
</FloatingBar>
)}

View File

@@ -16,6 +16,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { CopyButton } from '../copy'
import { GraphNode } from '@/types'
import { formatDistanceToNow } from 'date-fns'
import { usePermissions } from '@/hooks/use-can'
export type RelationshipType = {
source: GraphNode
@@ -31,13 +32,15 @@ interface NodeItemProps {
onNodeClick: (node: GraphNode) => void
isSelected: boolean
onSelectionChange: (node: GraphNode, checked: boolean) => void
canEdit: boolean
}
const NodeItem = memo(function NodeItem({
node,
onNodeClick,
isSelected,
onSelectionChange
onSelectionChange,
canEdit
}: NodeItemProps) {
const SourceIcon = useIcon(node.nodeType, {
nodeColor: node.nodeColor,
@@ -77,13 +80,15 @@ const NodeItem = memo(function NodeItem({
<div
className="grid items-center h-14 gap-3 text-sm border-b last:border-b-0"
style={{
gridTemplateColumns: '24px 32px auto 1fr 140px 160px 32px'
gridTemplateColumns: canEdit ? '24px 32px auto 1fr 140px 160px 32px' : '32px auto 1fr 140px 160px 32px'
}}
>
{/* Checkbox */}
<div className="flex items-center">
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} />
</div>
{canEdit && (
<div className="flex items-center">
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} />
</div>
)}
{/* Icon */}
<div className="flex items-center justify-center">
@@ -139,6 +144,7 @@ type NodesTableProps = {
nodes: GraphNode[]
}
export default function NodesTable({ nodes }: NodesTableProps) {
const { canEdit } = usePermissions()
const [searchQuery, setSearchQuery] = useState('')
const [selectedType, setSelectedType] = useState<string>('all')
const parentRef = useRef<HTMLDivElement>(null)
@@ -284,19 +290,21 @@ export default function NodesTable({ nodes }: NodesTableProps) {
{/* Table Header */}
<div
className="grid items-center h-11 px-4 bg-muted/50 p-2 rounded-t-md border text-sm font-medium text-muted-foreground"
style={{ gridTemplateColumns: '24px 32px auto 1fr 140px 160px 32px' }}
style={{ gridTemplateColumns: canEdit ? '24px 32px auto 1fr 140px 160px 32px' : '32px auto 1fr 140px 160px 32px' }}
>
<div className="flex items-center">
<Checkbox
checked={isAllSelected}
ref={(el) => {
if (el && 'indeterminate' in el) {
;(el as HTMLInputElement).indeterminate = isIndeterminate
}
}}
onCheckedChange={handleSelectAll}
/>
</div>
{canEdit && (
<div className="flex items-center">
<Checkbox
checked={isAllSelected}
ref={(el) => {
if (el && 'indeterminate' in el) {
;(el as HTMLInputElement).indeterminate = isIndeterminate
}
}}
onCheckedChange={handleSelectAll}
/>
</div>
)}
<div></div> {/* Icon */}
<div className="text-left">Label</div>
<div>Data</div>
@@ -336,6 +344,7 @@ export default function NodesTable({ nodes }: NodesTableProps) {
onNodeClick={onNodeClick}
isSelected={isNodeSelected(node.id)}
onSelectionChange={handleNodeSelectionChange}
canEdit={canEdit}
/>
</div>
)

View File

@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { useConfirm } from '@/components/use-confirm-dialog'
import { toast } from 'sonner'
import { usePermissions } from '@/hooks/use-can'
const ITEM_HEIGHT = 67 // Balanced spacing between items (55px card + 12px padding)
@@ -34,6 +35,7 @@ interface RelationshipItemProps {
onNodeClick: (node: GraphNode) => void
isSelected: boolean
onSelectionChange: (edge: GraphEdge, checked: boolean) => void
canEdit: boolean
}
function RelationshipItem({
@@ -41,7 +43,8 @@ function RelationshipItem({
style,
onNodeClick,
isSelected,
onSelectionChange
onSelectionChange,
canEdit
}: RelationshipItemProps) {
const SourceIcon = useIcon(relationship.source.nodeType, {
nodeColor: relationship.source.nodeColor,
@@ -73,9 +76,11 @@ function RelationshipItem({
<Card className="h-[55px] p-0 rounded-md">
<CardContent className="p-3 h-[55px] flex items-center gap-3 min-w-0">
{/* Checkbox */}
<div className="flex items-center shrink-0">
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} />
</div>
{canEdit && (
<div className="flex items-center shrink-0">
<Checkbox checked={isSelected} onCheckedChange={handleCheckboxChange} />
</div>
)}
{/* Source Node */}
<div className="flex items-center gap-2 flex-1 min-w-0 max-w-[35%]">
@@ -143,6 +148,7 @@ export default function RelationshipsTable() {
queryFn: () => sketchService.getGraphDataById(sketchId as string, true)
})
const { canEdit } = usePermissions()
const [searchQuery, setSearchQuery] = useState('')
const [selectedType, setSelectedType] = useState<string>('all')
const parentRef = useRef<HTMLDivElement>(null)
@@ -389,7 +395,7 @@ export default function RelationshipsTable() {
</div>
{/* Selection Controls */}
{filteredRelationships.length > 0 && (
{canEdit && filteredRelationships.length > 0 && (
<div className="flex items-center gap-4 mb-2">
<div className="flex items-center gap-2">
<Checkbox
@@ -434,6 +440,7 @@ export default function RelationshipsTable() {
onNodeClick={onNodeClick}
isSelected={isEdgeSelected(relationship.edge.id)}
onSelectionChange={handleEdgeSelectionChange}
canEdit={canEdit}
style={{
position: 'absolute',
top: 0,

View File

@@ -1,7 +1,24 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
import { getDisplayName, getInitials } from '@/lib/user-display'
import type { Profile } from '@/types'
import type { User } from '@/stores/auth-store'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
type UserLike = (Partial<Profile> & Partial<User>) | null | undefined
const SIZES = {
xs: { root: 'size-5', text: 'text-[10px]' },
sm: { root: 'size-6', text: 'text-[10px]' },
md: { root: 'size-7', text: 'text-xs' },
lg: { root: 'size-8', text: 'text-sm' },
xl: { root: 'size-10', text: 'text-base' },
} as const
type AvatarSize = keyof typeof SIZES
// --- Primitives (re-exported for non-user edge cases like tool-card) ---
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
@@ -35,4 +52,66 @@ function AvatarFallback({
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
// --- Domain components ---
interface UserAvatarProps {
user: UserLike
size?: AvatarSize
className?: string
}
function UserAvatar({ user, size = 'lg', className }: UserAvatarProps) {
const s = SIZES[size]
return (
<Avatar className={cn(s.root, className)}>
{user?.avatar_url && <AvatarImage src={user.avatar_url} alt={getDisplayName(user)} />}
<AvatarFallback className={s.text}>{getInitials(user)}</AvatarFallback>
</Avatar>
)
}
interface AvatarGroupProps {
users: UserLike[]
size?: AvatarSize
max?: number
className?: string
}
function AvatarGroup({ users, size = 'sm', max, className }: AvatarGroupProps) {
const visible = max != null ? users.slice(0, max) : users
const overflow = max != null ? users.length - max : 0
const s = SIZES[size]
return (
<div className={cn('flex -space-x-1.5', className)}>
{visible.map((user, i) => (
<Tooltip key={user?.id ?? i}>
<TooltipTrigger asChild>
<div>
<UserAvatar
user={user}
size={size}
className="border border-background"
/>
</div>
</TooltipTrigger>
<TooltipContent>{getDisplayName(user)}</TooltipContent>
</Tooltip>
))}
{overflow > 0 && (
<div
className={cn(
s.root,
s.text,
'relative flex shrink-0 items-center justify-center rounded-full border border-background bg-muted font-medium text-muted-foreground'
)}
>
+{overflow}
</div>
)}
</div>
)
}
export { Avatar, AvatarImage, AvatarFallback, UserAvatar, AvatarGroup }

View File

@@ -0,0 +1,105 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface TagsInputProps {
id?: string | undefined;
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
disabled?: boolean;
orientation?: 'vertical' | 'horizontal';
variant?: 'full' | 'compact';
className?: string | undefined;
};
function TagsInput({
id,
value,
onChange,
placeholder,
disabled = false,
orientation = 'vertical',
className,
variant = 'compact',
...props
}: TagsInputProps) {
const [inputValue, setInputValue] = React.useState("");
const addTag = () => {
const trimmed = inputValue.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
setInputValue("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag();
}
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove));
};
return (
<div
className={cn(
"flex items-center gap-2 rounded-md text-sm",
orientation === 'horizontal'
? "flex-wrap items-center gap-2"
: "flex-col items-end gap-2",
disabled && "cursor-not-allowed opacity-50",
className
)}
{...props}
>
{value.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
disabled={disabled}
>
<X className="h-3 w-3" />
<span className="sr-only">Delete tag {tag}</span>
</Button>
</Badge>
))}
{variant === 'compact' ? (
<input
id={id}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
className="w-full bg-transparent outline-none placeholder:text-muted-foreground/30 focus:bg-muted/20 px-1 rounded transition-colors"
placeholder={placeholder ?? 'Empty'}
/>) : (
<Input
id={id}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
placeholder={placeholder}
/>
)}
</div>
);
}
export { TagsInput }

View File

@@ -0,0 +1,77 @@
import { useMemo } from 'react'
import { useLoaderData } from '@tanstack/react-router'
import type { InvestigationRole } from '@/types'
type Action =
| 'read'
| 'create'
| 'update'
| 'delete'
| 'manage'
export type Permissions = {
can: (action: Action) => boolean
canEdit: boolean
canCreate: boolean
canDelete: boolean
canManage: boolean
isViewer: boolean
isOwner: boolean
role: InvestigationRole | null
}
const ROLE_LEVEL: Record<InvestigationRole, number> = {
owner: 4,
admin: 3,
editor: 2,
viewer: 1
}
function canRolePerform(role: InvestigationRole | null, action: Action): boolean {
if (!role) return false
const level = ROLE_LEVEL[role]
switch (action) {
case 'read':
return level >= 1
case 'create':
case 'update':
return level >= 2
case 'manage':
return level >= 3
case 'delete':
return level >= 4
default:
return false
}
}
export function useCan(role: InvestigationRole | null | undefined): Permissions {
return useMemo(() => {
const r = role ?? null
const can = (action: Action) => canRolePerform(r, action)
return {
can,
canEdit: can('update'),
canCreate: can('create'),
canDelete: can('delete'),
canManage: can('manage'),
isViewer: r === 'viewer',
isOwner: r === 'owner',
role: r
}
}, [role])
}
/**
* Read permissions from the $investigationId layout route loader data.
* Works anywhere rendered under that route — including components in
* RootLayout like DetailsPanel, since the route is active when they render.
*/
export function usePermissions(): Permissions {
const role = useLoaderData({
from: '/_auth/dashboard/investigations/$investigationId',
select: (d: any) => d?.investigation?.current_user_role ?? null,
strict: false,
}) as InvestigationRole | null | undefined
return useCan(role)
}

View File

@@ -1,4 +1,4 @@
export type FieldType = "text" | "date" | "email" | "number" | "select" | "textarea" | "hidden" | "tel" | "url" | "metadata"
export type FieldType = "text" | "date" | "email" | "number" | "select" | "textarea" | "hidden" | "tel" | "url" | "metadata" | "list"
export interface SelectOption {
label: string

View File

@@ -0,0 +1,30 @@
import type { Profile } from '@/types'
import type { User } from '@/stores/auth-store'
type UserLike = Partial<Profile> & Partial<User> | null | undefined
/**
* Returns the best display name available:
* username > "first last" > email > "Unknown"
*/
export function getDisplayName(user: UserLike): string {
if (!user) return 'Unknown'
if ('username' in user && user.username) return user.username
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ')
if (fullName) return fullName
if (user.email) return user.email
return 'Unknown'
}
/**
* Returns 1-2 letter initials from the best available name source.
*/
export function getInitials(user: UserLike): string {
if (!user) return '?'
if ('username' in user && user.username) return user.username[0].toUpperCase()
const first = user.first_name?.[0] ?? ''
const last = user.last_name?.[0] ?? ''
if (first || last) return (first + last).toUpperCase()
if (user.email) return user.email[0].toUpperCase()
return '?'
}

View File

@@ -18,9 +18,11 @@ import { Route as AuthDashboardRouteImport } from './routes/_auth.dashboard'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth.dashboard.index'
import { Route as AuthDashboardVaultRouteImport } from './routes/_auth.dashboard.vault'
import { Route as AuthDashboardToolsRouteImport } from './routes/_auth.dashboard.tools'
import { Route as AuthDashboardProfileRouteImport } from './routes/_auth.dashboard.profile'
import { Route as AuthDashboardFlowsIndexRouteImport } from './routes/_auth.dashboard.flows.index'
import { Route as AuthDashboardEnrichersIndexRouteImport } from './routes/_auth.dashboard.enrichers.index'
import { Route as AuthDashboardCustomTypesIndexRouteImport } from './routes/_auth.dashboard.custom-types.index'
import { Route as AuthDashboardInvestigationsInvestigationIdRouteImport } from './routes/_auth.dashboard.investigations.$investigationId'
import { Route as AuthDashboardFlowsFlowIdRouteImport } from './routes/_auth.dashboard.flows.$flowId'
import { Route as AuthDashboardEnrichersNewRouteImport } from './routes/_auth.dashboard.enrichers.new'
import { Route as AuthDashboardEnrichersEnricherIdRouteImport } from './routes/_auth.dashboard.enrichers.$enricherId'
@@ -72,6 +74,11 @@ const AuthDashboardToolsRoute = AuthDashboardToolsRouteImport.update({
path: '/tools',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardProfileRoute = AuthDashboardProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardFlowsIndexRoute = AuthDashboardFlowsIndexRouteImport.update({
id: '/flows/',
path: '/flows/',
@@ -89,6 +96,12 @@ const AuthDashboardCustomTypesIndexRoute =
path: '/custom-types/',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardInvestigationsInvestigationIdRoute =
AuthDashboardInvestigationsInvestigationIdRouteImport.update({
id: '/investigations/$investigationId',
path: '/investigations/$investigationId',
getParentRoute: () => AuthDashboardRoute,
} as any)
const AuthDashboardFlowsFlowIdRoute =
AuthDashboardFlowsFlowIdRouteImport.update({
id: '/flows/$flowId',
@@ -115,15 +128,15 @@ const AuthDashboardCustomTypesTypeIdRoute =
} as any)
const AuthDashboardInvestigationsInvestigationIdIndexRoute =
AuthDashboardInvestigationsInvestigationIdIndexRouteImport.update({
id: '/investigations/$investigationId/',
path: '/investigations/$investigationId/',
getParentRoute: () => AuthDashboardRoute,
id: '/',
path: '/',
getParentRoute: () => AuthDashboardInvestigationsInvestigationIdRoute,
} as any)
const AuthDashboardInvestigationsInvestigationIdTypeIdRoute =
AuthDashboardInvestigationsInvestigationIdTypeIdRouteImport.update({
id: '/investigations/$investigationId/$type/$id',
path: '/investigations/$investigationId/$type/$id',
getParentRoute: () => AuthDashboardRoute,
id: '/$type/$id',
path: '/$type/$id',
getParentRoute: () => AuthDashboardInvestigationsInvestigationIdRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -132,6 +145,7 @@ export interface FileRoutesByFullPath {
'/middleware': typeof MiddlewareRoute
'/register': typeof RegisterRoute
'/dashboard': typeof AuthDashboardRouteWithChildren
'/dashboard/profile': typeof AuthDashboardProfileRoute
'/dashboard/tools': typeof AuthDashboardToolsRoute
'/dashboard/vault': typeof AuthDashboardVaultRoute
'/dashboard/': typeof AuthDashboardIndexRoute
@@ -139,10 +153,11 @@ export interface FileRoutesByFullPath {
'/dashboard/enrichers/$enricherId': typeof AuthDashboardEnrichersEnricherIdRoute
'/dashboard/enrichers/new': typeof AuthDashboardEnrichersNewRoute
'/dashboard/flows/$flowId': typeof AuthDashboardFlowsFlowIdRoute
'/dashboard/investigations/$investigationId': typeof AuthDashboardInvestigationsInvestigationIdRouteWithChildren
'/dashboard/custom-types': typeof AuthDashboardCustomTypesIndexRoute
'/dashboard/enrichers': typeof AuthDashboardEnrichersIndexRoute
'/dashboard/flows': typeof AuthDashboardFlowsIndexRoute
'/dashboard/investigations/$investigationId': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/dashboard/investigations/$investigationId/': typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
'/dashboard/investigations/$investigationId/$type/$id': typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
}
export interface FileRoutesByTo {
@@ -150,6 +165,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/middleware': typeof MiddlewareRoute
'/register': typeof RegisterRoute
'/dashboard/profile': typeof AuthDashboardProfileRoute
'/dashboard/tools': typeof AuthDashboardToolsRoute
'/dashboard/vault': typeof AuthDashboardVaultRoute
'/dashboard': typeof AuthDashboardIndexRoute
@@ -171,6 +187,7 @@ export interface FileRoutesById {
'/middleware': typeof MiddlewareRoute
'/register': typeof RegisterRoute
'/_auth/dashboard': typeof AuthDashboardRouteWithChildren
'/_auth/dashboard/profile': typeof AuthDashboardProfileRoute
'/_auth/dashboard/tools': typeof AuthDashboardToolsRoute
'/_auth/dashboard/vault': typeof AuthDashboardVaultRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
@@ -178,6 +195,7 @@ export interface FileRoutesById {
'/_auth/dashboard/enrichers/$enricherId': typeof AuthDashboardEnrichersEnricherIdRoute
'/_auth/dashboard/enrichers/new': typeof AuthDashboardEnrichersNewRoute
'/_auth/dashboard/flows/$flowId': typeof AuthDashboardFlowsFlowIdRoute
'/_auth/dashboard/investigations/$investigationId': typeof AuthDashboardInvestigationsInvestigationIdRouteWithChildren
'/_auth/dashboard/custom-types/': typeof AuthDashboardCustomTypesIndexRoute
'/_auth/dashboard/enrichers/': typeof AuthDashboardEnrichersIndexRoute
'/_auth/dashboard/flows/': typeof AuthDashboardFlowsIndexRoute
@@ -192,6 +210,7 @@ export interface FileRouteTypes {
| '/middleware'
| '/register'
| '/dashboard'
| '/dashboard/profile'
| '/dashboard/tools'
| '/dashboard/vault'
| '/dashboard/'
@@ -199,10 +218,11 @@ export interface FileRouteTypes {
| '/dashboard/enrichers/$enricherId'
| '/dashboard/enrichers/new'
| '/dashboard/flows/$flowId'
| '/dashboard/investigations/$investigationId'
| '/dashboard/custom-types'
| '/dashboard/enrichers'
| '/dashboard/flows'
| '/dashboard/investigations/$investigationId'
| '/dashboard/investigations/$investigationId/'
| '/dashboard/investigations/$investigationId/$type/$id'
fileRoutesByTo: FileRoutesByTo
to:
@@ -210,6 +230,7 @@ export interface FileRouteTypes {
| '/login'
| '/middleware'
| '/register'
| '/dashboard/profile'
| '/dashboard/tools'
| '/dashboard/vault'
| '/dashboard'
@@ -230,6 +251,7 @@ export interface FileRouteTypes {
| '/middleware'
| '/register'
| '/_auth/dashboard'
| '/_auth/dashboard/profile'
| '/_auth/dashboard/tools'
| '/_auth/dashboard/vault'
| '/_auth/dashboard/'
@@ -237,6 +259,7 @@ export interface FileRouteTypes {
| '/_auth/dashboard/enrichers/$enricherId'
| '/_auth/dashboard/enrichers/new'
| '/_auth/dashboard/flows/$flowId'
| '/_auth/dashboard/investigations/$investigationId'
| '/_auth/dashboard/custom-types/'
| '/_auth/dashboard/enrichers/'
| '/_auth/dashboard/flows/'
@@ -317,6 +340,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardToolsRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/profile': {
id: '/_auth/dashboard/profile'
path: '/profile'
fullPath: '/dashboard/profile'
preLoaderRoute: typeof AuthDashboardProfileRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/flows/': {
id: '/_auth/dashboard/flows/'
path: '/flows'
@@ -338,6 +368,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardCustomTypesIndexRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/investigations/$investigationId': {
id: '/_auth/dashboard/investigations/$investigationId'
path: '/investigations/$investigationId'
fullPath: '/dashboard/investigations/$investigationId'
preLoaderRoute: typeof AuthDashboardInvestigationsInvestigationIdRouteImport
parentRoute: typeof AuthDashboardRoute
}
'/_auth/dashboard/flows/$flowId': {
id: '/_auth/dashboard/flows/$flowId'
path: '/flows/$flowId'
@@ -368,22 +405,41 @@ declare module '@tanstack/react-router' {
}
'/_auth/dashboard/investigations/$investigationId/': {
id: '/_auth/dashboard/investigations/$investigationId/'
path: '/investigations/$investigationId'
fullPath: '/dashboard/investigations/$investigationId'
path: '/'
fullPath: '/dashboard/investigations/$investigationId/'
preLoaderRoute: typeof AuthDashboardInvestigationsInvestigationIdIndexRouteImport
parentRoute: typeof AuthDashboardRoute
parentRoute: typeof AuthDashboardInvestigationsInvestigationIdRoute
}
'/_auth/dashboard/investigations/$investigationId/$type/$id': {
id: '/_auth/dashboard/investigations/$investigationId/$type/$id'
path: '/investigations/$investigationId/$type/$id'
path: '/$type/$id'
fullPath: '/dashboard/investigations/$investigationId/$type/$id'
preLoaderRoute: typeof AuthDashboardInvestigationsInvestigationIdTypeIdRouteImport
parentRoute: typeof AuthDashboardRoute
parentRoute: typeof AuthDashboardInvestigationsInvestigationIdRoute
}
}
}
interface AuthDashboardInvestigationsInvestigationIdRouteChildren {
AuthDashboardInvestigationsInvestigationIdIndexRoute: typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
AuthDashboardInvestigationsInvestigationIdTypeIdRoute: typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
}
const AuthDashboardInvestigationsInvestigationIdRouteChildren: AuthDashboardInvestigationsInvestigationIdRouteChildren =
{
AuthDashboardInvestigationsInvestigationIdIndexRoute:
AuthDashboardInvestigationsInvestigationIdIndexRoute,
AuthDashboardInvestigationsInvestigationIdTypeIdRoute:
AuthDashboardInvestigationsInvestigationIdTypeIdRoute,
}
const AuthDashboardInvestigationsInvestigationIdRouteWithChildren =
AuthDashboardInvestigationsInvestigationIdRoute._addFileChildren(
AuthDashboardInvestigationsInvestigationIdRouteChildren,
)
interface AuthDashboardRouteChildren {
AuthDashboardProfileRoute: typeof AuthDashboardProfileRoute
AuthDashboardToolsRoute: typeof AuthDashboardToolsRoute
AuthDashboardVaultRoute: typeof AuthDashboardVaultRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
@@ -391,14 +447,14 @@ interface AuthDashboardRouteChildren {
AuthDashboardEnrichersEnricherIdRoute: typeof AuthDashboardEnrichersEnricherIdRoute
AuthDashboardEnrichersNewRoute: typeof AuthDashboardEnrichersNewRoute
AuthDashboardFlowsFlowIdRoute: typeof AuthDashboardFlowsFlowIdRoute
AuthDashboardInvestigationsInvestigationIdRoute: typeof AuthDashboardInvestigationsInvestigationIdRouteWithChildren
AuthDashboardCustomTypesIndexRoute: typeof AuthDashboardCustomTypesIndexRoute
AuthDashboardEnrichersIndexRoute: typeof AuthDashboardEnrichersIndexRoute
AuthDashboardFlowsIndexRoute: typeof AuthDashboardFlowsIndexRoute
AuthDashboardInvestigationsInvestigationIdIndexRoute: typeof AuthDashboardInvestigationsInvestigationIdIndexRoute
AuthDashboardInvestigationsInvestigationIdTypeIdRoute: typeof AuthDashboardInvestigationsInvestigationIdTypeIdRoute
}
const AuthDashboardRouteChildren: AuthDashboardRouteChildren = {
AuthDashboardProfileRoute: AuthDashboardProfileRoute,
AuthDashboardToolsRoute: AuthDashboardToolsRoute,
AuthDashboardVaultRoute: AuthDashboardVaultRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
@@ -406,13 +462,11 @@ const AuthDashboardRouteChildren: AuthDashboardRouteChildren = {
AuthDashboardEnrichersEnricherIdRoute: AuthDashboardEnrichersEnricherIdRoute,
AuthDashboardEnrichersNewRoute: AuthDashboardEnrichersNewRoute,
AuthDashboardFlowsFlowIdRoute: AuthDashboardFlowsFlowIdRoute,
AuthDashboardInvestigationsInvestigationIdRoute:
AuthDashboardInvestigationsInvestigationIdRouteWithChildren,
AuthDashboardCustomTypesIndexRoute: AuthDashboardCustomTypesIndexRoute,
AuthDashboardEnrichersIndexRoute: AuthDashboardEnrichersIndexRoute,
AuthDashboardFlowsIndexRoute: AuthDashboardFlowsIndexRoute,
AuthDashboardInvestigationsInvestigationIdIndexRoute:
AuthDashboardInvestigationsInvestigationIdIndexRoute,
AuthDashboardInvestigationsInvestigationIdTypeIdRoute:
AuthDashboardInvestigationsInvestigationIdTypeIdRoute,
}
const AuthDashboardRouteWithChildren = AuthDashboardRoute._addFileChildren(

View File

@@ -168,7 +168,8 @@ function CustomTypesList({ types, onDelete, navigate }: CustomTypesListProps) {
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div style={{ containerType: 'inline-size' }}>
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 gap-4">
{types.map((customType) => (
<Card key={customType.id} className="hover:border-primary/50 transition-colors">
<CardHeader>
@@ -211,5 +212,6 @@ function CustomTypesList({ types, onDelete, navigate }: CustomTypesListProps) {
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,63 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { investigationService } from '@/api/investigation-service'
import { createFileRoute, useLoaderData, useNavigate } from '@tanstack/react-router'
import { analysisService } from '@/api/analysis-service'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CaseOverviewPage } from "@/components/dashboard/investigation/case-overview-page"
function InvestigationSkeleton() {
return (
<div className="h-full w-full bg-background overflow-y-auto">
<div className="max-w-7xl mx-auto p-8 space-y-8">
{/* Header Skeleton */}
<div className="space-y-4">
<div className="w-64 h-8 bg-muted rounded animate-pulse" />
<div className="w-96 h-4 bg-muted rounded animate-pulse" />
<div className="flex items-center gap-4">
<div className="w-20 h-6 bg-muted rounded animate-pulse" />
<div className="w-24 h-6 bg-muted rounded animate-pulse" />
<div className="w-32 h-6 bg-muted rounded animate-pulse" />
</div>
</div>
{/* Stats Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 bg-muted rounded-lg animate-pulse" />
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-4">
<div className="w-48 h-6 bg-muted rounded animate-pulse" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-32 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export const Route = createFileRoute('/_auth/dashboard/investigations/$investigationId/')({
loader: async ({ params: { investigationId } }) => {
return {
investigation: await investigationService.getById(investigationId)
}
},
component: InvestigationPage,
pendingComponent: InvestigationSkeleton
})
function InvestigationPage() {
const { investigation } = Route.useLoaderData()
const { investigation } = useLoaderData({
from: '/_auth/dashboard/investigations/$investigationId'
})
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -73,7 +27,6 @@ function InvestigationPage() {
onSuccess: async (data) => {
queryClient.invalidateQueries({ queryKey: ['analyses', 'investigation', investigation.id] })
toast.success('New analysis created')
// Navigate to the New analysis page
navigate({
to: '/dashboard/investigations/$investigationId/$type/$id',
params: {

View File

@@ -0,0 +1,34 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { investigationService } from '@/api/investigation-service'
function InvestigationSkeleton() {
return (
<div className="h-full w-full bg-background overflow-y-auto">
<div className="max-w-7xl mx-auto p-8 space-y-8">
<div className="space-y-4">
<div className="w-64 h-8 bg-muted rounded animate-pulse" />
<div className="w-96 h-4 bg-muted rounded animate-pulse" />
<div className="flex items-center gap-4">
<div className="w-20 h-6 bg-muted rounded animate-pulse" />
<div className="w-24 h-6 bg-muted rounded animate-pulse" />
<div className="w-32 h-6 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 bg-muted rounded-lg animate-pulse" />
))}
</div>
</div>
</div>
)
}
export const Route = createFileRoute('/_auth/dashboard/investigations/$investigationId')({
loader: async ({ params: { investigationId } }) => {
const investigation = await investigationService.getById(investigationId)
return { investigation }
},
component: () => <Outlet />,
pendingComponent: InvestigationSkeleton
})

View File

@@ -0,0 +1,151 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { authService } from '@/api/auth-service'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { UserAvatar } from '@/components/ui/avatar'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { getDisplayName } from '@/lib/user-display'
import { SESSION_QUERY_KEY } from '@/hooks/use-auth'
export const Route = createFileRoute('/_auth/dashboard/profile')({
component: ProfilePage,
})
function ProfilePage() {
const authUser = useAuthStore((s) => s.user)
const setAuth = useAuthStore((s) => s.setAuth)
const token = useAuthStore((s) => s.token)
const { data: profile, isLoading } = useQuery({
queryKey: ['profile', 'me'],
queryFn: authService.getCurrentUser,
})
const [form, setForm] = useState({
first_name: '',
last_name: '',
avatar_url: '',
})
useEffect(() => {
if (profile) {
setForm({
first_name: profile.first_name ?? '',
last_name: profile.last_name ?? '',
avatar_url: profile.avatar_url ?? '',
})
}
}, [profile])
const queryClient = useQueryClient()
const updateMutation = useMutation({
mutationFn: authService.updateProfile,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['profile', 'me'] })
queryClient.invalidateQueries({ queryKey: SESSION_QUERY_KEY })
if (token && authUser) {
setAuth(token, {
...authUser,
first_name: data.first_name ?? undefined,
last_name: data.last_name ?? undefined,
avatar_url: data.avatar_url ?? undefined,
username: [data.first_name, data.last_name].filter(Boolean).join(' ') || authUser.email,
})
}
toast.success('Profile updated')
},
onError: () => toast.error('Failed to update profile'),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateMutation.mutate(form)
}
const handleChange = (field: string, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
if (isLoading) {
return (
<div className="flex-1 h-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
const displayName = getDisplayName(profile)
return (
<main className="flex-1 h-full overflow-auto">
<div className="max-w-xl mx-auto px-8 py-12 space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your account information.</p>
</div>
{/* Avatar preview */}
<div className="flex items-center gap-4">
<UserAvatar
user={{ ...profile, avatar_url: form.avatar_url || undefined }}
size="xl"
className="size-16 text-lg"
/>
<div>
<p className="font-medium">{displayName}</p>
<p className="text-sm text-muted-foreground">{profile?.email}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">First name</Label>
<Input
id="first_name"
value={form.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
placeholder="John"
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last name</Label>
<Input
id="last_name"
value={form.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
placeholder="Doe"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="avatar_url">Avatar URL</Label>
<Input
id="avatar_url"
value={form.avatar_url}
onChange={(e) => handleChange('avatar_url', e.target.value)}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
</div>
<div className="pt-2">
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save changes'}
</Button>
</div>
</form>
</div>
</main>
)
}

View File

@@ -6,6 +6,9 @@ export interface User {
id: string
username: string
email: string
first_name?: string
last_name?: string
avatar_url?: string
}
interface AuthState {

View File

@@ -96,6 +96,12 @@ const DEFAULT_SETTINGS = {
description:
'Adjusts the font size of link labels (percentage of base size, scales with zoom)'
},
linkLabelHorizontal: {
name: 'Horizontal Link Labels',
type: 'boolean',
value: false,
description: 'Display link labels horizontally instead of following the edge angle.'
},
dagLevelDistance: {
name: 'DAG Level Distance',
type: 'number',
@@ -401,7 +407,7 @@ type GraphGeneralSettingsStore = {
}
// Storage version - increment this whenever you make breaking changes to DEFAULT_SETTINGS
const STORAGE_VERSION = 7
const STORAGE_VERSION = 8
export const useGraphSettingsStore = create<GraphGeneralSettingsStore>()(
persist(

View File

@@ -9,24 +9,48 @@
.cq-sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 768px) {
.cq-md\:grid-cols-3 {
.cq-sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container (min-width: 1024px) {
.cq-lg\:grid-cols-4 {
.cq-sm\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@container (min-width: 768px) {
.cq-md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cq-md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cq-md\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@container (min-width: 1024px) {
.cq-lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cq-lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.cq-lg\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@container (min-width: 1280px) {
.cq-xl\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.cq-xl\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.cq-xl\:grid-cols-6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
/* Electron title bar draggable region */

View File

@@ -2,6 +2,8 @@ import { type Sketch } from './sketch'
import { type Profile } from './profile'
import { type Analysis } from './analysis'
export type InvestigationRole = 'owner' | 'admin' | 'editor' | 'viewer'
export interface Investigation {
id: string
name: string
@@ -13,4 +15,12 @@ export interface Investigation {
owner: Profile
owner_id: string
status: string
current_user_role: InvestigationRole | null
}
export interface Collaborator {
id: string
user_id: string
roles: string[]
user: Profile | null
}

View File

@@ -4,4 +4,5 @@ export interface Profile {
last_name: string
id: string
avatar_url?: string
email?: string
}

View File

@@ -14,7 +14,7 @@ It contains:
## Testing
```bash
poetry run pytest
uv run pytest
```
> ⚠️ 🚧 Work in progress !.

5335
flowsint-core/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,55 @@
[tool.poetry]
[project]
name = "flowsint-core"
version = "1.0.0"
version = "1.2.8"
description = "Core utilities and base classes for flowsint modules"
authors = ["dextmorgn <contact@flowsint.io>"]
packages = [{ include = "flowsint_core", from = "src" }]
authors = [{ name = "dextmorgn", email = "contact@flowsint.io" }]
requires-python = ">=3.12,<4.0"
dependencies = [
"flowsint-enrichers",
"pydantic[email]>=2.11.7,<3.0.0",
"neo4j>=5.0,<6.0",
"sqlalchemy>=2.0,<3.0",
"psycopg2-binary>=2.9,<3.0",
"asyncpg>=0.30,<0.31",
"redis>=5.0,<6.0",
"celery>=5.3,<6.0",
"python-dotenv>=1.0,<2.0",
"requests>=2.31,<3.0",
"httpx>=0.28,<0.29",
"networkx>=2.6.3,<3.0.0",
"passlib[bcrypt]>=1.7,<2.0",
"bcrypt>=4.0.0,<5.0.0",
"python-jose[cryptography]>=3.3,<4.0",
"sse-starlette>=1.8,<2.0",
"alembic==1.13.0",
"phonenumbers>=9.0.8,<10.0.0",
"python-multipart>=0.0.20,<0.0.21",
"docker>=7.1.0,<8.0.0",
"pytest>=8.4.2,<9.0.0",
"cryptography>=45.0.7,<46.0.0",
"openpyxl>=3.1,<4.0",
]
[tool.poetry.dependencies]
python = ">=3.12,<4.0"
pydantic = {extras = ["email"], version = "^2.11.7"}
flowsint-enrichers = { path = "../flowsint-enrichers", develop = true }
neo4j = "^5.0"
sqlalchemy = "^2.0"
psycopg2-binary = "^2.9"
asyncpg = "^0.30"
redis = "^5.0"
celery = "^5.3"
python-dotenv = "^1.0"
requests = "^2.31"
httpx = "^0.28"
networkx = "^2.6.3"
passlib = {extras = ["bcrypt"], version = "^1.7"}
bcrypt = ">=4.0.0,<5.0.0"
python-jose = {extras = ["cryptography"], version = "^3.3"}
sse-starlette = "^1.8"
alembic = "1.13.0"
phonenumbers = "^9.0.8"
python-multipart = "^0.0.20"
docker = "^7.1.0"
pytest = "^8.4.2"
cryptography = "^45.0.7"
openpyxl = "^3.1"
[tool.poetry.group.dev.dependencies]
pytest-asyncio = "^0.21"
pytest-httpx = "^0.35"
black = "^23.0"
isort = "^5.12"
flake8 = "^6.0"
mypy = "^1.5"
factory-boy = "^3.3"
[dependency-groups]
dev = [
"pytest-asyncio>=0.21,<0.22",
"pytest-httpx>=0.35,<0.36",
"black>=25.0,<26.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",
"factory-boy>=3.3,<4.0",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/flowsint_core"]
[tool.uv.sources]
flowsint-enrichers = { workspace = true }
[tool.black]
line-length = 88
@@ -56,4 +63,4 @@ multi_line_output = 3
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_untyped_defs = true

View File

@@ -22,7 +22,7 @@ class BaseRepository(Generic[T]):
self, user_id: UUID, allowed_roles: Optional[List[Role]] = None
) -> Set[UUID]:
if allowed_roles is None:
allowed_roles = [Role.OWNER, Role.EDITOR, Role.VIEWER]
allowed_roles = [Role.OWNER, Role.ADMIN, Role.EDITOR, Role.VIEWER]
role_entries = (
self._db.query(InvestigationUserRole)
.filter(InvestigationUserRole.user_id == user_id)

View File

@@ -32,7 +32,7 @@ class InvestigationRepository(BaseRepository[Investigation]):
)
def get_with_relations(
self, investigation_id: UUID, owner_id: UUID
self, investigation_id: UUID
) -> Optional[Investigation]:
return (
self._db.query(Investigation)
@@ -41,10 +41,7 @@ class InvestigationRepository(BaseRepository[Investigation]):
selectinload(Investigation.analyses),
selectinload(Investigation.owner),
)
.filter(
Investigation.id == investigation_id,
Investigation.owner_id == owner_id,
)
.filter(Investigation.id == investigation_id)
.first()
)
@@ -72,3 +69,30 @@ class InvestigationRepository(BaseRepository[Investigation]):
def add_user_role(self, role_entry: InvestigationUserRole) -> InvestigationUserRole:
self._db.add(role_entry)
return role_entry
def get_collaborators(
self, investigation_id: UUID
) -> List[InvestigationUserRole]:
return (
self._db.query(InvestigationUserRole)
.options(selectinload(InvestigationUserRole.user))
.filter(InvestigationUserRole.investigation_id == investigation_id)
.all()
)
def update_user_role(
self, user_id: UUID, investigation_id: UUID, roles: List[Role]
) -> Optional[InvestigationUserRole]:
entry = self.get_user_role(user_id, investigation_id)
if entry:
entry.roles = roles
return entry
def remove_user_role(
self, user_id: UUID, investigation_id: UUID
) -> bool:
entry = self.get_user_role(user_id, investigation_id)
if entry:
self._db.delete(entry)
return True
return False

View File

@@ -32,8 +32,15 @@ class AuthService(BaseService):
return {
"access_token": access_token,
"user_id": user.id,
"token_type": "bearer",
"user": {
"id": str(user.id),
"email": user.email,
"username": user.email.split("@")[0],
"first_name": user.first_name,
"last_name": user.last_name,
"avatar_url": user.avatar_url,
},
}
def register(self, email: str, password: str) -> Dict[str, Any]:

View File

@@ -54,6 +54,8 @@ class BaseService:
for action in actions:
if role == Role.OWNER:
return True
if role == Role.ADMIN and action in ["read", "create", "update", "manage"]:
return True
if role == Role.EDITOR and action in ["read", "create", "update"]:
return True
if role == Role.VIEWER and action == "read":

View File

@@ -11,9 +11,19 @@ from sqlalchemy.orm import Session
from ..models import Investigation, InvestigationUserRole, Sketch, Analysis
from ..types import Role
from ..graph import create_graph_service
from ..repositories import InvestigationRepository, SketchRepository, AnalysisRepository
from ..repositories import (
InvestigationRepository,
SketchRepository,
AnalysisRepository,
ProfileRepository,
)
from .base import BaseService
from .exceptions import NotFoundError, PermissionDeniedError, DatabaseError
from .exceptions import (
NotFoundError,
PermissionDeniedError,
ConflictError,
DatabaseError,
)
class InvestigationService(BaseService):
@@ -27,12 +37,14 @@ class InvestigationService(BaseService):
investigation_repo: InvestigationRepository,
sketch_repo: SketchRepository,
analysis_repo: AnalysisRepository,
profile_repo: ProfileRepository,
**kwargs,
):
super().__init__(db, **kwargs)
self._investigation_repo = investigation_repo
self._sketch_repo = sketch_repo
self._analysis_repo = analysis_repo
self._profile_repo = profile_repo
def get_accessible_investigations(
self, user_id: UUID, allowed_roles: Optional[List[Role]] = None
@@ -42,9 +54,7 @@ class InvestigationService(BaseService):
def get_by_id(self, investigation_id: UUID, user_id: UUID) -> Investigation:
self._check_permission(user_id, investigation_id, actions=["read"])
investigation = self._investigation_repo.get_with_relations(
investigation_id, user_id
)
investigation = self._investigation_repo.get_with_relations(investigation_id)
if not investigation:
raise NotFoundError("Investigation not found")
return investigation
@@ -90,7 +100,7 @@ class InvestigationService(BaseService):
description: str,
status: str,
) -> Investigation:
self._check_permission(user_id, investigation_id, actions=["write"])
self._check_permission(user_id, investigation_id, actions=["update"])
investigation = self._investigation_repo.get_by_id(investigation_id)
if not investigation:
@@ -137,6 +147,109 @@ class InvestigationService(BaseService):
self._investigation_repo.delete(investigation)
self._commit()
# ── Collaborator management ──────────────────────────────────────────
def get_user_role_for_investigation(
self, user_id: UUID, investigation_id: UUID
) -> Optional[InvestigationUserRole]:
return self._investigation_repo.get_user_role(user_id, investigation_id)
def get_collaborators(
self, investigation_id: UUID, user_id: UUID
) -> List[InvestigationUserRole]:
self._check_permission(user_id, investigation_id, actions=["read"])
return self._investigation_repo.get_collaborators(investigation_id)
def add_collaborator(
self,
investigation_id: UUID,
user_id: UUID,
target_email: str,
role: Role,
) -> InvestigationUserRole:
self._check_permission(user_id, investigation_id, actions=["manage"])
# Verify investigation exists
investigation = self._investigation_repo.get_by_id(investigation_id)
if not investigation:
raise NotFoundError("Investigation not found")
# Cannot assign OWNER role
if role == Role.OWNER:
raise PermissionDeniedError("Cannot assign owner role")
# Look up target user by email
target_user = self._profile_repo.get_by_email(target_email)
if not target_user:
raise NotFoundError("User not found")
# Check if already a collaborator
existing = self._investigation_repo.get_user_role(
target_user.id, investigation_id
)
if existing:
raise ConflictError("User is already a collaborator")
role_entry = InvestigationUserRole(
id=uuid4(),
user_id=target_user.id,
investigation_id=investigation_id,
roles=[role],
)
self._investigation_repo.add_user_role(role_entry)
self._commit()
self._db.refresh(role_entry)
return role_entry
def update_collaborator_role(
self,
investigation_id: UUID,
user_id: UUID,
target_user_id: UUID,
role: Role,
) -> InvestigationUserRole:
self._check_permission(user_id, investigation_id, actions=["manage"])
if role == Role.OWNER:
raise PermissionDeniedError("Cannot assign owner role")
existing = self._investigation_repo.get_user_role(
target_user_id, investigation_id
)
if not existing:
raise NotFoundError("Collaborator not found")
# Cannot change the owner's role
if Role.OWNER in existing.roles:
raise PermissionDeniedError("Cannot change owner role")
entry = self._investigation_repo.update_user_role(
target_user_id, investigation_id, [role]
)
self._commit()
self._db.refresh(entry)
return entry
def remove_collaborator(
self,
investigation_id: UUID,
user_id: UUID,
target_user_id: UUID,
) -> None:
self._check_permission(user_id, investigation_id, actions=["manage"])
existing = self._investigation_repo.get_user_role(
target_user_id, investigation_id
)
if not existing:
raise NotFoundError("Collaborator not found")
if Role.OWNER in existing.roles:
raise PermissionDeniedError("Cannot remove owner")
self._investigation_repo.remove_user_role(target_user_id, investigation_id)
self._commit()
def create_investigation_service(db: Session) -> InvestigationService:
investigation_repo = InvestigationRepository(db)
@@ -145,4 +258,5 @@ def create_investigation_service(db: Session) -> InvestigationService:
investigation_repo=investigation_repo,
sketch_repo=SketchRepository(db),
analysis_repo=AnalysisRepository(db),
profile_repo=ProfileRepository(db),
)

View File

@@ -103,5 +103,6 @@ class FlowBranch(BaseModel):
class Role(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"

View File

@@ -69,10 +69,10 @@ def run_enricher(
results = asyncio.run(enricher.execute(values=serialized_objects))
scan.status = EventLevel.COMPLETED
scan.results = to_json_serializable(results)
scan.details = to_json_serializable(results)
session.commit()
return {"result": scan.results}
return {"result": scan.details}
except Exception as ex:
session.rollback()
@@ -82,7 +82,7 @@ def run_enricher(
scan = session.query(Scan).filter(Scan.id == uuid.UUID(self.request.id)).first()
if scan:
scan.status = EventLevel.FAILED
scan.results = {"error": error_logs}
scan.error = error_logs
session.commit()
self.update_state(state=states.FAILURE)
@@ -145,10 +145,10 @@ def run_template_enricher(
results = asyncio.run(enricher.execute(values=serialized_objects))
scan.status = EventLevel.COMPLETED
scan.results = to_json_serializable(results)
scan.details = to_json_serializable(results)
session.commit()
return {"result": scan.results}
return {"result": scan.details}
except Exception as ex:
session.rollback()
@@ -162,7 +162,7 @@ def run_template_enricher(
)
if scan:
scan.status = EventLevel.FAILED
scan.results = {"error": error_logs}
scan.error = error_logs
session.commit()
self.update_state(state=states.FAILURE)

View File

@@ -65,10 +65,10 @@ def run_flow(
results = enricher.scan(values=serialized_objects)
scan.status = EventLevel.COMPLETED
scan.results = to_json_serializable(results)
scan.details = to_json_serializable(results)
session.commit()
return {"result": scan.results}
return {"result": scan.details}
except Exception as ex:
session.rollback()
@@ -78,7 +78,7 @@ def run_flow(
scan = session.query(Scan).filter(Scan.id == uuid.UUID(self.request.id)).first()
if scan:
scan.status = EventLevel.FAILED
scan.results = {"error": error_logs}
scan.error = error_logs
session.commit()
self.update_state(state=states.FAILURE)

Some files were not shown because too many files have changed in this diff Show More