feat(settings): tables that save on change, Users, sheets on their pages

Settings has six tabs: General, API keys, Proxies, Notifications, AI and
Users. Each tab is one card whose controls save on change; list tabs are
tables with a row menu and their Add action in the tab row. A non-admin
sees General and AI, read-only.

Out-of-band testing is the Callback server sheet on the Arsenal Nuclei
tab, with a Need a callback count that filters the library to the same
rows. Bounty Hub settings are a sheet on the Bounty Hub page. The old
settings routes redirect to both.

- API keys store the last test result on the row and clear it when the
  key changes. Providers report whether a test exists. The usage count
  is gone: only ViewDNS ever incremented it.
- Notification channels store the last delivery, written by the
  notifier on its own connection. A channel offers the seven event
  types the product emits, program changes on by default, and levels
  named All events, Warning and critical, Critical only.
- A default proxy applies to a scan launched without a context and is
  preselected for new contexts. A pool hands each scan one endpoint,
  proxy URLs bracket IPv6 hosts, and deleting a proxy clears it from
  its contexts.
- Users can be added with a role, disabled and promoted. An account
  cannot change itself, and one that owns records answers 409.
- Retention windows are validated and include keep forever. The
  instance name cannot be blank and names every page title.
- The AI list drops the two switches nothing read; each feature states
  what it sends. The out-of-band Off help says the checks are skipped.

Connectors, Bounty Hub and Remote control lose their extra page
padding. Migration 5d2e9a8c1b47 adds the test and delivery columns and
drops proxies.mode.

Claude-Session: https://claude.ai/code/session_01RVAHQxZ7JAkoEiTCDiHYue
This commit is contained in:
Yogesh Ojha
2026-09-27 10:01:19 +05:30
parent be75bde8b0
commit a548afffa2
100 changed files with 4203 additions and 3442 deletions
@@ -0,0 +1,52 @@
"""api_keys test status, notification_channels delivery status, proxies without mode
Revision ID: 5d2e9a8c1b47
Revises: a1f4c7d20e93
Create Date: 2026-09-27
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "5d2e9a8c1b47"
down_revision: str | None = "a1f4c7d20e93"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"api_keys", sa.Column("last_test_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("api_keys", sa.Column("last_test_ok", sa.Boolean(), nullable=True))
op.add_column(
"api_keys", sa.Column("last_test_message", sa.String(length=500), nullable=True)
)
op.add_column(
"notification_channels",
sa.Column("last_sent_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"notification_channels", sa.Column("last_sent_ok", sa.Boolean(), nullable=True)
)
op.add_column(
"notification_channels",
sa.Column("last_sent_message", sa.String(length=500), nullable=True),
)
op.drop_column("proxies", "mode")
def downgrade() -> None:
op.add_column(
"proxies",
sa.Column("mode", sa.String(), nullable=False, server_default="single"),
)
op.drop_column("notification_channels", "last_sent_message")
op.drop_column("notification_channels", "last_sent_ok")
op.drop_column("notification_channels", "last_sent_at")
op.drop_column("api_keys", "last_test_message")
op.drop_column("api_keys", "last_test_ok")
op.drop_column("api_keys", "last_test_at")
+9 -3
View File
@@ -89,7 +89,11 @@ async def list_providers(
_current_user: CurrentSuperuser,
service: Annotated[APIKeyService, Depends(get_api_key_service)],
):
return await service.list_providers()
providers = await service.list_providers()
return [
p.model_copy(update={"testable": p.provider in API_KEY_TESTERS})
for p in providers
]
@router.get("", response_model=list[APIKeyRead])
@@ -161,13 +165,15 @@ async def test_api_key(
return {
"provider": api_key.provider,
"success": False,
"message": f"No test available for {api_key.provider.value}",
"message": f"No key test for {api_key.provider.value}.",
}
key_value = try_decrypt(api_key.key_value) or api_key.key_value
try:
result = await tester(key_value, api_key.key_meta)
return {"provider": api_key.provider, "success": True, **result}
except Exception as e:
message = str(e).replace(key_value, MASK) if key_value else str(e)
await service.record_test(api_key, False, message)
return {"provider": api_key.provider, "success": False, "message": message}
await service.record_test(api_key, True, result.get("message", ""))
return {"provider": api_key.provider, "success": True, **result}
+88 -4
View File
@@ -2,12 +2,21 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from app.api.deps import CurrentSuperuser, CurrentUser
from app.core.database import get_session
from shared.models.user import User, UserRead, UserSummary
from app.core.security import hash_password
from app.utils.validation import validate_password_strength, validate_username
from shared.models.user import (
User,
UserAdminCreate,
UserAdminUpdate,
UserRead,
UserSummary,
)
router = APIRouter(prefix="/users", tags=["users"])
@@ -34,11 +43,78 @@ async def list_users(
skip: Annotated[int, Query(ge=0, le=1_000_000)] = 0,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
):
query = select(User).offset(skip).limit(limit)
query = select(User).order_by(User.created_at).offset(skip).limit(limit)
result = await session.execute(query)
return result.scalars().all()
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserAdminCreate,
session: Annotated[AsyncSession, Depends(get_session)],
current_user: CurrentSuperuser, # noqa: ARG001
):
username = data.username.strip()
email = data.email.strip().lower()
try:
validate_username(username)
validate_password_strength(data.password, user_inputs=[email, username])
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
) from e
if "@" not in email:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Enter a valid email address.",
)
taken = await session.scalar(
select(User).where((User.email == email) | (User.username == username))
)
if taken is not None:
field = "email" if taken.email == email else "username"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A user with this {field} exists.",
)
user = User(
email=email,
username=username,
hashed_password=hash_password(data.password),
is_superuser=data.is_superuser,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@router.patch("/{user_id}", response_model=UserRead)
async def update_user(
user_id: UUID,
data: UserAdminUpdate,
session: Annotated[AsyncSession, Depends(get_session)],
current_user: CurrentSuperuser,
):
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The signed-in account cannot change its own role or status.",
)
user = await session.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if data.is_active is not None:
user.is_active = data.is_active
if data.is_superuser is not None:
user.is_superuser = data.is_superuser
await session.commit()
await session.refresh(user)
return user
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: str,
@@ -94,7 +170,15 @@ async def delete_user(
detail="User not found",
)
username = user.username
await session.delete(user)
await session.commit()
try:
await session.commit()
except IntegrityError as e:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{username} created records on this instance. Disable the account instead.",
) from e
return {"message": f"User {user.username} deleted"}
return {"message": f"User {username} deleted"}
+29 -1
View File
@@ -9,6 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import encrypt_secret, try_decrypt
from shared.definitions.mode_features import VALID_MODES, capabilities_for
from shared.definitions.retention import (
KEEP_FOREVER,
SCAN_RETENTION_DAYS,
SCREENSHOT_RETENTION_DAYS,
)
from shared.enums.instance import AIProvider
from shared.http import get_async_client
from shared.logging import get_logger
@@ -65,6 +70,15 @@ async def _admit_on_change(changed: bool) -> None:
logger.warning("scan admission dispatch failed", exc_info=True)
def _check_window(label: str, days: int, allowed: tuple[int, ...]) -> None:
if days not in allowed:
choices = ", ".join(str(d) for d in allowed if d != KEEP_FOREVER)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{label} retention must be {choices} days, or {KEEP_FOREVER} to keep forever.",
)
class InstanceSettingsService:
def __init__(self, session: AsyncSession):
self.session = session
@@ -124,7 +138,13 @@ class InstanceSettingsService:
settings = await self.get_or_create()
if data.instance_name is not None:
settings.instance_name = data.instance_name
name = data.instance_name.strip()
if not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Instance name is required.",
)
settings.instance_name = name[:120]
if data.timezone is not None:
try:
ZoneInfo(data.timezone)
@@ -140,8 +160,16 @@ class InstanceSettingsService:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
settings.mode = data.mode
if data.scan_history_retention_days is not None:
_check_window(
"Scan history", data.scan_history_retention_days, SCAN_RETENTION_DAYS
)
settings.scan_history_retention_days = data.scan_history_retention_days
if data.screenshot_retention_days is not None:
_check_window(
"Screenshot",
data.screenshot_retention_days,
SCREENSHOT_RETENTION_DAYS,
)
settings.screenshot_retention_days = data.screenshot_retention_days
if data.cert_recheck_enabled is not None:
settings.cert_recheck_enabled = data.cert_recheck_enabled
+3
View File
@@ -152,6 +152,9 @@ def _to_read(channel: NotificationChannel) -> NotificationChannelRead:
last_test_at=channel.last_test_at,
last_test_ok=channel.last_test_ok,
last_test_message=channel.last_test_message,
last_sent_at=channel.last_sent_at,
last_sent_ok=channel.last_sent_ok,
last_sent_message=channel.last_sent_message,
)
+78 -29
View File
@@ -7,13 +7,12 @@ from uuid import UUID
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select, text, update
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import encrypt_secret
from shared.http import get_async_client
from shared.models.proxy import (
PROXY_MODES,
PROXY_SCHEMES,
Proxy,
ProxyCreate,
@@ -23,6 +22,7 @@ from shared.models.proxy import (
ProxyTestResult,
ProxyUpdate,
)
from shared.models.scan_context import ScanContext
from shared.services.proxy_resolve import (
build_proxy_url as _build_url,
)
@@ -42,18 +42,13 @@ _TCP_TIMEOUT = 6.0
_MIN_PORT = 1
_MAX_PORT = 65535
_DEFAULT_LOCK_KEY = 0x70726F78
_MAX_TESTED = 10
def _bad(detail: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def _validate_mode(mode: str) -> None:
if mode not in PROXY_MODES:
msg = f"Invalid mode. Must be one of {', '.join(PROXY_MODES)}."
raise _bad(msg)
def _validate_endpoints(endpoints: list[ProxyEndpoint]) -> None:
if not endpoints:
msg = "At least one endpoint is required."
@@ -77,10 +72,11 @@ def _endpoint_key(ep: ProxyEndpoint) -> tuple:
def _mask_url(ep: ProxyEndpoint) -> str:
authority = host_port(ep.host, ep.port)
if ep.username:
cred = f"{ep.username}:{MASK}@" if ep.password else f"{ep.username}@"
return f"{ep.scheme}://{cred}{ep.host}:{ep.port}"
return f"{ep.scheme}://{ep.host}:{ep.port}"
return f"{ep.scheme}://{cred}{authority}"
return f"{ep.scheme}://{authority}"
def _endpoint_to_read(ep: ProxyEndpoint) -> ProxyEndpointRead:
@@ -99,21 +95,46 @@ def _encrypt_endpoints(endpoints: list[ProxyEndpoint]) -> str:
return encrypt_secret(json.dumps(payload))
def _summarize(
endpoints: list[ProxyEndpoint], results: list[ProxyTestResult]
) -> ProxyTestResult:
if len(results) == 1:
return results[0]
passed = [r for r in results if r.success]
if len(passed) == len(results):
return ProxyTestResult(
success=True,
message=f"All {len(results)} endpoints carried the request.",
latency_ms=max(r.latency_ms or 0 for r in passed),
reachable=True,
)
failed = next(i for i, r in enumerate(results) if not r.success)
ep = endpoints[failed]
return ProxyTestResult(
success=False,
message=(
f"{len(passed)} of {len(results)} endpoints carried the request. "
f"{host_port(ep.host, ep.port)}: {results[failed].message}"
),
reachable=all(r.reachable for r in results),
)
class ProxyService:
def __init__(self, session: AsyncSession):
self.session = session
def to_read(self, proxy: Proxy) -> ProxyRead:
def to_read(self, proxy: Proxy, contexts: int = 0) -> ProxyRead:
endpoints = _load_endpoints(proxy)
return ProxyRead(
id=proxy.id,
name=proxy.name,
description=proxy.description,
mode=proxy.mode,
is_active=proxy.is_active,
is_default=proxy.is_default,
endpoints=[_endpoint_to_read(e) for e in endpoints],
endpoint_count=proxy.endpoint_count,
contexts=contexts,
created_at=proxy.created_at,
updated_at=proxy.updated_at,
last_test_at=proxy.last_test_at,
@@ -121,24 +142,37 @@ class ProxyService:
last_test_message=proxy.last_test_message,
)
async def _read(self, proxy: Proxy) -> ProxyRead:
used = await self.session.scalar(
select(func.count()).where(ScanContext.proxy_id == proxy.id)
)
return self.to_read(proxy, int(used or 0))
async def list(self) -> list[ProxyRead]:
result = await self.session.execute(
select(Proxy).order_by(Proxy.is_default.desc(), Proxy.updated_at.desc())
)
return [self.to_read(p) for p in result.scalars().all()]
used = dict(
(
await self.session.execute(
select(ScanContext.proxy_id, func.count())
.where(ScanContext.proxy_id.is_not(None))
.group_by(ScanContext.proxy_id)
)
).all()
)
return [self.to_read(p, used.get(p.id, 0)) for p in result.scalars().all()]
async def get(self, id: UUID) -> ProxyRead:
proxy = await self._get_or_404(id)
return self.to_read(proxy)
return await self._read(proxy)
async def create(self, data: ProxyCreate, created_by: UUID) -> ProxyRead:
_validate_mode(data.mode)
_validate_endpoints(data.endpoints)
proxy = Proxy(
name=data.name,
description=data.description,
mode=data.mode,
is_active=data.is_active,
is_default=data.is_default,
endpoints_encrypted=_encrypt_endpoints(data.endpoints),
@@ -151,7 +185,7 @@ class ProxyService:
await self._unset_other_defaults(proxy.id)
await self.session.commit()
await self.session.refresh(proxy)
return self.to_read(proxy)
return await self._read(proxy)
async def update(self, id: UUID, data: ProxyUpdate) -> ProxyRead:
proxy = await self._get_or_404(id)
@@ -160,9 +194,6 @@ class ProxyService:
proxy.name = data.name
if data.description is not None:
proxy.description = data.description
if data.mode is not None:
_validate_mode(data.mode)
proxy.mode = data.mode
if data.is_active is not None:
proxy.is_active = data.is_active
@@ -181,10 +212,15 @@ class ProxyService:
await self._unset_other_defaults(proxy.id)
await self.session.commit()
await self.session.refresh(proxy)
return self.to_read(proxy)
return await self._read(proxy)
async def delete(self, id: UUID) -> bool:
proxy = await self._get_or_404(id)
await self.session.execute(
update(ScanContext)
.where(ScanContext.proxy_id == proxy.id)
.values(proxy_id=None)
)
await self.session.delete(proxy)
await self.session.commit()
return True
@@ -197,7 +233,7 @@ class ProxyService:
proxy.updated_at = utc_now()
await self.session.commit()
await self.session.refresh(proxy)
return self.to_read(proxy)
return await self._read(proxy)
async def test(self, id: UUID) -> ProxyTestResult:
proxy = await self._get_or_404(id)
@@ -208,14 +244,27 @@ class ProxyService:
ProxyTestResult(success=False, message="No endpoints configured."),
)
ep = endpoints[0]
url = _build_url(ep)
result = await self._probe(ep, url)
return await self._persist_test(proxy, result)
tested = endpoints[:_MAX_TESTED]
results = await asyncio.gather(
*(self._probe(ep, _build_url(ep)) for ep in tested)
)
return await self._persist_test(proxy, _summarize(tested, results))
async def resolve_proxy_url(self, proxy_id: UUID) -> str | None:
proxy = await self.session.get(Proxy, proxy_id)
return _resolve_proxy_url(proxy)
async def scan_proxy(self, context: ScanContext | None) -> Proxy | None:
"""The context's proxy, or the default proxy for a scan without a context."""
if context is None:
result = await self.session.execute(
select(Proxy).where(
Proxy.is_default.is_(True), Proxy.is_active.is_(True)
)
)
return result.scalars().first()
if context.proxy_id is None:
return None
return await self.session.get(Proxy, context.proxy_id)
async def scan_proxy_url(self, context: ScanContext | None) -> str | None:
return _resolve_proxy_url(await self.scan_proxy(context))
def _merge_endpoints(
self, proxy: Proxy, incoming: list[ProxyEndpoint]
+3 -13
View File
@@ -321,11 +321,7 @@ class ScanService:
if context_id is not None:
context = await self._get_context(context_id, project_id)
proxy_url = None
if context is not None and context.proxy_id is not None:
proxy_url = await ProxyService(self.session).resolve_proxy_url(
context.proxy_id
)
proxy_url = await ProxyService(self.session).scan_proxy_url(context)
return engine, context, proxy_url
@staticmethod
@@ -477,14 +473,8 @@ class ScanService:
h.get("name") for h in (extra_headers or []) if h.get("name")
]
proxy_name = None
if context is not None and context.proxy_id is not None:
try:
proxy_name = (
await ProxyService(self.session).get(context.proxy_id)
).name
except HTTPException:
proxy_name = None
proxy = await ProxyService(self.session).scan_proxy(context)
proxy_name = proxy.name if proxy is not None and proxy.is_active else None
summary = PreviewSummary(
auth_summary=_auth_summary(auth, extra_headers),
+6
View File
@@ -215,9 +215,13 @@ class VulnTemplateService:
fired = await self.session.scalar(
select(func.count(func.distinct(Vulnerability.template_id)))
)
callback = await self.session.scalar(
select(func.count(VulnTemplate.id)).where(VulnTemplate.needs_oast.is_(True))
)
return TemplateLibraryStats(
ready=total > 0,
total=total,
callback=int(callback or 0),
official=by_origin.get(TemplateOrigin.OFFICIAL.value, 0),
custom=by_origin.get(TemplateOrigin.CUSTOM.value, 0),
by_severity=[
@@ -274,6 +278,8 @@ class VulnTemplateService:
)
if f.fired:
query = query.where(hits.c.findings > 0)
if f.callback:
query = query.where(VulnTemplate.needs_oast.is_(True))
if f.new_since is not None:
query = query.where(
VulnTemplate.origin == TemplateOrigin.OFFICIAL.value,
+25
View File
@@ -12,6 +12,19 @@ export interface UserAccount {
is_active: boolean;
is_superuser: boolean;
totp_enabled: boolean;
created_at: string;
}
export interface UserAccountCreate {
username: string;
email: string;
password: string;
is_superuser: boolean;
}
export interface UserAccountUpdate {
is_active?: boolean;
is_superuser?: boolean;
}
const cache = new Map<string, Promise<UserSummary>>();
@@ -21,6 +34,18 @@ export const usersApi = {
return api.get<UserAccount[]>('/users?limit=500');
},
create(data: UserAccountCreate): Promise<UserAccount> {
return api.post<UserAccount>('/users', data);
},
update(userId: string, data: UserAccountUpdate): Promise<UserAccount> {
return api.patch<UserAccount>(`/users/${userId}`, data);
},
remove(userId: string): Promise<void> {
return api.delete(`/users/${userId}`);
},
getSummary(userId: string): Promise<UserSummary> {
const cached = cache.get(userId);
if (cached) return cached;
@@ -0,0 +1,334 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import CheckIcon from '@lucide/svelte/icons/check';
import CircleXIcon from '@lucide/svelte/icons/circle-x';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import * as Select from '$lib/components/ui/select/index.js';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import ConfirmDialog from '$lib/components/confirm-dialog.svelte';
import LoadingButton from '$lib/components/loading-button.svelte';
import { oastApi } from '$lib/api/oast';
import { auth } from '$lib/stores/auth.svelte';
import { ROUTES } from '$lib/config/routes';
import {
CALLBACK_SERVER,
DEFAULT_WAIT_SECONDS,
OAST_MODES,
OAST_MODE_HELP,
OAST_MODE_LABELS,
OastMode,
PUBLIC_ACK,
WAIT_STEPS
} from '$lib/config/oast';
import { relativeTime } from '$lib/utilities/dates';
import type { OastRead, OastTest } from '$lib/types/oast';
interface Props {
open: boolean;
settings: OastRead | null;
onSaved: (row: OastRead) => void;
}
let { open = $bindable(), settings, onSaved }: Props = $props();
const LABEL = 'text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase';
let mode = $state<OastMode>(OastMode.OFF);
let server = $state('');
let wait = $state(0);
let acknowledged = $state(false);
let serverError = $state<string | null>(null);
let saving = $state(false);
let testing = $state(false);
let result = $state<OastTest | null>(null);
let resetOpen = $state(false);
let resetting = $state(false);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const dirty = $derived(
!!settings &&
(mode !== settings.mode ||
server.trim() !== (settings.server ?? '') ||
wait !== settings.wait_seconds ||
acknowledged !== settings.public_acknowledged)
);
const blocked = $derived(
mode === OastMode.SELF_HOSTED && !server.trim()
? 'Server domain not set'
: mode === OastMode.PUBLIC && !acknowledged
? 'Public server not accepted'
: null
);
const canTest = $derived(
!!settings && settings.mode !== OastMode.OFF && !settings.reason && !dirty
);
const canReset = $derived(
!!settings &&
(settings.mode !== OastMode.OFF ||
!!settings.server ||
settings.token_set ||
settings.public_acknowledged ||
settings.wait_seconds !== DEFAULT_WAIT_SECONDS)
);
$effect(() => {
if (open && settings) {
mode = settings.mode as OastMode;
server = settings.server ?? '';
wait = settings.wait_seconds;
acknowledged = settings.public_acknowledged;
serverError = null;
result = null;
}
});
function waitLabel(seconds: number): string {
if (seconds === 0) return 'No wait';
if (seconds < 60) return `${seconds} seconds`;
const minutes = seconds / 60;
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
}
async function save() {
saving = true;
try {
const row = await oastApi.update({
mode,
server: server.trim(),
wait_seconds: wait,
public_acknowledged: acknowledged
});
onSaved(row);
result = null;
toast.success(`${CALLBACK_SERVER} saved`);
} catch (e) {
const message = e instanceof Error ? e.message : `${CALLBACK_SERVER} not saved`;
if (mode === OastMode.SELF_HOSTED) serverError = message;
else toast.error(message);
} finally {
saving = false;
}
}
async function test() {
testing = true;
try {
result = await oastApi.test();
} catch (e) {
result = {
ok: false,
detail: e instanceof Error ? e.message : 'Request failed.',
address: null,
status_code: null
};
} finally {
testing = false;
}
}
async function reset() {
resetting = true;
try {
const row = await oastApi.reset();
onSaved(row);
resetOpen = false;
toast.success(`${CALLBACK_SERVER} reset`);
} catch (e) {
toast.error(e instanceof Error ? e.message : `${CALLBACK_SERVER} not reset`);
} finally {
resetting = false;
}
}
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<Sheet.Header class="border-b px-5 py-4">
<Sheet.Title>{CALLBACK_SERVER}</Sheet.Title>
{#if settings}
<Sheet.Description class="tabular-nums">
{settings.checks.toLocaleString()} checks need a callback
</Sheet.Description>
{/if}
</Sheet.Header>
{#if settings}
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col divide-y px-5">
<section class="flex flex-col gap-2.5 py-5">
<h3 class={LABEL}>Mode</h3>
<ToggleGroup.Root
type="single"
variant="outline"
value={mode}
onValueChange={(v) => v && (mode = v as OastMode)}
disabled={!isAdmin || saving}
class="w-fit"
aria-label="Mode"
>
{#each OAST_MODES as option (option)}
<ToggleGroup.Item value={option} class="h-9 px-3 text-sm font-normal">
{OAST_MODE_LABELS[option]}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
{#if mode !== OastMode.PUBLIC}
<p class="text-xs text-muted-foreground">{OAST_MODE_HELP[mode]}</p>
{/if}
{#if settings.reason && settings.mode !== OastMode.OFF && !dirty}
<p class="text-xs text-warning">{settings.reason}</p>
{/if}
</section>
{#if mode === OastMode.SELF_HOSTED}
<section class="flex flex-col gap-2.5 py-5">
<h3 class={LABEL}>Server</h3>
<Input
placeholder="oast.example.com"
bind:value={server}
oninput={() => (serverError = null)}
autocomplete="off"
spellcheck={false}
disabled={!isAdmin || saving}
aria-label="Server domain"
aria-invalid={serverError ? true : undefined}
class="font-mono text-xs"
/>
{#if serverError}
<p class="text-xs text-destructive">{serverError}</p>
{/if}
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span class="text-muted-foreground">Token</span>
<span>{settings.token_set ? 'Set' : 'Not set'}</span>
<a
href={ROUTES.settings('api-keys')}
class="text-primary transition-colors hover:text-foreground"
>
API keys
</a>
</div>
</section>
{/if}
{#if mode === OastMode.PUBLIC}
<section class="flex flex-col gap-2.5 py-5">
<h3 class={LABEL}>Public server</h3>
<div class="flex items-start gap-2 text-xs">
<TriangleAlertIcon class="mt-px size-3.5 shrink-0 text-warning" />
<span>{PUBLIC_ACK}</span>
</div>
<label class="flex w-fit cursor-pointer items-center gap-2 text-sm" for="oast-ack">
<Checkbox
id="oast-ack"
checked={acknowledged}
disabled={!isAdmin || saving}
onCheckedChange={(v) => (acknowledged = v === true)}
/>
Accepted for every scan
</label>
<p class="font-mono text-2xs text-muted-foreground">
{settings.public_servers.join(' · ')}
</p>
</section>
{/if}
{#if mode !== OastMode.OFF}
<section class="flex flex-col gap-2.5 py-5">
<h3 class={LABEL}>Wait after each out-of-band batch</h3>
<Select.Root
type="single"
value={String(wait)}
onValueChange={(v) => v !== undefined && (wait = Number(v))}
disabled={!isAdmin || saving}
>
<Select.Trigger class="h-9 w-44" aria-label="Wait">
{waitLabel(wait)}
</Select.Trigger>
<Select.Content>
{#each WAIT_STEPS as step (step)}
<Select.Item value={String(step)} label={waitLabel(step)}>
{waitLabel(step)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</section>
{/if}
{#if result && !dirty}
<section class="flex items-start gap-2 py-4 text-sm">
{#if result.ok}
<CheckIcon class="mt-0.5 size-4 shrink-0 text-success" />
{:else}
<CircleXIcon class="mt-0.5 size-4 shrink-0 text-destructive" />
{/if}
<span class="wrap-anywhere">{result.detail}</span>
</section>
{/if}
{#if settings.interactions > 0}
<section class="py-4 text-xs text-muted-foreground tabular-nums">
{settings.interactions.toLocaleString()}
{settings.interactions === 1 ? 'finding' : 'findings'} with a callback
{#if settings.last_interaction_at}
· most recent {relativeTime(settings.last_interaction_at)}
{/if}
</section>
{/if}
</div>
</ScrollArea>
<div class="flex flex-wrap items-center gap-2 border-t px-5 py-3">
<LoadingButton
loading={saving}
loadingLabel="Saving"
disabled={!isAdmin || !dirty || !!blocked}
onclick={save}
>
Save
</LoadingButton>
<LoadingButton
variant="outline"
loading={testing}
loadingLabel="Testing"
disabled={!isAdmin || !canTest}
onclick={test}
>
Test server
</LoadingButton>
<span class="flex-1"></span>
<Button
variant="ghost"
class="text-muted-foreground"
disabled={!isAdmin || !canReset}
onclick={() => (resetOpen = true)}
>
Reset
</Button>
</div>
{#if blocked && dirty}
<p class="border-t px-5 py-2.5 text-xs text-muted-foreground">{blocked}</p>
{:else if !isAdmin}
<p class="border-t px-5 py-2.5 text-xs text-muted-foreground">
Editable by administrators.
</p>
{/if}
{/if}
</Sheet.Content>
</Sheet.Root>
<ConfirmDialog
open={resetOpen}
title="Reset {CALLBACK_SERVER.toLowerCase()}"
description="The server, the token and the public-server acceptance are removed."
confirmLabel="Reset"
destructive
loading={resetting}
onOpenChange={(v) => (resetOpen = v)}
onConfirm={reset}
/>
@@ -1,6 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { page as route } from '$app/state';
import { replaceState } from '$app/navigation';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Download from '@lucide/svelte/icons/download';
import FilePlus from '@lucide/svelte/icons/file-plus';
@@ -11,6 +13,7 @@
import Trash2 from '@lucide/svelte/icons/trash-2';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { Badge } from '$lib/components/ui/badge';
@@ -27,6 +30,11 @@
import SeverityMark from '$lib/components/scans/results/vulnerabilities/severity-mark.svelte';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import TemplateSheet from './template-sheet.svelte';
import CallbackServerSheet from './callback-server-sheet.svelte';
import { oastApi } from '$lib/api/oast';
import { CALLBACK_PANEL, PANEL_PARAM } from '$lib/config/routes';
import { CALLBACK_SERVER, OAST_MODE_LABELS, OastMode } from '$lib/config/oast';
import type { OastRead } from '$lib/types/oast';
import { vulnTemplatesApi } from '$lib/api/vulnerabilities';
import SelectionDeleteBar from '$lib/components/selection-delete-bar.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -72,6 +80,9 @@
let origin = $state(ALL);
let set = $state(ALL);
let onlyNew = $state(false);
let onlyCallback = $state(false);
let oast = $state<OastRead | null>(null);
let callbackOpen = $state(false);
let seenAt = $state<string | null>(null);
let reqId = 0;
@@ -159,6 +170,7 @@
const org = origin === ALL ? [] : [origin];
const chosen = set === ALL ? [] : [set];
const newSince = onlyNew ? seenAt : null;
const callback = onlyCallback;
untrack(() => {
filter = {
...filter,
@@ -167,11 +179,42 @@
origins: org,
sets: chosen,
new_since: newSince,
callback,
offset: 0
};
});
});
const callbackState = $derived(
!oast || oast.mode === OastMode.OFF ? 'off' : oast.reason ? 'blocked' : 'on'
);
const CALLBACK_DOT: Record<string, string> = {
on: 'bg-success',
blocked: 'bg-warning',
off: 'bg-muted-foreground/40'
};
onMount(() => {
void oastApi
.get()
.then((row) => (oast = row))
.catch(() => (oast = null));
if (route.url.searchParams.get(PANEL_PARAM) === CALLBACK_PANEL) callbackOpen = true;
});
$effect(() => {
if (callbackOpen) return;
const params = untrack(() => new URLSearchParams(route.url.searchParams));
if (params.get(PANEL_PARAM) !== CALLBACK_PANEL) return;
params.delete(PANEL_PARAM);
const qs = params.toString();
try {
replaceState(qs ? `?${qs}` : location.pathname, {});
} catch {
// ignore
}
});
async function sync() {
syncing = true;
try {
@@ -258,7 +301,14 @@
{#if stats?.last_synced_at}
<Card.Description>Synced {relativeTime(stats.last_synced_at)}</Card.Description>
{/if}
<Card.Action class="flex items-center gap-2">
<Card.Action class="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" size="sm" class="gap-2" onclick={() => (callbackOpen = true)}>
<span class="size-2 shrink-0 rounded-full {CALLBACK_DOT[callbackState]}"></span>
{CALLBACK_SERVER}
<span class="text-muted-foreground">
{callbackState === 'on' && oast ? OAST_MODE_LABELS[oast.mode as OastMode] : 'Off'}
</span>
</Button>
<input
bind:this={fileInput}
type="file"
@@ -343,6 +393,19 @@
</span>
<span class="text-xs text-muted-foreground">{TEMPLATE_ORIGIN_LABELS.custom}</span>
</div>
{#if stats.callback > 0}
<button
type="button"
class="-mx-2 -my-1 flex flex-col rounded-md px-2 py-1 text-left transition-colors hover:bg-muted/60 aria-pressed:bg-muted"
aria-pressed={onlyCallback}
onclick={() => (onlyCallback = !onlyCallback)}
>
<span class="font-mono text-sm leading-6 tabular-nums">
{stats.callback.toLocaleString()}
</span>
<span class="text-xs text-muted-foreground">Need a callback</span>
</button>
{/if}
<div class="flex min-w-64 flex-1 flex-col gap-2">
<SeverityBar counts={severityCounts} height="h-1.5" />
<div class="flex flex-wrap gap-x-4 gap-y-1">
@@ -419,6 +482,18 @@
</ToggleGroup.Item>
</ToggleGroup.Root>
{/if}
{#if onlyCallback}
<Button
variant="secondary"
size="sm"
class="h-9 gap-1.5"
aria-label="Clear the need a callback filter"
onclick={() => (onlyCallback = false)}
>
Need a callback
<X class="size-3.5" />
</Button>
{/if}
<Button
variant="outline"
size="icon"
@@ -616,3 +691,5 @@
}}
onClear={() => picked.clear()}
/>
<CallbackServerSheet bind:open={callbackOpen} settings={oast} onSaved={(row) => (oast = row)} />
@@ -0,0 +1,251 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import * as Select from '$lib/components/ui/select/index.js';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { bountyProgramsApi } from '$lib/api/bounty-programs';
import { auth } from '$lib/stores/auth.svelte';
import { ROUTES, routeLabels } from '$lib/config/routes';
import { SYNC_INTERVAL_LABELS } from '$lib/config/bounty-programs';
import { relativeTime, untilTime } from '$lib/utilities/dates';
import type {
BountyEventSpec,
BountySettings,
BountySettingsUpdate,
BountyVocabulary
} from '$lib/types/bounty-program';
interface Props {
open: boolean;
onSaved?: (settings: BountySettings) => void;
}
let { open = $bindable(), onSaved }: Props = $props();
const LABEL = 'text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase';
const OFF = 'off';
let settings = $state<BountySettings | null>(null);
let vocabulary = $state<BountyVocabulary | null>(null);
let loadError = $state<string | null>(null);
let saving = $state(false);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const connectable = $derived((settings?.platforms ?? []).filter((p) => p.api_provider));
const specs = $derived<BountyEventSpec[]>(
(settings?.notifiable_events ?? [])
.map((kind) => vocabulary?.events.find((e) => e.kind === kind))
.filter((e): e is BountyEventSpec => Boolean(e))
);
async function load() {
try {
[settings, vocabulary] = await Promise.all([
bountyProgramsApi.settings(),
bountyProgramsApi.vocabulary()
]);
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : 'Request failed.';
}
}
$effect(() => {
if (open) void load();
});
function schedule(interval: string, last: string | null, next: string | null): string {
const parts: string[] = [];
if (last) parts.push(`Last ${relativeTime(last)}`);
if (interval !== OFF && next)
parts.push(untilTime(next) ? `Next ${untilTime(next)}` : 'Overdue');
return parts.join(' · ');
}
async function save(patch: BountySettingsUpdate, label: string) {
saving = true;
try {
settings = await bountyProgramsApi.saveSettings(patch);
onSaved?.(settings);
toast.success(`${label} saved`);
} catch (e) {
toast.error(e instanceof Error ? e.message : `${label} not saved`);
await load();
} finally {
saving = false;
}
}
function toggleEvent(kind: string, on: boolean) {
const current = settings?.notify_events ?? [];
const next = on ? [...current, kind] : current.filter((k) => k !== kind);
void save({ notify_events: [...new Set(next)] }, 'Alerts');
}
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<Sheet.Header class="border-b px-5 py-4">
<Sheet.Title>{routeLabels['bounty-hub']} settings</Sheet.Title>
</Sheet.Header>
{#if loadError && !settings}
<div class="flex flex-col items-start gap-3 px-5 py-6 text-sm">
<p class="text-muted-foreground">{loadError}</p>
<Button variant="outline" size="sm" onclick={load}>Retry</Button>
</div>
{:else if !settings}
<div class="flex flex-col gap-3 px-5 py-6">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
</div>
{:else}
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col divide-y px-5">
<section class="flex flex-col gap-3 py-5">
<h3 class={LABEL}>Platforms</h3>
{#each connectable as platform (platform.platform)}
<div class="flex items-center justify-between gap-4 text-sm">
<span class="font-medium">{platform.label}</span>
{#if platform.configured}
<span class="text-xs text-muted-foreground tabular-nums">
{platform.programs.toLocaleString()} programs
{#if platform.private_programs > 0}
· {platform.private_programs.toLocaleString()} private
{/if}
</span>
{:else}
<a
href={ROUTES.settings('api-keys')}
class="text-xs text-primary transition-colors hover:text-foreground"
>
Add key
</a>
{/if}
</div>
{/each}
</section>
<section class="flex flex-col gap-4 py-5">
<h3 class={LABEL}>Sync</h3>
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Platform APIs</span>
<span class="text-xs text-muted-foreground">
{schedule(settings.sync_interval, settings.last_synced_at, settings.next_sync_at)}
</span>
</div>
<Select.Root
type="single"
value={settings.sync_interval}
onValueChange={(v) => v && save({ sync_interval: v }, 'Platform sync')}
disabled={!isAdmin || saving}
>
<Select.Trigger class="h-9 w-40 shrink-0" aria-label="Platform API interval">
{SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval}
</Select.Trigger>
<Select.Content>
{#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)}
<Select.Item {value} {label}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Public feed</span>
<span class="text-xs text-muted-foreground">
{schedule(
settings.feed_interval,
settings.feed_synced_at,
settings.feed_next_sync_at
)}
</span>
<a
href={settings.feed_url}
target="_blank"
rel="noreferrer noopener"
class="w-fit text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{settings.feed_source} · {settings.feed_license}
</a>
</div>
<Select.Root
type="single"
value={settings.feed_interval}
onValueChange={(v) => v && save({ feed_interval: v }, 'Feed sync')}
disabled={!isAdmin || saving}
>
<Select.Trigger class="h-9 w-40 shrink-0" aria-label="Public feed interval">
{SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval}
</Select.Trigger>
<Select.Content>
{#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)}
<Select.Item {value} {label}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</section>
<section class="flex flex-col gap-3 py-5">
<h3 class={LABEL}>Alerts</h3>
<label class="flex items-start justify-between gap-4" for="bounty-notify">
<span class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Program changes</span>
<span class="text-xs text-muted-foreground">
Inbox and channels subscribed to Program changes
</span>
</span>
<Switch
id="bounty-notify"
checked={settings.notify}
disabled={!isAdmin || saving}
onCheckedChange={(v) => save({ notify: v }, 'Alerts')}
/>
</label>
{#if settings.notify}
<div class="flex flex-col divide-y rounded-lg border">
{#each specs as spec (spec.kind)}
<label
class="flex items-start justify-between gap-4 px-3 py-2.5"
for="bounty-event-{spec.kind}"
>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm">{spec.label}</span>
<span class="text-xs text-muted-foreground">{spec.description}</span>
</span>
<Switch
id="bounty-event-{spec.kind}"
checked={settings.notify_events.includes(spec.kind)}
disabled={!isAdmin || saving}
onCheckedChange={(v) => toggleEvent(spec.kind, v)}
/>
</label>
{/each}
</div>
{/if}
<p class="text-xs text-muted-foreground tabular-nums">
{settings.events_recorded.toLocaleString()} changes recorded ·
<a
href={ROUTES.settings('notifications')}
class="text-primary transition-colors hover:text-foreground"
>
Notification channels
</a>
</p>
</section>
</div>
</ScrollArea>
{#if !isAdmin}
<p class="border-t px-5 py-2.5 text-xs text-muted-foreground">
Editable by administrators.
</p>
{/if}
{/if}
</Sheet.Content>
</Sheet.Root>
@@ -9,7 +9,6 @@
import { SUBMISSION_STATE_LABELS } from '$lib/config/bounty-programs';
import { bountyVocabulary } from '$lib/stores/bounty-vocabulary.svelte';
import { SubmissionState } from '$lib/types/bounty-program';
import { ROUTES } from '$lib/config/routes';
import { relativeTime } from '$lib/utilities/dates';
import {
WatchStatus,
@@ -91,7 +90,14 @@
{:else}
<span>Certificate stream not running. Check that the ct-stream service is up.</span>
{/if}
<a href={ROUTES.settings('bounty-hub')} class="hover:underline">Details</a>
{#if stream.last_error}
<span class="text-destructive">
{stream.last_error}
{#if stream.last_error_at}
<span class="text-muted-foreground">· {relativeTime(stream.last_error_at)}</span>
{/if}
</span>
{/if}
</div>
{/if}
@@ -88,7 +88,7 @@
{/if}
<a
href={ROUTES.settings()}
href={ROUTES.settings('proxies')}
class="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Manage proxies in Settings
@@ -22,14 +22,11 @@
import { ONBOARDING_NOTIFICATION_PROVIDERS as PROVIDERS } from '$lib/config/notification-providers';
import type { StepProps } from '$lib/types/onboarding';
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
import { Capability } from '$lib/config/capabilities';
import { SvelteSet } from 'svelte/reactivity';
let { next, setFooter }: StepProps = $props();
const categories = $derived(
NOTIF_CATEGORIES.filter(
(c) => c.value !== 'watch' || capabilitiesStore.has(Capability.PROGRAM_WATCHES)
)
NOTIF_CATEGORIES.filter((c) => !c.capability || capabilitiesStore.has(c.capability))
);
interface ChannelDraft {
@@ -153,7 +153,6 @@
if (!endpoints) return null;
const created = await proxiesApi.create({
name: `${data.instanceName || 'reNgine'} Proxy`,
mode: choice === 'single' ? 'rotating' : 'single',
is_active: true,
is_default: asDefault,
endpoints
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -19,6 +19,8 @@
} from '$lib/components/contexts/context-form';
import { scanContextsStore } from '$lib/stores/scan-contexts.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { proxiesStore } from '$lib/stores/proxies.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { DEFAULT_SCAN_CONTEXT, type ScanContextCreate } from '$lib/types/scan-context';
interface Props {
@@ -46,6 +48,18 @@
let validation = $derived(validateDraft(draft));
$effect(() => {
if (auth.user?.is_superuser && !proxiesStore.hasFetched) untrack(() => proxiesStore.fetch());
});
let proxyPreset = false;
$effect(() => {
const fallback = proxiesStore.defaultId;
if (proxyPreset || !proxiesStore.hasFetched) return;
proxyPreset = true;
if (fallback && !draft.proxy_id) untrack(() => (draft.proxy_id = fallback));
});
function seed(): ScanContextCreate {
const d = DEFAULT_SCAN_CONTEXT();
d.name = targetValue ? `${targetValue} scope` : '';
@@ -1,413 +0,0 @@
<script lang="ts">
import { APIProvider, type APIKeyRead, type ProviderInfo } from '$lib/types/api-key';
import { getProviderIcon } from '$lib/config/icons';
import * as Card from '$lib/components/ui/card/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import LoadingButton from '$lib/components/loading-button.svelte';
import FormField from '$lib/components/form-field.svelte';
import { writeClipboard } from '$lib/utilities/clipboard';
import { toast } from 'svelte-sonner';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import PlusIcon from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import FlaskConicalIcon from '@lucide/svelte/icons/flask-conical';
import ActivityIcon from '@lucide/svelte/icons/activity';
import KeyIcon from '@lucide/svelte/icons/key';
import UserIcon from '@lucide/svelte/icons/user';
import EyeIcon from '@lucide/svelte/icons/eye';
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
import CopyIcon from '@lucide/svelte/icons/copy';
import CheckIcon from '@lucide/svelte/icons/check';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { formatDate } from '$lib/utilities';
interface Props {
provider: ProviderInfo;
apiKey: APIKeyRead | undefined;
testing: boolean;
onToggle: (provider: APIProvider, enabled: boolean) => void;
onAdd: (provider: APIProvider) => void;
onTest: (provider: APIProvider, keyId: string) => void;
onDelete: (provider: APIProvider) => void;
onReveal: (keyId: string) => Promise<string>;
onEditSave: (provider: APIProvider, keyValue: string, username: string) => Promise<boolean>;
}
let {
provider,
apiKey,
testing,
onToggle,
onAdd,
onTest,
onDelete,
onReveal,
onEditSave
}: Props = $props();
const Icon = $derived(getProviderIcon(provider.icon));
const isConfigured = $derived(!!apiKey);
let revealedKeyValue = $state<string | null>(null);
let revealLoading = $state(false);
let revealError = $state(false);
let copied = $state(false);
let editOpen = $state(false);
let editKeyValue = $state('');
let editUsername = $state('');
let editSaving = $state(false);
let editShowKey = $state(false);
function resetReveal() {
revealedKeyValue = null;
revealLoading = false;
revealError = false;
copied = false;
}
async function reveal(keyId: string) {
revealedKeyValue = null;
revealError = false;
revealLoading = true;
try {
revealedKeyValue = await onReveal(keyId);
} catch {
revealError = true;
} finally {
revealLoading = false;
}
}
async function copyKey(value: string) {
if (await writeClipboard(value)) {
copied = true;
setTimeout(() => (copied = false), 2000);
} else {
toast.error('Copy failed');
}
}
function resetEdit() {
editKeyValue = '';
editUsername = '';
editSaving = false;
editShowKey = false;
}
async function saveEdit() {
editSaving = true;
try {
if (await onEditSave(provider.provider, editKeyValue.trim(), editUsername.trim())) {
editOpen = false;
}
} finally {
editSaving = false;
}
}
</script>
<Card.Root
class="relative transition-all duration-200 {isConfigured
? 'ring-1 ring-border'
: 'border-dashed opacity-75 hover:opacity-100'}"
>
<Card.Content class="p-5">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3 min-w-0">
<div class="shrink-0 rounded-lg border bg-muted p-2.5">
<Icon class="size-[18px] text-muted-foreground" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-medium text-sm">{provider.name}</h4>
<a
href={provider.docs_url}
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLinkIcon class="size-3.5" />
</a>
</div>
<p class="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{provider.description}
</p>
</div>
</div>
{#if apiKey}
<Switch
checked={apiKey.is_enabled}
onCheckedChange={(checked) => onToggle(provider.provider, checked)}
/>
{/if}
</div>
{#if apiKey}
<Separator class="my-3" />
<div class="space-y-3">
{#if provider.requires_username && apiKey.key_meta?.username}
<div class="flex items-center gap-2">
<UserIcon class="size-3.5 text-muted-foreground shrink-0" />
<code class="text-xs font-mono text-muted-foreground truncate">
{apiKey.key_meta.username}
</code>
</div>
{/if}
<div class="flex items-center gap-2">
<KeyIcon class="size-3.5 text-muted-foreground shrink-0" />
<code class="text-xs font-mono text-muted-foreground truncate">
{apiKey.key_value_masked}
</code>
<Popover.Root
onOpenChange={(open) => {
if (open) reveal(apiKey.id);
else resetReveal();
}}
>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Reveal key"
>
<EyeIcon class="size-3.5" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80" align="start">
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-xs font-medium">API key</Label>
{#if revealedKeyValue}
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-foreground"
onclick={() => copyKey(revealedKeyValue ?? '')}
>
{#if copied}
<CheckIcon class="size-3.5 text-foreground" />
{:else}
<CopyIcon class="size-3.5" />
{/if}
</Button>
{/if}
</div>
{#if revealLoading}
<div class="flex items-center justify-center py-3">
<Spinner class="size-4" />
</div>
{:else if revealedKeyValue}
<code
class="block text-xs font-mono text-foreground bg-muted px-3 py-2.5 rounded-md break-all select-all leading-relaxed"
>
{revealedKeyValue}
</code>
{:else if revealError}
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">Key not loaded</p>
<Button
variant="outline"
size="sm"
class="h-7 px-2 text-xs"
onclick={() => reveal(apiKey.id)}
>
<RefreshCwIcon class="size-3 mr-1" />
Retry
</Button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
{#if apiKey.is_enabled}
<Badge variant="secondary" class="h-5 text-2xs px-1.5 shrink-0">Active</Badge>
{:else}
<Badge
variant="secondary"
class="h-5 text-2xs px-1.5 bg-muted text-muted-foreground border-0 shrink-0"
>
Disabled
</Badge>
{/if}
</div>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="flex items-center gap-1">
<ActivityIcon class="size-3" />
{apiKey.usage_counter} calls
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>API calls</Tooltip.Content>
</Tooltip.Root>
{#if apiKey.last_used_at}
<span>Last used {formatDate(apiKey.last_used_at)}</span>
{:else}
<span>Unused</span>
{/if}
</div>
<div class="flex items-center gap-1.5 pt-1">
<Popover.Root
open={editOpen}
onOpenChange={(open) => {
if (open) {
editOpen = true;
editKeyValue = '';
editUsername = (apiKey.key_meta?.username as string) ?? '';
} else {
editOpen = false;
resetEdit();
}
}}
>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" class="h-7 px-2 text-xs">
<Pencil class="size-3 mr-1" />
Edit
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80" align="start">
<div class="space-y-3">
<div>
<h4 class="text-sm font-medium">Update API key</h4>
</div>
{#if provider.requires_username}
<FormField label="Username">
{#snippet children({ id })}
<Input
{id}
type="text"
bind:value={editUsername}
placeholder="Account username"
disabled={editSaving}
class="h-8 text-xs"
/>
{/snippet}
</FormField>
{/if}
<FormField label="New API key">
{#snippet children({ id })}
<div class="relative">
<Input
{id}
type={editShowKey ? 'text' : 'password'}
bind:value={editKeyValue}
placeholder="API key"
disabled={editSaving}
class="h-8 text-xs pr-8"
/>
<Button
variant="ghost"
size="icon"
class="absolute right-1 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={editShowKey ? 'Hide key' : 'Show key'}
aria-pressed={editShowKey}
onclick={() => (editShowKey = !editShowKey)}
>
{#if editShowKey}
<EyeOffIcon class="size-3.5" />
{:else}
<EyeIcon class="size-3.5" />
{/if}
</Button>
</div>
{/snippet}
</FormField>
<LoadingButton
size="sm"
class="w-full h-8 text-xs"
loading={editSaving}
loadingLabel="Saving"
disabled={!editKeyValue.trim() ||
(provider.requires_username && !editUsername.trim())}
onclick={saveEdit}
>
Update key
</LoadingButton>
</div>
</Popover.Content>
</Popover.Root>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
disabled={testing}
onclick={() => onTest(provider.provider, apiKey.id)}
>
{#if testing}
<Spinner class="size-3 mr-1" />
{:else}
<FlaskConicalIcon class="size-3 mr-1" />
{/if}
Test
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Test key</Tooltip.Content>
</Tooltip.Root>
<div class="flex-1"></div>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onclick={() => onDelete(provider.provider)}
>
<Trash2Icon class="size-3" />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Remove API key</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{:else}
<div class="mt-4">
<Button
variant="outline"
size="sm"
class="w-full h-8 text-xs"
onclick={() => onAdd(provider.provider)}
>
<PlusIcon class="size-3.5 mr-1.5" />
Add API key
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -1,71 +1,96 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { apiKeysApi } from '$lib/api/api-keys';
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
import { providerAllowed } from '$lib/config/capabilities';
import { APIProvider, type APIKeyRead, type ProviderInfo } from '$lib/types/api-key';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import LoadingButton from '$lib/components/loading-button.svelte';
import DeleteConfirmationDialog from '$lib/components/delete-confirmation-dialog.svelte';
import FormField from '$lib/components/form-field.svelte';
import ApiKeyProviderCard from './api-key-provider-card.svelte';
import { toast } from 'svelte-sonner';
import { SvelteMap } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import EyeIcon from '@lucide/svelte/icons/eye';
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import KeyRoundIcon from '@lucide/svelte/icons/key-round';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
import RotateCwIcon from '@lucide/svelte/icons/rotate-cw';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import ConfirmDialog from '$lib/components/confirm-dialog.svelte';
import EmptyState from '$lib/components/empty-state.svelte';
import FormField from '$lib/components/form-field.svelte';
import Hint from '$lib/components/hint.svelte';
import LoadingButton from '$lib/components/loading-button.svelte';
import { apiKeysApi } from '$lib/api/api-keys';
import { auth } from '$lib/stores/auth.svelte';
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
import { providerAllowed } from '$lib/config/capabilities';
import { writeClipboard } from '$lib/utilities/clipboard';
import { relativeTime } from '$lib/utilities/dates';
import type { APIKeyRead, ProviderInfo } from '$lib/types/api-key';
import { ProviderGroup } from '$lib/config/api-keys';
import { BODY_ROW, GROUP_ROW, HEAD_ROW, KEY_COL } from './columns';
import { CHECK_DOT, checkState, type CheckState } from './status';
const GROUP_ORDER = Object.values(ProviderGroup);
const STATUS_LABEL: Record<CheckState, string> = {
ok: 'Valid',
failed: 'Rejected',
untested: 'Not tested',
off: 'Disabled'
};
let providers = $state<ProviderInfo[]>([]);
let configuredKeys = new SvelteMap<string, APIKeyRead>();
let isLoading = $state(true);
const keys = new SvelteMap<string, APIKeyRead>();
let loading = $state(true);
let loadError = $state<string | null>(null);
let testing = $state<string | null>(null);
let addDialogOpen = $state(false);
let addDialogProvider = $state<APIProvider | null>(null);
let addDialogKeyValue = $state('');
let addDialogUsername = $state('');
let addDialogSaving = $state(false);
let addShowKey = $state(false);
let addUsernameInput = $state<HTMLInputElement | null>(null);
let addKeyInput = $state<HTMLInputElement | null>(null);
let editing = $state<ProviderInfo | null>(null);
let dialogOpen = $state(false);
let keyValue = $state('');
let username = $state('');
let showKey = $state(false);
let saving = $state(false);
let usernameInput = $state<HTMLInputElement | null>(null);
let keyInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (addDialogOpen) {
tick().then(() => (addUsernameInput ?? addKeyInput)?.focus());
let removing = $state<ProviderInfo | null>(null);
let deleting = $state(false);
let revealed = $state<string | null>(null);
let revealFailed = $state(false);
let copied = $state(false);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const replacing = $derived(editing ? keys.has(editing.provider) : false);
const canSave = $derived(
!saving && !!keyValue.trim() && (!editing?.requires_username || !!username.trim() || replacing)
);
const groups = $derived.by(() => {
const out: { group: string; label: string; items: ProviderInfo[] }[] = [];
for (const provider of providers) {
let entry = out.find((g) => g.group === provider.group);
if (!entry) {
entry = { group: provider.group, label: provider.group_label, items: [] };
out.push(entry);
}
entry.items.push(provider);
}
return out.sort(
(a, b) =>
GROUP_ORDER.indexOf(a.group as ProviderGroup) -
GROUP_ORDER.indexOf(b.group as ProviderGroup)
);
});
const setCount = $derived(providers.filter((p) => keys.has(p.provider)).length);
let addRequiresUsername = $derived(
!!providers.find((p) => p.provider === addDialogProvider)?.requires_username
);
let addCanSave = $derived(
!addDialogSaving &&
!!addDialogKeyValue.trim() &&
(!addRequiresUsername || !!addDialogUsername.trim())
);
let deleteDialogOpen = $state(false);
let deletingProvider = $state<APIProvider | null>(null);
let deletingKeyId = $state<string | null>(null);
let isDeleting = $state(false);
let testingKeyId = $state<string | null>(null);
let testDialogOpen = $state(false);
let testDialogKeyId = $state<string | null>(null);
let testDialogProvider = $state<APIProvider | null>(null);
async function fetchData() {
isLoading = true;
async function load() {
loading = true;
loadError = null;
try {
const [providerList, keyList] = await Promise.all([
@@ -73,367 +98,415 @@
apiKeysApi.list()
]);
providers = providerList.filter((p) => providerAllowed(capabilitiesStore.mode, p.provider));
configuredKeys.clear();
for (const k of keyList) configuredKeys.set(k.provider, k);
keys.clear();
for (const key of keyList) keys.set(key.provider, key);
} catch (e) {
loadError = e instanceof Error ? e.message : 'API keys not loaded';
toast.error(loadError);
} finally {
isLoading = false;
}
}
function openAddDialog(provider: APIProvider) {
addDialogProvider = provider;
addDialogKeyValue = '';
addDialogUsername = '';
addShowKey = false;
addDialogOpen = true;
}
async function handleAdd() {
if (!addDialogProvider || !addDialogKeyValue.trim()) {
toast.error('API key is required');
return;
}
const addMeta = providers.find((p) => p.provider === addDialogProvider);
if (addMeta?.requires_username && !addDialogUsername.trim()) {
toast.error('Username is required');
return;
}
addDialogSaving = true;
try {
const created = await apiKeysApi.create({
provider: addDialogProvider,
key_value: addDialogKeyValue.trim(),
...(addMeta?.requires_username ? { key_meta: { username: addDialogUsername.trim() } } : {})
});
configuredKeys.set(created.provider, created);
const meta = providers.find((p) => p.provider === addDialogProvider);
toast.success(`${meta?.name ?? 'Provider'} API key added`);
await refreshProviders();
addDialogOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'API key not added');
} finally {
addDialogSaving = false;
}
}
async function handleEditSave(
provider: APIProvider,
keyValue: string,
username: string
): Promise<boolean> {
const key = configuredKeys.get(provider);
if (!key || !keyValue) {
toast.error('API key is required');
return false;
}
const editMeta = providers.find((p) => p.provider === provider);
if (editMeta?.requires_username && !username) {
toast.error('Username is required');
return false;
}
try {
const updated = await apiKeysApi.update(key.id, {
key_value: keyValue,
...(editMeta?.requires_username ? { key_meta: { username } } : {})
});
configuredKeys.set(updated.provider, updated);
toast.success('API key updated');
return true;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'API key not updated');
return false;
}
}
async function handleToggle(provider: APIProvider, enabled: boolean) {
const key = configuredKeys.get(provider);
if (!key) return;
try {
const updated = await apiKeysApi.update(key.id, { is_enabled: enabled });
configuredKeys.set(updated.provider, updated);
await refreshProviders();
toast.success(`${key.meta.name} ${enabled ? 'enabled' : 'disabled'}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'API key not updated');
}
}
function openDeleteDialog(provider: APIProvider) {
const key = configuredKeys.get(provider);
if (!key) return;
deletingProvider = provider;
deletingKeyId = key.id;
deleteDialogOpen = true;
}
async function handleDelete() {
if (!deletingKeyId || !deletingProvider) return;
isDeleting = true;
try {
await apiKeysApi.delete(deletingKeyId);
configuredKeys.delete(deletingProvider);
await refreshProviders();
toast.success('API key removed');
deleteDialogOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'API key not removed');
} finally {
isDeleting = false;
}
}
function openTestDialog(provider: APIProvider, keyId: string) {
testDialogKeyId = keyId;
testDialogProvider = provider;
testDialogOpen = true;
}
async function handleTest(keyId: string) {
testingKeyId = keyId;
try {
const result = await apiKeysApi.test(keyId);
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Test failed');
} finally {
testingKeyId = null;
}
}
async function handleReveal(keyId: string): Promise<string> {
const result = await apiKeysApi.reveal(keyId);
return result.key_value;
}
async function refreshProviders() {
try {
providers = (await apiKeysApi.listProviders()).filter((p) =>
providerAllowed(capabilitiesStore.mode, p.provider)
);
} catch {
/* empty */
loading = false;
}
}
onMount(() => {
fetchData();
if (isAdmin) void load();
else loading = false;
});
function stateOf(provider: ProviderInfo): CheckState {
const key = keys.get(provider.provider);
if (!key) return 'off';
return checkState(key.is_enabled, key.last_test_ok);
}
async function openDialog(provider: ProviderInfo) {
editing = provider;
keyValue = '';
username = String(keys.get(provider.provider)?.key_meta?.username ?? '');
showKey = false;
dialogOpen = true;
await tick();
(provider.requires_username && !username ? usernameInput : keyInput)?.focus();
}
async function saveKey() {
const provider = editing;
if (!provider || !canSave) return;
saving = true;
const meta = provider.requires_username ? { key_meta: { username: username.trim() } } : {};
try {
const existing = keys.get(provider.provider);
const stored = existing
? await apiKeysApi.update(existing.id, { key_value: keyValue.trim(), ...meta })
: await apiKeysApi.create({
provider: provider.provider,
key_value: keyValue.trim(),
...meta
});
keys.set(stored.provider, stored);
toast.success(`${provider.name} key ${existing ? 'replaced' : 'added'}`);
dialogOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : `${provider.name} key not saved`);
} finally {
saving = false;
}
}
async function test(provider: ProviderInfo) {
const key = keys.get(provider.provider);
if (!key) return;
testing = provider.provider;
try {
const result = await apiKeysApi.test(key.id);
keys.set(provider.provider, {
...key,
last_test_at: new Date().toISOString(),
last_test_ok: result.success,
last_test_message: result.message
});
if (result.success) toast.success(result.message);
else toast.error(result.message);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Key not tested');
} finally {
testing = null;
}
}
async function setEnabled(provider: ProviderInfo, enabled: boolean) {
const key = keys.get(provider.provider);
if (!key) return;
try {
keys.set(provider.provider, await apiKeysApi.update(key.id, { is_enabled: enabled }));
toast.success(`${provider.name} key ${enabled ? 'enabled' : 'disabled'}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Key not updated');
}
}
async function remove() {
const provider = removing;
const key = provider ? keys.get(provider.provider) : undefined;
if (!provider || !key) return;
deleting = true;
try {
await apiKeysApi.delete(key.id);
keys.delete(provider.provider);
toast.success(`${provider.name} key removed`);
removing = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Key not removed');
} finally {
deleting = false;
}
}
async function reveal(key: APIKeyRead) {
revealed = null;
revealFailed = false;
try {
revealed = (await apiKeysApi.reveal(key.id)).key_value;
} catch {
revealFailed = true;
}
}
async function copy() {
if (revealed && (await writeClipboard(revealed))) {
copied = true;
setTimeout(() => (copied = false), 2000);
} else {
toast.error('Copy failed');
}
}
</script>
<div class="space-y-6">
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold tracking-tight">API keys</h2>
<p class="text-sm text-muted-foreground">
Keys for external intelligence providers. Shared by every project.
</p>
</div>
<Badge variant="outline" class="text-xs">
{configuredKeys.size} / {providers.length} configured
</Badge>
</div>
<Separator />
{#if isLoading}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each Array(6) as _, i (i)}
<Card.Root>
<Card.Content class="p-5">
<div class="flex items-start gap-3">
<Skeleton class="size-10 rounded-lg" />
<div class="flex-1 space-y-2">
<Skeleton class="h-5 w-28" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
</div>
{#if !isAdmin}
<EmptyState compact icon={KeyRoundIcon} title="API keys are managed by administrators" />
{:else if loading}
<Card.Root class="gap-3 p-4">
<Skeleton class="h-8 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</Card.Root>
{:else if loadError}
<EmptyState compact icon={TriangleAlertIcon} title="API keys not loaded" description={loadError}>
<Button variant="outline" size="sm" onclick={load}>
<RotateCwIcon class="size-3.5" />
Retry
</Button>
</EmptyState>
{:else}
<Card.Root class="gap-0 overflow-hidden py-0">
<div class="@container/keys w-full" role="table" aria-label="API keys">
<div class={HEAD_ROW} role="row">
<div class={KEY_COL.provider}>Provider</div>
<div class={KEY_COL.key}>Key</div>
<div class={KEY_COL.status}>Status</div>
<div class={KEY_COL.tested}>Last tested</div>
<div class="{KEY_COL.actions} normal-case tracking-normal tabular-nums">
{setCount} of {providers.length} set
</div>
</div>
{#each groups as group (group.group)}
<div class={GROUP_ROW} role="row">{group.label}</div>
{#each group.items as provider (provider.provider)}
{@const key = keys.get(provider.provider)}
{@const check = stateOf(provider)}
<div class={BODY_ROW} role="row">
<div class="{KEY_COL.provider} flex flex-col">
<span class="flex items-center gap-1.5 text-sm leading-5 font-medium">
{provider.name}
<a
href={provider.docs_url}
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground transition-colors hover:text-foreground"
aria-label="{provider.name} documentation"
>
<ExternalLinkIcon class="size-3" />
</a>
</span>
<span class="text-2xs text-muted-foreground">{provider.description}</span>
</div>
</Card.Content>
</Card.Root>
<div class={KEY_COL.key}>
{#if key}
<div class="flex items-center gap-1">
<span class="flex min-w-0 flex-col">
<code class="truncate font-mono text-xs text-muted-foreground">
{key.key_value_masked}
</code>
{#if key.key_meta?.username}
<code class="truncate font-mono text-2xs text-muted-foreground">
{key.key_meta.username}
</code>
{/if}
</span>
<Popover.Root
onOpenChange={(open) => {
if (open) void reveal(key);
else revealed = null;
}}
>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground"
aria-label="Reveal {provider.name} key"
>
<EyeIcon class="size-3.5" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80" align="start">
{#if revealed}
<div class="flex items-start gap-2">
<code
class="min-w-0 flex-1 rounded-md bg-muted px-2.5 py-2 font-mono text-xs break-all select-all"
>
{revealed}
</code>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
aria-label="Copy key"
onclick={copy}
>
{#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon
class="size-3.5"
/>{/if}
</Button>
</div>
{:else if revealFailed}
<p class="text-xs text-muted-foreground">Key not loaded.</p>
{:else}
<div class="flex justify-center py-2"><Spinner class="size-4" /></div>
{/if}
</Popover.Content>
</Popover.Root>
</div>
{:else}
<span class="text-xs text-muted-foreground">Not set</span>
{/if}
</div>
<div class={KEY_COL.status}>
{#if key}
<Hint text={check === 'failed' ? key.last_test_message : null}>
{#snippet child(props)}
<span {...props} class="inline-flex items-center gap-2 text-sm">
{#if testing === provider.provider}
<Spinner class="size-3" />
<span class="text-muted-foreground">Testing</span>
{:else}
<span class="size-2 shrink-0 rounded-full {CHECK_DOT[check]}"></span>
<span
class={check === 'ok' || check === 'failed'
? ''
: 'text-muted-foreground'}
>
{STATUS_LABEL[check]}
</span>
{/if}
</span>
{/snippet}
</Hint>
{:else}
<span class="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span class="size-2 shrink-0 rounded-full {CHECK_DOT.off}"></span>
Not set
</span>
{/if}
</div>
<div class="{KEY_COL.tested} text-xs text-muted-foreground tabular-nums">
{key?.last_test_at ? relativeTime(key.last_test_at) : 'Never'}
</div>
<div class={KEY_COL.actions}>
{#if key}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-7">
<MoreVerticalIcon class="size-4" />
<span class="sr-only">{provider.name} key actions</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if provider.testable}
<DropdownMenu.Item
disabled={testing !== null || !key.is_enabled}
onSelect={() => test(provider)}
>
Test
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onSelect={() => openDialog(provider)}>
Replace key
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setEnabled(provider, !key.is_enabled)}>
{key.is_enabled ? 'Disable' : 'Enable'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => (removing = provider)}>
Remove
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<Button
variant="outline"
size="sm"
class="h-7"
onclick={() => openDialog(provider)}
>
Add key
</Button>
{/if}
</div>
</div>
{/each}
{/each}
</div>
{:else if loadError}
<EmptyState
compact
icon={TriangleAlertIcon}
title="API keys not loaded"
description={loadError}
>
<Button variant="outline" size="sm" onclick={fetchData}>
<RefreshCwIcon class="mr-1.5 size-3.5" />
Retry
</Button>
</EmptyState>
{:else}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each providers as provider (provider.provider)}
<ApiKeyProviderCard
{provider}
apiKey={configuredKeys.get(provider.provider)}
testing={testingKeyId === configuredKeys.get(provider.provider)?.id}
onToggle={handleToggle}
onAdd={openAddDialog}
onTest={openTestDialog}
onDelete={openDeleteDialog}
onReveal={handleReveal}
onEditSave={handleEditSave}
/>
{/each}
</div>
{/if}
</div>
</Card.Root>
{/if}
<Dialog.Root bind:open={addDialogOpen}>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Add API key</Dialog.Title>
<Dialog.Description>
{#if addDialogProvider}
{@const meta = providers.find((p) => p.provider === addDialogProvider)}
{#if meta}
<a
href={meta.docs_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:underline"
>
Get a {meta.name} key <ExternalLinkIcon class="size-3" />
</a>
{/if}
{/if}
</Dialog.Description>
<Dialog.Title>
{editing ? `${replacing ? 'Replace' : 'Add'} ${editing.name} key` : 'API key'}
</Dialog.Title>
{#if editing}
<Dialog.Description>
<a
href={editing.docs_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary transition-colors hover:text-foreground"
>
{editing.name} documentation <ExternalLinkIcon class="size-3" />
</a>
</Dialog.Description>
{/if}
</Dialog.Header>
<form
class="flex flex-col gap-4"
onsubmit={(e) => {
e.preventDefault();
if (addCanSave) handleAdd();
void saveKey();
}}
>
<div class="space-y-4 py-2">
{#if addRequiresUsername}
<FormField label="Username">
{#snippet children({ id })}
<Input
{id}
type="text"
bind:ref={addUsernameInput}
bind:value={addDialogUsername}
placeholder="Account username"
disabled={addDialogSaving}
/>
{/snippet}
</FormField>
{/if}
<FormField label="API key">
{#if editing?.requires_username}
<FormField label="Username">
{#snippet children({ id })}
<div class="relative">
<Input
{id}
type={addShowKey ? 'text' : 'password'}
bind:ref={addKeyInput}
bind:value={addDialogKeyValue}
placeholder="API key"
disabled={addDialogSaving}
class="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={addShowKey ? 'Hide key' : 'Show key'}
aria-pressed={addShowKey}
onclick={() => (addShowKey = !addShowKey)}
>
{#if addShowKey}
<EyeOffIcon class="size-4" />
{:else}
<EyeIcon class="size-4" />
{/if}
</Button>
</div>
<Input
{id}
bind:ref={usernameInput}
bind:value={username}
autocomplete="off"
disabled={saving}
/>
{/snippet}
</FormField>
</div>
{/if}
<FormField label="API key">
{#snippet children({ id })}
<div class="relative">
<Input
{id}
type={showKey ? 'text' : 'password'}
bind:ref={keyInput}
bind:value={keyValue}
autocomplete="off"
spellcheck={false}
class="pr-10 font-mono text-xs"
disabled={saving}
/>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute top-1/2 right-1.5 size-7 -translate-y-1/2 text-muted-foreground"
aria-label={showKey ? 'Hide key' : 'Show key'}
aria-pressed={showKey}
onclick={() => (showKey = !showKey)}
>
{#if showKey}<EyeOffIcon class="size-4" />{:else}<EyeIcon class="size-4" />{/if}
</Button>
</div>
{/snippet}
</FormField>
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (addDialogOpen = false)}
disabled={addDialogSaving}
disabled={saving}
onclick={() => (dialogOpen = false)}
>
Cancel
</Button>
<LoadingButton
type="submit"
loading={addDialogSaving}
loadingLabel="Saving"
disabled={!addCanSave}
>
Add key
<LoadingButton type="submit" loading={saving} loadingLabel="Saving" disabled={!canSave}>
{replacing ? 'Replace key' : 'Add key'}
</LoadingButton>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<DeleteConfirmationDialog
bind:open={deleteDialogOpen}
<ConfirmDialog
open={removing !== null}
title="Remove API key"
description={deletingProvider
? `The ${providers.find((p) => p.provider === deletingProvider)?.name ?? 'provider'} key is removed.`
: ''}
description={removing ? `The ${removing.name} key is removed.` : ''}
confirmLabel="Remove"
{isDeleting}
onOpenChange={(open) => (deleteDialogOpen = open)}
onConfirm={handleDelete}
destructive
loading={deleting}
onOpenChange={(open) => {
if (!open) removing = null;
}}
onConfirm={remove}
/>
<Dialog.Root bind:open={testDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Test API key</Dialog.Title>
<Dialog.Description>
One call is made against the
{providers.find((p) => p.provider === testDialogProvider)?.name ?? 'provider'} quota.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (testDialogOpen = false)}
disabled={testingKeyId !== null}
>
Cancel
</Button>
<LoadingButton
loading={testingKeyId !== null}
loadingLabel="Testing"
onclick={async () => {
if (testDialogKeyId) {
testDialogOpen = false;
await handleTest(testDialogKeyId);
}
}}
>
Test
</LoadingButton>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,276 +0,0 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import { toast } from 'svelte-sonner';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import EmptyState from '$lib/components/empty-state.svelte';
import SectionHead from '$lib/components/section-head.svelte';
import CertificateStreamCard from './certificate-stream-card.svelte';
import ConnectAlert from '$lib/components/bounty-hub/connect-alert.svelte';
import { bountyProgramsApi } from '$lib/api/bounty-programs';
import {
EVENT_TONE,
EVENT_TONE_BG,
SYNC_INTERVAL_LABELS,
assetIcon
} from '$lib/config/bounty-programs';
import { ROUTES } from '$lib/config/routes';
import { relativeTime } from '$lib/utilities/dates';
import type {
BountyEventSpec,
BountySettings,
BountyVocabulary
} from '$lib/types/bounty-program';
let settings = $state<BountySettings | null>(null);
let vocabulary = $state<BountyVocabulary | null>(null);
let loading = $state(true);
let loadError = $state<string | null>(null);
let saving = $state(false);
async function load() {
loading = true;
try {
[settings, vocabulary] = await Promise.all([
bountyProgramsApi.settings(),
bountyProgramsApi.vocabulary()
]);
loadError = null;
} catch (error) {
loadError = error instanceof Error ? error.message : 'Request failed.';
toast.error(error instanceof Error ? error.message : 'Bounty Hub settings not loaded');
} finally {
loading = false;
}
}
$effect(() => {
void load();
});
const platforms = $derived(settings?.platforms ?? []);
const connected = $derived(platforms.filter((p) => p.configured));
const fedPlatforms = $derived(platforms.filter((p) => p.has_feed));
const specs = $derived<BountyEventSpec[]>(
(settings?.notifiable_events ?? [])
.map((kind) => vocabulary?.events.find((e) => e.kind === kind))
.filter((e): e is BountyEventSpec => Boolean(e))
);
async function save(patch: Parameters<typeof bountyProgramsApi.saveSettings>[0]) {
saving = true;
try {
settings = await bountyProgramsApi.saveSettings(patch);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Settings not saved');
await load();
} finally {
saving = false;
}
}
function toggleEvent(kind: string, on: boolean) {
const current = settings?.notify_events ?? [];
const next = on ? [...current, kind] : current.filter((k) => k !== kind);
void save({ notify_events: [...new Set(next)] });
}
</script>
{#if loading}
<div class="flex flex-col gap-6" aria-busy="true">
{#each Array(3) as _, i (i)}
<div class="flex flex-col gap-3 rounded-xl border p-4">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-3 w-64 max-w-full" />
<div class="flex flex-col gap-2 pt-1">
{#each Array(3) as _, j (j)}
<div class="flex items-center justify-between gap-3">
<Skeleton class="h-4 w-48 max-w-full" />
<Skeleton class="h-5 w-9 rounded-full" />
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else if settings}
<div class="flex flex-col gap-6">
<CertificateStreamCard />
<ConnectAlert {platforms} />
<Card.Root class="gap-0 py-0">
<div class="border-b p-4">
<SectionHead title="Platform APIs" count={`${connected.length} connected`}>
{#if settings.last_synced_at}
<span>Last synced {relativeTime(settings.last_synced_at)}</span>
{/if}
{#if settings.next_sync_at}
<span>Next {relativeTime(settings.next_sync_at)}</span>
{/if}
</SectionHead>
</div>
{#each connected as platform (platform.platform)}
<div class="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-2.5 text-sm">
<span class="font-medium">{platform.label}</span>
<span class="text-xs text-muted-foreground">
{platform.programs.toLocaleString()} programs
{#if platform.private_programs > 0}
· {platform.private_programs.toLocaleString()} private
{/if}
</span>
</div>
{/each}
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Sync interval</span>
</div>
<Select.Root
type="single"
value={settings.sync_interval}
onValueChange={(v) => v && save({ sync_interval: v })}
>
<Select.Trigger class="w-44 shrink-0">
{SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval}
</Select.Trigger>
<Select.Content>
{#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if settings.sync_interval === 'off'}
<div class="border-t px-4 py-2.5 text-xs text-muted-foreground">
Programs update on manual refresh only.
</div>
{/if}
</Card.Root>
<Card.Root class="gap-0 py-0">
<div class="border-b p-4">
<SectionHead title="Public program feed" count={`${settings.feed_programs} programs`}>
{#if settings.feed_synced_at}
<span>Last synced {relativeTime(settings.feed_synced_at)}</span>
{/if}
{#if settings.feed_next_sync_at}
<span>Next {relativeTime(settings.feed_next_sync_at)}</span>
{/if}
</SectionHead>
</div>
{#each fedPlatforms as platform (platform.platform)}
<div class="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-2.5 text-sm">
<span class="font-medium">{platform.label}</span>
<span class="text-xs text-muted-foreground">
{platform.feed_programs.toLocaleString()} programs
</span>
</div>
{/each}
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Sync interval</span>
<span class="text-xs text-muted-foreground">
From
<a
href={settings.feed_url}
target="_blank"
rel="noreferrer noopener"
class="underline hover:text-foreground"
>
{settings.feed_source}
</a>
under {settings.feed_license}. Public programs only.
</span>
</div>
<Select.Root
type="single"
value={settings.feed_interval}
onValueChange={(v) => v && save({ feed_interval: v })}
>
<Select.Trigger class="w-44 shrink-0">
{SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval}
</Select.Trigger>
<Select.Content>
{#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</Card.Root>
<Card.Root class="gap-0 py-0">
<div class="border-b p-4">
<SectionHead title="Notifications" count={`${settings.events_recorded} recorded`}>
<a href={ROUTES.settings('notifications')} class="hover:underline">
Channels
<ExternalLinkIcon class="inline size-3" />
</a>
</SectionHead>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm font-medium">Notify on program changes</span>
<span class="text-xs text-muted-foreground">
Sent to the inbox and every notification channel.
</span>
</div>
<Switch
checked={settings.notify}
disabled={saving}
onCheckedChange={(v) => save({ notify: v })}
/>
</div>
{#if settings.notify}
<div class="border-t">
{#each specs as spec (spec.kind)}
{@const Icon = assetIcon(spec.icon)}
{@const on = settings.notify_events.includes(spec.kind)}
<div class="flex items-center justify-between gap-4 border-b px-4 py-3 last:border-b-0">
<div class="flex min-w-0 items-start gap-3">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md {EVENT_TONE_BG[
spec.tone
] ?? 'bg-muted'}"
>
<Icon class="size-3.5 {EVENT_TONE[spec.tone] ?? 'text-muted-foreground'}" />
</span>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-sm">{spec.label}</span>
<span class="text-xs text-muted-foreground">{spec.description}</span>
</div>
</div>
<Switch
checked={on}
disabled={saving}
onCheckedChange={(v) => toggleEvent(spec.kind, v)}
/>
</div>
{/each}
</div>
{:else}
<div class="border-t px-4 py-2.5 text-xs text-muted-foreground">
Changes are recorded in the Updates tab. Nothing is sent.
</div>
{/if}
</Card.Root>
</div>
{:else}
<EmptyState
icon={TriangleAlert}
title="Bounty Hub settings not loaded"
description={loadError ?? undefined}
>
<Button variant="outline" size="sm" onclick={() => void load()}>Retry</Button>
</EmptyState>
{/if}
@@ -1,99 +0,0 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import SectionHead from '$lib/components/section-head.svelte';
import { watchesApi } from '$lib/api/watches';
import { STREAM_POLL_MS } from '$lib/config/watch';
import { relativeTime } from '$lib/utilities/dates';
import type { StreamStatus } from '$lib/types/watch';
let stream = $state<StreamStatus | null>(null);
let failed = $state(false);
let timer: ReturnType<typeof setInterval> | null = null;
async function load() {
try {
stream = await watchesApi.stream();
failed = false;
} catch {
failed = true;
}
}
$effect(() => {
void load();
timer = setInterval(() => void load(), STREAM_POLL_MS);
return () => {
if (timer) clearInterval(timer);
};
});
const version = $derived(
stream?.version?.replace(/^certspotter version /, 'certspotter ').split(' (')[0]
);
const streamState = $derived.by(() => {
if (failed || !stream) return { label: 'Status not read', tone: 'text-muted-foreground' };
if (!stream.reachable) return { label: 'Not running', tone: 'text-destructive' };
if (stream.running) return { label: 'Connected', tone: 'text-success' };
return { label: 'Idle', tone: 'text-muted-foreground' };
});
</script>
<Card.Root class="gap-0 py-0">
<div class="border-b p-4">
<SectionHead title="Certificate stream" count={version}>
<span>Certificate transparency for the watched apexes</span>
</SectionHead>
</div>
<div class="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
<div class="flex flex-col gap-0.5 bg-card px-4 py-3">
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Stream</span>
<span class="text-sm font-medium {streamState.tone}">{streamState.label}</span>
</div>
<div class="flex flex-col gap-0.5 bg-card px-4 py-3">
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Watched apexes</span>
<span class="text-sm font-medium tabular-nums">{stream ? stream.items : 'Not read'}</span>
</div>
<div class="flex flex-col gap-0.5 bg-card px-4 py-3">
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Last certificate</span>
<span class="text-sm font-medium">
{#if !stream}
Not read
{:else}
{stream.last_certificate_at ? relativeTime(stream.last_certificate_at) : 'None yet'}
{/if}
</span>
</div>
<div class="flex flex-col gap-0.5 bg-card px-4 py-3">
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Since start</span>
<span class="text-sm font-medium tabular-nums">
{#if !stream}
Not read
{:else}
{stream.certificates_seen}
{stream.certificates_seen === 1 ? 'certificate' : 'certificates'}
{#if stream.started_at}
<span class="text-xs font-normal text-muted-foreground">
· {relativeTime(stream.started_at)}
</span>
{/if}
{/if}
</span>
</div>
</div>
{#if stream?.last_error}
<div class="border-t px-4 py-2.5 text-xs text-destructive">
{stream.last_error}
{#if stream.last_error_at}
<span class="text-muted-foreground">· {relativeTime(stream.last_error_at)}</span>
{/if}
</div>
{:else if failed}
<div class="border-t px-4 py-2.5 text-xs text-muted-foreground">
The API did not respond. Check that the api service is running.
</div>
{:else if stream && !stream.reachable}
<div class="border-t px-4 py-2.5 text-xs text-muted-foreground">
The ct-stream service has not reported. Check that it is running.
</div>
{/if}
</Card.Root>
@@ -0,0 +1,40 @@
export const KEY_COL = {
provider: 'min-w-0 flex-1',
key: 'hidden w-[190px] shrink-0 @2xl/keys:block',
status: 'w-[112px] shrink-0',
tested: 'hidden w-[104px] shrink-0 @xl/keys:block',
actions: 'flex w-[84px] shrink-0 justify-end'
} as const;
export const PROXY_COL = {
proxy: 'min-w-0 flex-1',
endpoint: 'hidden w-[230px] shrink-0 @2xl/proxies:block',
contexts: 'hidden w-[96px] shrink-0 @xl/proxies:block',
status: 'w-[128px] shrink-0',
actions: 'flex w-8 shrink-0 justify-end'
} as const;
export const CHANNEL_COL = {
channel: 'min-w-0 flex-1',
events: 'hidden w-[190px] shrink-0 @2xl/channels:block',
level: 'hidden w-[150px] shrink-0 @xl/channels:block',
delivery: 'w-[140px] shrink-0',
actions: 'flex w-8 shrink-0 justify-end'
} as const;
export const USER_COL = {
user: 'min-w-0 flex-1',
role: 'w-[104px] shrink-0',
twoFactor: 'hidden w-[104px] shrink-0 @xl/users:block',
created: 'hidden w-[120px] shrink-0 @2xl/users:block',
actions: 'flex w-8 shrink-0 justify-end'
} as const;
export const HEAD_ROW =
'flex items-center gap-4 border-b bg-muted/20 px-4 py-2 text-2xs font-medium tracking-wide text-muted-foreground uppercase';
export const BODY_ROW =
'flex items-center gap-4 border-b border-border/60 px-4 py-2.5 last:border-b-0';
export const GROUP_ROW =
'border-b border-border/60 bg-muted/10 px-4 pt-3 pb-1.5 text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase';
@@ -1,385 +1,239 @@
<script lang="ts">
import { ROUTES } from '$lib/config/routes';
import { onMount } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { instanceSettingsStore } from '$lib/stores/instanceSettings.svelte';
import UnsavedChangesDialog from '$lib/components/unsaved-changes-dialog.svelte';
import {
INSTANCE_MODES,
MODE_LABELS,
DEFAULT_INSTANCE_MODE,
coerceInstanceMode
} from '$lib/config/capabilities';
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import { onMount, untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { AUTOMATIC, CONCURRENT_SCAN_CHOICES } from '$lib/config/scan-admission';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import RotateCwIcon from '@lucide/svelte/icons/rotate-cw';
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
import SectionHead from '$lib/components/section-head.svelte';
import SettingRow from './setting-row.svelte';
import TimezonePicker from './timezone-picker.svelte';
import { instanceSettingsStore } from '$lib/stores/instanceSettings.svelte';
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { INSTANCE_MODES, MODE_LABELS, coerceInstanceMode } from '$lib/config/capabilities';
import { AUTOMATIC, CONCURRENT_SCAN_CHOICES } from '$lib/config/scan-admission';
import { SCAN_RETENTION, SCREENSHOT_RETENTION, retentionLabel } from '$lib/config/retention';
import type { InstanceSettingsUpdate } from '$lib/types/instance-settings';
const TIMEZONES = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Singapore',
'Asia/Shanghai',
'Asia/Tokyo',
'Australia/Sydney'
];
const SCAN_RETENTION = [
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
{ value: '180', label: '180 days' },
{ value: '365', label: '1 year' }
];
const SCREENSHOT_RETENTION = [
{ value: '7', label: '7 days' },
{ value: '14', label: '14 days' },
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' }
];
let isLoading = $state(true);
let loadFailed = $state(false);
let loading = $state(true);
let saving = $state(false);
let name = $state('');
let instanceName = $state('reNgine');
let timezone = $state('UTC');
let mode = $state<string>(DEFAULT_INSTANCE_MODE);
let scanRetention = $state('90');
let screenshotRetention = $state('30');
let certRecheck = $state(false);
let concurrentScans = $state(String(AUTOMATIC));
const settings = $derived(instanceSettingsStore.settings);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const locked = $derived(!isAdmin || saving);
let zoneOptions = $derived.by(() => {
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (local && !TIMEZONES.includes(local)) return [local, ...TIMEZONES];
return TIMEZONES;
});
let automaticLabel = $derived.by(() => {
const auto = instanceSettingsStore.settings?.concurrent_scans_auto;
return auto ? `Automatic · ${auto}` : 'Automatic';
});
let concurrencyOptions = $derived([
const automaticLabel = $derived(
settings?.concurrent_scans_auto ? `Automatic · ${settings.concurrent_scans_auto}` : 'Automatic'
);
const concurrency = $derived([
{ value: String(AUTOMATIC), label: automaticLabel },
...CONCURRENT_SCAN_CHOICES.map((n) => ({ value: String(n), label: String(n) }))
]);
let modePlaceholder = $derived(
INSTANCE_MODES.find((m) => m.value === mode)?.label ?? MODE_LABELS[DEFAULT_INSTANCE_MODE]
);
let snapshot = $state<string | null>(null);
function currentState() {
return JSON.stringify({
instanceName: instanceName.trim(),
timezone,
mode,
scanRetention,
screenshotRetention,
certRecheck,
concurrentScans
});
async function load() {
loading = true;
await instanceSettingsStore.fetch();
loading = false;
}
let isDirty = $derived(snapshot !== null && currentState() !== snapshot);
onMount(() => {
if (instanceSettingsStore.hasFetched) loading = false;
else void load();
});
function hydrate() {
const s = instanceSettingsStore.settings;
if (!s) return;
instanceName = s.instance_name;
timezone = s.timezone;
mode = coerceInstanceMode(s.mode);
scanRetention = String(s.scan_history_retention_days);
screenshotRetention = String(s.screenshot_retention_days);
certRecheck = s.cert_recheck_enabled;
concurrentScans = String(s.concurrent_scans);
snapshot = currentState();
}
$effect(() => {
const current = settings?.instance_name;
if (current !== undefined) untrack(() => (name = current));
});
async function handleSave() {
if (!instanceSettingsStore.settings) {
toast.error('Settings not loaded. Retry before saving.');
return;
}
if (!instanceName.trim()) {
toast.error('Instance name is required');
return;
}
async function save(patch: InstanceSettingsUpdate, label: string) {
saving = true;
try {
const updated = await instanceSettingsStore.update({
instance_name: instanceName.trim(),
timezone,
mode,
scan_history_retention_days: Number(scanRetention),
screenshot_retention_days: Number(screenshotRetention),
cert_recheck_enabled: certRecheck,
concurrent_scans: Number(concurrentScans)
});
if (updated) {
hydrate();
toast.success('Settings saved');
}
const updated = await instanceSettingsStore.update(patch);
if (!updated) return;
capabilitiesStore.setMode(updated.mode);
capabilitiesStore.setInstanceName(updated.instance_name);
toast.success(`${label} saved`);
} finally {
saving = false;
}
}
async function load() {
isLoading = true;
loadFailed = false;
if (!instanceSettingsStore.hasFetched) await instanceSettingsStore.fetch();
if (instanceSettingsStore.settings) hydrate();
else loadFailed = true;
isLoading = false;
}
onMount(load);
let showLeaveDialog = $state(false);
let pendingNav: (() => void) | null = $state(null);
let allowNavigation = $state(false);
beforeNavigate((nav) => {
if (allowNavigation) {
allowNavigation = false;
function commitName() {
if (!settings) return;
const next = name.trim();
if (next === settings.instance_name) return;
if (!next) {
name = settings.instance_name;
toast.error('Instance name is required');
return;
}
if (!isDirty || saving || pendingNav) return;
nav.cancel();
pendingNav = () => {
allowNavigation = true;
if (nav.to) goto(nav.to.url);
};
showLeaveDialog = true;
});
$effect(() => {
if (typeof window === 'undefined') return;
function onBeforeUnload(e: BeforeUnloadEvent) {
if (!isDirty || saving) return;
e.preventDefault();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
});
void save({ instance_name: next }, 'Instance name');
}
</script>
<div class="space-y-6">
<div>
<h2 class="text-lg font-semibold tracking-tight">General</h2>
<p class="text-sm text-muted-foreground">
Instance identity, scan concurrency, retention windows and AI analysis.
</p>
</div>
{#if loading && !settings}
<Card.Root class="gap-3 p-5">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
</Card.Root>
{:else if !settings}
<EmptyState compact icon={CircleAlertIcon} title="Settings not loaded">
<Button variant="outline" size="sm" onclick={load}>
<RotateCwIcon class="size-3.5" />
Retry
</Button>
</EmptyState>
{:else}
<Card.Root class="gap-0 overflow-hidden py-0">
<div class="px-5 pt-4 pb-2"><SectionHead title="Instance" /></div>
<SettingRow
label="Name"
help="Shown in browser tab titles"
for="instance-name"
class="border-t-0"
>
<Input
id="instance-name"
bind:value={name}
maxlength={120}
autocomplete="off"
class="h-9 w-60"
disabled={locked}
onblur={commitName}
onkeydown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
</SettingRow>
<SettingRow label="Time zone" help="Schedules and What's new dates" for="instance-timezone">
<TimezonePicker
id="instance-timezone"
value={settings.timezone}
disabled={locked}
onChange={(zone) => save({ timezone: zone }, 'Time zone')}
/>
</SettingRow>
<SettingRow
label="Mode"
help="Bug bounty adds Bounty Hub, program watches and recon presets"
for="instance-mode"
>
<Select.Root
type="single"
value={coerceInstanceMode(settings.mode)}
onValueChange={(v) => v && v !== settings.mode && save({ mode: v }, 'Mode')}
disabled={locked}
>
<Select.Trigger id="instance-mode" class="h-9 w-60">
{MODE_LABELS[coerceInstanceMode(settings.mode)]}
</Select.Trigger>
<Select.Content>
{#each INSTANCE_MODES as mode (mode.value)}
<Select.Item value={mode.value} label={mode.label}>{mode.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</SettingRow>
<Separator />
<div class="border-t px-5 pt-4 pb-2"><SectionHead title="Scanning" /></div>
<SettingRow
label="Concurrent scans"
help="Scans past the limit wait in launch order"
for="instance-concurrency"
class="border-t-0"
>
<Select.Root
type="single"
value={String(settings.concurrent_scans)}
onValueChange={(v) =>
v !== undefined &&
Number(v) !== settings.concurrent_scans &&
save({ concurrent_scans: Number(v) }, 'Concurrent scans')}
disabled={locked}
>
<Select.Trigger id="instance-concurrency" class="h-9 w-60">
{concurrency.find((o) => o.value === String(settings.concurrent_scans))?.label ??
settings.concurrent_scans}
</Select.Trigger>
<Select.Content>
{#each concurrency as option (option.value)}
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</SettingRow>
<SettingRow
label="Re-check certificates"
help="Refreshes certificate expiry between scans"
for="instance-cert-recheck"
>
<Switch
id="instance-cert-recheck"
checked={settings.cert_recheck_enabled}
disabled={locked}
onCheckedChange={(v) => save({ cert_recheck_enabled: v }, 'Certificate re-check')}
/>
</SettingRow>
{#if isLoading}
<div class="space-y-4">
<Skeleton class="h-40 w-full rounded-xl" />
<Skeleton class="h-40 w-full rounded-xl" />
</div>
{:else if loadFailed}
<EmptyState class="py-16" icon={CircleAlertIcon} title="Settings not loaded">
<Button onclick={load} class="gap-2">
<RotateCwIcon class="size-4" />
Retry
</Button>
</EmptyState>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Instance</Card.Title>
<Card.Description>Name, time zone and mode.</Card.Description>
</Card.Header>
<Card.Content class="space-y-5">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-1.5">
<Label class="text-xs" for="instance-name">Instance name</Label>
<Input
id="instance-name"
bind:value={instanceName}
placeholder="reNgine"
class="h-9"
disabled={saving}
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">Time zone</Label>
<Select.Root type="single" bind:value={timezone}>
<Select.Trigger class="h-9 w-full text-sm">{timezone}</Select.Trigger>
<Select.Content>
{#each zoneOptions as tz (tz)}
<Select.Item value={tz} label={tz}>{tz}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="border-t px-5 pt-4 pb-2"><SectionHead title="Retention" /></div>
<SettingRow
label="Scan history"
help="Excludes the newest run of each target"
for="instance-scan-retention"
class="border-t-0"
>
<Select.Root
type="single"
value={String(settings.scan_history_retention_days)}
onValueChange={(v) =>
v !== undefined &&
Number(v) !== settings.scan_history_retention_days &&
save({ scan_history_retention_days: Number(v) }, 'Scan history retention')}
disabled={locked}
>
<Select.Trigger id="instance-scan-retention" class="h-9 w-60">
{retentionLabel(SCAN_RETENTION, String(settings.scan_history_retention_days))}
</Select.Trigger>
<Select.Content>
{#each SCAN_RETENTION as option (option.value)}
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</SettingRow>
<SettingRow label="Screenshots and response bodies" for="instance-evidence-retention">
<Select.Root
type="single"
value={String(settings.screenshot_retention_days)}
onValueChange={(v) =>
v !== undefined &&
Number(v) !== settings.screenshot_retention_days &&
save({ screenshot_retention_days: Number(v) }, 'Evidence retention')}
disabled={locked}
>
<Select.Trigger id="instance-evidence-retention" class="h-9 w-60">
{retentionLabel(SCREENSHOT_RETENTION, String(settings.screenshot_retention_days))}
</Select.Trigger>
<Select.Content>
{#each SCREENSHOT_RETENTION as option (option.value)}
<Select.Item value={option.value} label={option.label}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</SettingRow>
<div class="space-y-1.5">
<Label class="text-xs">Mode</Label>
<Select.Root type="single" bind:value={mode}>
<Select.Trigger class="h-9 w-full text-sm sm:w-72">{modePlaceholder}</Select.Trigger>
<Select.Content>
{#each INSTANCE_MODES as m (m.value)}
<Select.Item value={m.value} label={m.label}>{m.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Sets which integrations and scoping options appear.
</p>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Data retention</Card.Title>
<Card.Description>Runs and their evidence are pruned daily.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-1.5">
<Label class="text-xs">Scan history</Label>
<Select.Root type="single" bind:value={scanRetention}>
<Select.Trigger class="h-9 w-full text-sm">
{SCAN_RETENTION.find((o) => o.value === scanRetention)?.label ?? '90 days'}
</Select.Trigger>
<Select.Content>
{#each SCAN_RETENTION as o (o.value)}
<Select.Item value={o.value} label={o.label}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">Screenshots and response bodies</Label>
<Select.Root type="single" bind:value={screenshotRetention}>
<Select.Trigger class="h-9 w-full text-sm">
{SCREENSHOT_RETENTION.find((o) => o.value === screenshotRetention)?.label ??
'30 days'}
</Select.Trigger>
<Select.Content>
{#each SCREENSHOT_RETENTION as o (o.value)}
<Select.Item value={o.value} label={o.label}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Scans</Card.Title>
<Card.Description>Scans past the limit wait in launch order.</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-1.5">
<Label class="text-xs">Concurrent scans</Label>
<Select.Root type="single" bind:value={concurrentScans}>
<Select.Trigger class="h-9 w-full text-sm sm:w-72">
{concurrencyOptions.find((o) => o.value === concurrentScans)?.label ??
concurrentScans}
</Select.Trigger>
<Select.Content>
{#each concurrencyOptions as o (o.value)}
<Select.Item value={o.value} label={o.label}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Automatic follows the stage slots of the running scan workers.
</p>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">Between scans</Card.Title>
<Card.Description
>Opens one TLS connection to each web asset every few hours to refresh certificate expiry.</Card.Description
>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between gap-4">
<Label class="text-xs" for="cert-recheck">Re-check certificates</Label>
<Switch id="cert-recheck" bind:checked={certRecheck} />
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<SparklesIcon class="size-4 text-foreground" />
<Card.Title class="text-base">AI</Card.Title>
</div>
<Card.Description>Model connection, feature opt-ins and cost.</Card.Description>
</Card.Header>
<Card.Content>
<Button variant="outline" size="sm" href={ROUTES.ai()}>
<SparklesIcon class="mr-1.5 size-3.5" />
Open AI page
</Button>
</Card.Content>
</Card.Root>
<div class="flex items-center justify-end gap-3">
{#if isDirty}
<span class="text-xs text-muted-foreground">Unsaved changes</span>
{/if}
<Button onclick={handleSave} disabled={saving || !isDirty}>
{#if saving}
<Spinner class="mr-2" />
Saving
{:else}
Save changes
{/if}
</Button>
</div>
{/if}
</div>
<UnsavedChangesDialog
bind:open={showLeaveDialog}
onOpenChange={(o) => {
showLeaveDialog = o;
if (!o) pendingNav = null;
}}
onConfirm={() => {
showLeaveDialog = false;
const resume = pendingNav;
pendingNav = null;
resume?.();
}}
/>
{#if !isAdmin}
<div class="border-t px-5 py-2.5 text-xs text-muted-foreground">
Editable by administrators.
</div>
{/if}
</Card.Root>
{/if}
File diff suppressed because it is too large Load Diff
@@ -1,401 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import SatelliteDishIcon from '@lucide/svelte/icons/satellite-dish';
import CheckIcon from '@lucide/svelte/icons/check';
import CircleXIcon from '@lucide/svelte/icons/circle-x';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import ConfirmDialog from '$lib/components/confirm-dialog.svelte';
import FormField from '$lib/components/form-field.svelte';
import LoadingButton from '$lib/components/loading-button.svelte';
import UnsavedChangesDialog from '$lib/components/unsaved-changes-dialog.svelte';
import { oastApi } from '$lib/api/oast';
import { ROUTES } from '$lib/config/routes';
import {
DEFAULT_WAIT_SECONDS,
MAX_WAIT_SECONDS,
OAST_MODES,
OAST_MODE_HELP,
OAST_MODE_LABELS,
OastMode,
PUBLIC_ACK,
WAIT_STEPS
} from '$lib/config/oast';
import { relativeTime } from '$lib/utilities/dates';
import type { OastRead, OastTest } from '$lib/types/oast';
let settings = $state<OastRead | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let serverError = $state<string | null>(null);
let saving = $state(false);
let testing = $state(false);
let resetOpen = $state(false);
let resetting = $state(false);
let result = $state<OastTest | null>(null);
let mode = $state<OastMode>(OastMode.OFF);
let server = $state('');
let wait = $state(DEFAULT_WAIT_SECONDS);
let acknowledged = $state(false);
let ModeIcon = $derived(
!settings || settings.mode === OastMode.OFF ? CircleXIcon : SatelliteDishIcon
);
let tone = $derived<BadgeVariant>(
!settings || settings.reason
? 'outline'
: settings.mode === OastMode.PUBLIC
? 'warning'
: 'success'
);
let dirty = $derived(
!!settings &&
(mode !== settings.mode ||
server.trim() !== (settings.server ?? '') ||
wait !== settings.wait_seconds ||
acknowledged !== settings.public_acknowledged)
);
let blocked = $derived(
mode === OastMode.SELF_HOSTED && !server.trim()
? 'Set the server first.'
: mode === OastMode.PUBLIC && !acknowledged
? 'Accept the public server first.'
: null
);
let canTest = $derived(
!!settings && settings.mode !== OastMode.OFF && !settings.reason && !dirty
);
let canReset = $derived(
!!settings &&
(settings.mode !== OastMode.OFF ||
!!settings.server ||
settings.token_set ||
settings.public_acknowledged ||
settings.wait_seconds !== DEFAULT_WAIT_SECONDS)
);
function apply(row: OastRead) {
settings = row;
mode = row.mode as OastMode;
server = row.server ?? '';
wait = row.wait_seconds;
acknowledged = row.public_acknowledged;
serverError = null;
}
async function load() {
loading = true;
try {
apply(await oastApi.get());
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Request failed.';
} finally {
loading = false;
}
}
onMount(load);
let showLeaveDialog = $state(false);
let pendingNav: (() => void) | null = $state(null);
let allowNavigation = $state(false);
beforeNavigate((nav) => {
if (allowNavigation) {
allowNavigation = false;
return;
}
if (!dirty || saving || pendingNav) return;
nav.cancel();
pendingNav = () => {
allowNavigation = true;
if (nav.to) goto(nav.to.url);
};
showLeaveDialog = true;
});
async function save() {
saving = true;
try {
apply(
await oastApi.update({
mode,
server: server.trim(),
wait_seconds: wait,
public_acknowledged: acknowledged
})
);
result = null;
toast.success('Out-of-band settings saved');
} catch (e) {
const message = e instanceof Error ? e.message : 'Settings not saved';
if (mode === OastMode.SELF_HOSTED) serverError = message;
else toast.error(message);
} finally {
saving = false;
}
}
async function test() {
testing = true;
try {
result = await oastApi.test();
} catch (e) {
result = {
ok: false,
detail: e instanceof Error ? e.message : 'Request failed.',
address: null,
status_code: null
};
} finally {
testing = false;
}
}
async function reset() {
resetting = true;
try {
apply(await oastApi.reset());
result = null;
resetOpen = false;
toast.success('Out-of-band settings cleared');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Settings not cleared');
} finally {
resetting = false;
}
}
</script>
<Card.Root>
<Card.Header>
<div class="flex items-start gap-3">
<span class="flex h-5 items-center">
<ModeIcon class="size-5 shrink-0 text-muted-foreground" />
</span>
<div class="flex min-w-0 flex-col gap-1">
<Card.Title>Out-of-band testing</Card.Title>
<Card.Description>
{#if settings?.checks}
{settings.checks.toLocaleString()} checks confirm a finding through a callback to a server
outside the target.
{:else}
Checks that confirm a finding through a callback to a server outside the target.
{/if}
</Card.Description>
</div>
{#if settings}
<Badge variant={tone} class="ml-auto shrink-0">
{settings.reason ? 'Off' : OAST_MODE_LABELS[settings.mode as OastMode]}
</Badge>
{/if}
</div>
</Card.Header>
<Card.Content class="flex flex-col gap-5">
{#if loading}
<Skeleton class="h-40 w-full" />
{:else if error}
<p class="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" class="w-fit" onclick={() => load()}>Try again</Button>
{:else if settings}
<div class="flex flex-col gap-2">
<Label>Mode</Label>
<ToggleGroup.Root
type="single"
value={mode}
onValueChange={(v) => {
if (v) mode = v as OastMode;
}}
variant="outline"
class="w-fit"
aria-label="Out-of-band mode"
>
{#each OAST_MODES as option (option)}
<ToggleGroup.Item value={option} class="h-9 px-3 text-sm font-normal">
{OAST_MODE_LABELS[option]}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
<p class="text-sm text-muted-foreground">{OAST_MODE_HELP[mode]}</p>
{#if settings.reason && settings.mode !== OastMode.OFF && !dirty}
<p class="text-sm text-warning">{settings.reason}</p>
{/if}
</div>
{#if mode === OastMode.SELF_HOSTED}
<FormField label="Server domain" for="oast-server" error={serverError ?? undefined}>
{#snippet children({ id })}
<Input
{id}
placeholder="oast.example.com"
bind:value={server}
oninput={() => (serverError = null)}
autocomplete="off"
spellcheck="false"
/>
{/snippet}
</FormField>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Token</span>
{#if settings.token_set}
<Badge variant="success" class="gap-1"><CheckIcon class="size-3" />Set</Badge>
{:else}
<Badge variant="outline">Not set</Badge>
{/if}
<a href={ROUTES.settings('api-keys')} class="font-medium text-primary hover:underline">
{settings.token_set ? 'Replace it under API keys' : 'Add it under API keys'}
</a>
<span class="text-muted-foreground">A server without authentication needs no token.</span>
</div>
{/if}
{#if mode === OastMode.PUBLIC}
<div class="flex flex-col gap-2 rounded-md border border-warning/40 bg-warning/5 p-3">
<div class="flex items-start gap-2">
<span class="flex h-5 items-center">
<TriangleAlertIcon class="size-4 shrink-0 text-warning" />
</span>
<p class="text-sm">{PUBLIC_ACK}</p>
</div>
<Label class="flex w-fit cursor-pointer items-center gap-2 text-sm font-normal">
<Checkbox checked={acknowledged} onCheckedChange={(v) => (acknowledged = v === true)} />
<span>Accepted for every scan on this instance</span>
</Label>
<p class="text-xs text-muted-foreground">
Servers in rotation: {settings.public_servers.join(', ')}
</p>
</div>
{/if}
{#if mode !== OastMode.OFF}
<div class="flex flex-col gap-2">
<Label for="oast-wait">Wait for a callback</Label>
<div class="flex flex-wrap items-center gap-2">
<Select.Root
type="single"
value={String(wait)}
onValueChange={(v) => (wait = Number(v))}
>
<Select.Trigger id="oast-wait" class="h-9 w-40">
{wait === 0 ? 'No extra wait' : `${wait}s`}
</Select.Trigger>
<Select.Content>
{#each WAIT_STEPS as step (step)}
<Select.Item value={String(step)}>
{step === 0 ? 'No extra wait' : `${step}s`}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-sm text-muted-foreground">
Paid once at the end of each out-of-band batch, up to {MAX_WAIT_SECONDS}s.
</span>
</div>
</div>
{/if}
<Separator />
<div class="flex flex-wrap items-center gap-2">
<LoadingButton
loading={saving}
loadingLabel="Saving"
disabled={!dirty || !!blocked}
onclick={save}
>
Save
</LoadingButton>
<LoadingButton
variant="outline"
loading={testing}
loadingLabel="Testing"
disabled={!canTest}
onclick={test}
>
Test server
</LoadingButton>
{#if dirty}
<span class="text-xs text-muted-foreground">Unsaved changes</span>
{/if}
<span class="flex-1"></span>
<Button
variant="ghost"
class="text-muted-foreground"
disabled={!canReset}
onclick={() => (resetOpen = true)}
>
Reset
</Button>
</div>
{#if blocked}
<p class="text-sm text-muted-foreground">{blocked}</p>
{:else if dirty}
<p class="text-sm text-muted-foreground">Save to test the server.</p>
{:else if settings.mode === OastMode.OFF}
<p class="text-sm text-muted-foreground">Choose Self-hosted or Public to test a server.</p>
{/if}
{#if result && !dirty}
<div class="flex items-start gap-2 text-sm">
<span class="flex h-5 items-center">
{#if result.ok}
<CheckIcon class="size-4 shrink-0 text-success" />
{:else}
<CircleXIcon class="size-4 shrink-0 text-destructive" />
{/if}
</span>
<span>{result.detail}</span>
</div>
{/if}
{#if settings.interactions > 0}
<p class="text-sm text-muted-foreground">
{settings.interactions.toLocaleString()} findings carry a callback.
{#if settings.last_interaction_at}
Most recent {relativeTime(settings.last_interaction_at)}.
{/if}
</p>
{/if}
{/if}
</Card.Content>
</Card.Root>
<ConfirmDialog
open={resetOpen}
title="Reset out-of-band testing"
description="The server, the token and the public-server acceptance are removed."
confirmLabel="Reset"
destructive
loading={resetting}
onOpenChange={(open) => (resetOpen = open)}
onConfirm={reset}
/>
<UnsavedChangesDialog
open={showLeaveDialog}
onOpenChange={(o) => {
showLeaveDialog = o;
if (!o) pendingNav = null;
}}
onConfirm={() => {
showLeaveDialog = false;
const resume = pendingNav;
pendingNav = null;
resume?.();
}}
/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
label: string;
help?: string;
for?: string;
class?: string;
children: Snippet;
}
let { label, help, for: target, class: className, children }: Props = $props();
</script>
<div
class={cn(
'grid grid-cols-1 gap-2 border-t border-border/60 px-5 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:gap-6',
className
)}
>
<div class="flex min-w-0 flex-col gap-0.5">
{#if target}
<label for={target} class="text-sm leading-5 font-medium">{label}</label>
{:else}
<span class="text-sm leading-5 font-medium">{label}</span>
{/if}
{#if help}
<span class="text-xs text-muted-foreground">{help}</span>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{@render children()}
</div>
</div>
@@ -0,0 +1,15 @@
export type CheckState = 'ok' | 'failed' | 'untested' | 'off';
export const CHECK_DOT: Record<CheckState, string> = {
ok: 'bg-success',
failed: 'bg-destructive',
untested: 'bg-muted-foreground/60',
off: 'bg-muted-foreground/30'
};
export function checkState(enabled: boolean, ok: boolean | null): CheckState {
if (!enabled) return 'off';
if (ok === true) return 'ok';
if (ok === false) return 'failed';
return 'untested';
}
@@ -0,0 +1,67 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
interface Props {
value: string;
id?: string;
disabled?: boolean;
onChange: (zone: string) => void;
}
let { value, id, disabled = false, onChange }: Props = $props();
let open = $state(false);
const zones = $derived.by(() => {
const all = ['UTC', ...Intl.supportedValuesOf('timeZone').filter((z) => z !== 'UTC')];
return value && !all.includes(value) ? [value, ...all] : all;
});
function pick(zone: string) {
open = false;
if (zone !== value) onChange(zone);
}
</script>
<Popover.Root bind:open>
<Popover.Trigger {disabled}>
{#snippet child({ props })}
<Button
{...props}
{id}
variant="outline"
role="combobox"
aria-expanded={open}
class="h-9 w-60 justify-between font-normal"
>
<span class="truncate">{value}</span>
<ChevronsUpDownIcon class="size-4 shrink-0 text-muted-foreground" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<Command.Root>
<Command.Input placeholder="Search time zones" />
<Command.List class="max-h-none overflow-visible">
<Command.Empty>No time zone</Command.Empty>
<ScrollArea class="[&_[data-slot=scroll-area-viewport]]:max-h-72">
<Command.Group>
{#each zones as zone (zone)}
<Command.Item value={zone} onSelect={() => pick(zone)}>
<span class="flex-1 truncate">{zone}</span>
{#if zone === value}
<CheckIcon class="size-4 text-primary" />
{/if}
</Command.Item>
{/each}
</Command.Group>
</ScrollArea>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,320 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import EyeIcon from '@lucide/svelte/icons/eye';
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
import PlusIcon from '@lucide/svelte/icons/plus';
import RotateCwIcon from '@lucide/svelte/icons/rotate-cw';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import UsersIcon from '@lucide/svelte/icons/users';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import ConfirmDialog from '$lib/components/confirm-dialog.svelte';
import EmptyState from '$lib/components/empty-state.svelte';
import FormField from '$lib/components/form-field.svelte';
import LoadingButton from '$lib/components/loading-button.svelte';
import { usersApi, type UserAccount } from '$lib/api/users';
import { auth } from '$lib/stores/auth.svelte';
import { settingsActions } from '$lib/stores/settings-actions.svelte';
import { ROLE_LABELS, roleLabel } from '$lib/config/users';
import { formatDate } from '$lib/utilities';
import { BODY_ROW, HEAD_ROW, USER_COL } from './columns';
const ADMIN = 'admin';
const MEMBER = 'member';
let users = $state<UserAccount[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let dialogOpen = $state(false);
let username = $state('');
let email = $state('');
let password = $state('');
let showPassword = $state(false);
let role = $state(MEMBER);
let saving = $state(false);
let removing = $state<UserAccount | null>(null);
let deleting = $state(false);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const selfId = $derived(auth.user?.id ?? null);
const canSave = $derived(!saving && !!username.trim() && !!email.trim() && password.length > 0);
async function load() {
loading = true;
loadError = null;
try {
users = await usersApi.list();
} catch (e) {
loadError = e instanceof Error ? e.message : 'Users not loaded';
} finally {
loading = false;
}
}
onMount(() => {
if (isAdmin) void load();
else loading = false;
});
function openAdd() {
username = '';
email = '';
password = '';
showPassword = false;
role = MEMBER;
dialogOpen = true;
}
async function create() {
if (!canSave) return;
saving = true;
try {
const created = await usersApi.create({
username: username.trim(),
email: email.trim(),
password,
is_superuser: role === ADMIN
});
users = [...users, created];
toast.success(`User ${created.username} added`);
dialogOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'User not added');
} finally {
saving = false;
}
}
async function update(user: UserAccount, patch: { is_active?: boolean; is_superuser?: boolean }) {
try {
const updated = await usersApi.update(user.id, patch);
users = users.map((u) => (u.id === updated.id ? updated : u));
if (patch.is_active !== undefined) {
toast.success(`User ${user.username} ${patch.is_active ? 'enabled' : 'disabled'}`);
} else {
toast.success('Role saved');
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'User not updated');
}
}
async function remove() {
const user = removing;
if (!user) return;
deleting = true;
try {
await usersApi.remove(user.id);
users = users.filter((u) => u.id !== user.id);
toast.success(`User ${user.username} removed`);
removing = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'User not removed');
removing = null;
} finally {
deleting = false;
}
}
$effect(() => {
if (!isAdmin) return;
settingsActions.set(addAction);
return () => settingsActions.clear(addAction);
});
</script>
{#snippet addAction()}
<Button size="sm" onclick={openAdd}>
<PlusIcon class="size-4" />
Add user
</Button>
{/snippet}
{#if !isAdmin}
<EmptyState compact icon={UsersIcon} title="Users are managed by administrators" />
{:else if loading}
<Card.Root class="gap-3 p-4">
<Skeleton class="h-8 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</Card.Root>
{:else if loadError}
<EmptyState compact icon={TriangleAlertIcon} title="Users not loaded" description={loadError}>
<Button variant="outline" size="sm" onclick={load}>
<RotateCwIcon class="size-3.5" />
Retry
</Button>
</EmptyState>
{:else}
<Card.Root class="gap-0 overflow-hidden py-0">
<div class="@container/users w-full" role="table" aria-label="Users">
<div class={HEAD_ROW} role="row">
<div class={USER_COL.user}>User</div>
<div class={USER_COL.role}>Role</div>
<div class={USER_COL.twoFactor}>Two-factor</div>
<div class={USER_COL.created}>Added</div>
<div class={USER_COL.actions}></div>
</div>
{#each users as user (user.id)}
{@const isSelf = user.id === selfId}
<div class="{BODY_ROW} {user.is_active ? '' : 'text-muted-foreground'}" role="row">
<div class="{USER_COL.user} flex flex-col">
<span class="flex items-center gap-2 text-sm leading-5 font-medium">
<span class="wrap-anywhere">{user.username}</span>
{#if isSelf}
<Badge variant="secondary" class="h-5 px-1.5 text-2xs">Signed in</Badge>
{/if}
{#if !user.is_active}
<Badge variant="outline" class="h-5 px-1.5 text-2xs">Disabled</Badge>
{/if}
</span>
<span class="text-2xs text-muted-foreground wrap-anywhere">{user.email}</span>
</div>
<div class="{USER_COL.role} text-sm">{roleLabel(user.is_superuser)}</div>
<div class="{USER_COL.twoFactor} text-sm">
{#if user.totp_enabled}
On
{:else}
<span class="text-muted-foreground">Off</span>
{/if}
</div>
<div class="{USER_COL.created} text-xs text-muted-foreground tabular-nums">
{formatDate(user.created_at)}
</div>
<div class={USER_COL.actions}>
{#if !isSelf}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-7">
<MoreVerticalIcon class="size-4" />
<span class="sr-only">{user.username} actions</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onSelect={() => update(user, { is_superuser: !user.is_superuser })}
>
{user.is_superuser
? `Make ${ROLE_LABELS.member.toLowerCase()}`
: 'Make administrator'}
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => update(user, { is_active: !user.is_active })}>
{user.is_active ? 'Disable' : 'Enable'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => (removing = user)}>
Remove
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</div>
{/each}
</div>
</Card.Root>
{/if}
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Add user</Dialog.Title>
</Dialog.Header>
<form
class="flex flex-col gap-4"
onsubmit={(e) => {
e.preventDefault();
void create();
}}
>
<FormField label="Username">
{#snippet children({ id })}
<Input {id} bind:value={username} autocomplete="off" maxlength={50} disabled={saving} />
{/snippet}
</FormField>
<FormField label="Email">
{#snippet children({ id })}
<Input {id} type="email" bind:value={email} autocomplete="off" disabled={saving} />
{/snippet}
</FormField>
<FormField label="Password">
{#snippet children({ id })}
<div class="relative">
<Input
{id}
type={showPassword ? 'text' : 'password'}
bind:value={password}
autocomplete="new-password"
class="pr-10"
disabled={saving}
/>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute top-1/2 right-1.5 size-7 -translate-y-1/2 text-muted-foreground"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onclick={() => (showPassword = !showPassword)}
>
{#if showPassword}<EyeOffIcon class="size-4" />{:else}<EyeIcon class="size-4" />{/if}
</Button>
</div>
{/snippet}
</FormField>
<FormField label="Role">
{#snippet children({ id })}
<Select.Root type="single" bind:value={role} disabled={saving}>
<Select.Trigger {id} class="w-full">
{role === ADMIN ? ROLE_LABELS.admin : ROLE_LABELS.member}
</Select.Trigger>
<Select.Content>
<Select.Item value={MEMBER} label={ROLE_LABELS.member}
>{ROLE_LABELS.member}</Select.Item
>
<Select.Item value={ADMIN} label={ROLE_LABELS.admin}>{ROLE_LABELS.admin}</Select.Item>
</Select.Content>
</Select.Root>
{/snippet}
</FormField>
<Dialog.Footer>
<Button
type="button"
variant="outline"
disabled={saving}
onclick={() => (dialogOpen = false)}
>
Cancel
</Button>
<LoadingButton type="submit" loading={saving} loadingLabel="Adding" disabled={!canSave}>
Add user
</LoadingButton>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<ConfirmDialog
open={removing !== null}
title="Remove user"
description={removing ? `User ${removing.username} is removed.` : ''}
confirmLabel="Remove"
destructive
loading={deleting}
onOpenChange={(open) => {
if (!open) removing = null;
}}
onConfirm={remove}
/>
+17 -2
View File
@@ -28,6 +28,7 @@ import Radar from '@lucide/svelte/icons/radar';
import Target from '@lucide/svelte/icons/target';
import type { IconComponent } from './icons';
import { CALLBACK_SERVER } from './oast';
import { Capability, type CapabilityKey } from './capabilities';
import { ROUTES, routeLabels, SETTINGS_SECTIONS } from './routes';
import { SURFACE_ORDER } from './surface';
@@ -223,8 +224,7 @@ function destinations(): Destination[] {
label: `${routeLabels.settings} · ${routeLabels[section] ?? section}`,
href: ROUTES.settings(section),
icon: Settings2,
keywords: section.replace(/-/g, ' '),
capability: section === 'bounty-hub' ? Capability.BOUNTY_PLATFORMS : undefined
keywords: section.replace(/-/g, ' ')
}));
return [
@@ -341,6 +341,21 @@ function destinations(): Destination[] {
keywords: 'programs watching platforms',
capability: Capability.BOUNTY_PROGRAMS
},
{
id: 'page:bounty-hub-settings',
label: `${routeLabels['bounty-hub']} · ${routeLabels.settings}`,
href: ROUTES.bountyHubSettings(),
icon: Award,
keywords: 'program sync feed interval alerts',
capability: Capability.BOUNTY_PROGRAMS
},
{
id: 'page:callback-server',
label: `${routeLabels.arsenal} · ${CALLBACK_SERVER}`,
href: ROUTES.callbackServer(),
icon: Settings2,
keywords: 'out-of-band oast interactsh callback'
},
...settings
];
}
@@ -0,0 +1,73 @@
import { Capability, type CapabilityKey } from '$lib/config/capabilities';
// mirrors shared/definitions/notification_events.py
export interface ChannelEvent {
type: string;
label: string;
hint: string;
capability: CapabilityKey | null;
}
export const CHANNEL_EVENTS: ChannelEvent[] = [
{
type: 'scan',
label: 'Scan results',
hint: 'Run digest, failed runs, exposures and exploit intelligence changes',
capability: null
},
{
type: 'vulnerability',
label: 'New vulnerabilities',
hint: 'New findings and inferred software CVEs',
capability: null
},
{
type: 'integration',
label: 'Program changes',
hint: 'Scope and status changes on bug bounty programs',
capability: Capability.BOUNTY_PROGRAMS
},
{
type: 'watch',
label: 'Program watches',
hint: 'New in-scope assets on a watched program',
capability: Capability.PROGRAM_WATCHES
},
{
type: 'new_checks',
label: 'New checks',
hint: 'Library additions and follow-up run results',
capability: null
},
{
type: 'system',
label: 'Reports and exports',
hint: 'Ready or failed',
capability: null
},
{
type: 'target',
label: 'Enrichment',
hint: 'Failed WHOIS and BGP lookups',
capability: null
}
];
export const DEFAULT_CHANNEL_EVENTS: string[] = CHANNEL_EVENTS.map((event) => event.type);
export interface ChannelLevel {
value: string;
label: string;
}
export const CHANNEL_LEVELS: ChannelLevel[] = [
{ value: 'info', label: 'All events' },
{ value: 'warning', label: 'Warning and critical' },
{ value: 'error', label: 'Critical only' }
];
export const DEFAULT_CHANNEL_LEVEL = 'info';
export function channelEventsFor(has: (capability: CapabilityKey) => boolean): ChannelEvent[] {
return CHANNEL_EVENTS.filter((event) => !event.capability || has(event.capability));
}
+3 -1
View File
@@ -7,6 +7,8 @@ export enum OastMode {
export const OAST_MODES: OastMode[] = [OastMode.OFF, OastMode.SELF_HOSTED, OastMode.PUBLIC];
export const CALLBACK_SERVER = 'Callback server';
export const OAST_MODE_LABELS: Record<OastMode, string> = {
[OastMode.OFF]: 'Off',
[OastMode.SELF_HOSTED]: 'Self-hosted',
@@ -14,7 +16,7 @@ export const OAST_MODE_LABELS: Record<OastMode, string> = {
};
export const OAST_MODE_HELP: Record<OastMode, string> = {
[OastMode.OFF]: 'Checks that need a callback run against the response alone.',
[OastMode.OFF]: 'Checks that need a callback are skipped.',
[OastMode.SELF_HOSTED]:
'An interactsh server this instance operates. Callbacks reach that server.',
[OastMode.PUBLIC]:
+31
View File
@@ -0,0 +1,31 @@
export interface RetentionOption {
value: string;
label: string;
}
// mirrors shared/definitions/retention.py:KEEP_FOREVER
export const KEEP_FOREVER = '0';
// mirrors shared/definitions/retention.py:SCAN_RETENTION_DAYS
export const SCAN_RETENTION: RetentionOption[] = [
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
{ value: '180', label: '180 days' },
{ value: '365', label: '1 year' },
{ value: KEEP_FOREVER, label: 'Keep forever' }
];
// mirrors shared/definitions/retention.py:SCREENSHOT_RETENTION_DAYS
export const SCREENSHOT_RETENTION: RetentionOption[] = [
{ value: '7', label: '7 days' },
{ value: '14', label: '14 days' },
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
{ value: KEEP_FOREVER, label: 'Keep forever' }
];
export function retentionLabel(options: RetentionOption[], value: string): string {
return options.find((o) => o.value === value)?.label ?? `${value} days`;
}
+14 -4
View File
@@ -42,9 +42,9 @@ export const routeLabels: Record<string, string> = {
general: 'General',
'api-keys': 'API keys',
proxies: 'Proxies',
oast: 'Out-of-band testing',
notifications: 'Notifications',
ai: 'AI',
users: 'Users',
profile: 'Profile'
};
@@ -53,17 +53,25 @@ export const SETTINGS_SECTIONS = [
'general',
'api-keys',
'proxies',
'oast',
'notifications',
'bounty-hub',
'ai'
'ai',
'users'
] as const;
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
export const ADMIN_SETTINGS: readonly SettingsSection[] = [
'api-keys',
'proxies',
'notifications',
'users'
];
export const BOUNTY_HUB_TABS = ['watching', 'programs', 'updates'] as const;
export type BountyHubTab = (typeof BOUNTY_HUB_TABS)[number];
export const ARSENAL_TABS = ['nuclei', 'wordlists', 'threat-intel'] as const;
export const PANEL_PARAM = 'panel';
export const CALLBACK_PANEL = 'callback-server';
export const BOUNTY_SETTINGS_PANEL = 'settings';
export const EXPOSURE_TABS = ['exposures', 'rules', 'dismissed'] as const;
export type ExposureTab = (typeof EXPOSURE_TABS)[number];
export const REPORT_TABS = ['reports', 'templates', 'themes', 'typefaces', 'branding'] as const;
@@ -127,6 +135,8 @@ export const ROUTES = {
},
schedules: '/automation/schedules',
arsenal: (tab?: ArsenalTab) => (tab ? `/arsenal?tab=${tab}` : '/arsenal'),
callbackServer: () => `/arsenal?${PANEL_PARAM}=${CALLBACK_PANEL}`,
bountyHubSettings: () => `/bounty-hub?${PANEL_PARAM}=${BOUNTY_SETTINGS_PANEL}`,
bountyHub: (handle?: string, platform?: string) =>
handle ? `/bounty-hub?program=${handle}&platform=${platform ?? 'hackerone'}` : '/bounty-hub',
bountyHubTab: (tab: BountyHubTab) => `/bounty-hub?tab=${tab}`,
+8
View File
@@ -0,0 +1,8 @@
export const ROLE_LABELS = {
admin: 'Administrator',
member: 'Standard user'
} as const;
export function roleLabel(isSuperuser: boolean): string {
return isSuperuser ? ROLE_LABELS.admin : ROLE_LABELS.member;
}
+2
View File
@@ -1,5 +1,7 @@
export const MASK = '••••••••';
export const PRODUCT_NAME = 'reNgine';
export const SELECT_NONE = '__none__';
export const ACTIVITY_TICK_MS = 20_000;
@@ -5,10 +5,12 @@ import {
DEFAULT_INSTANCE_MODE,
type CapabilityKey
} from '$lib/config/capabilities';
import { PRODUCT_NAME } from '$lib/constants';
function createCapabilitiesStore() {
let mode = $state<string>(DEFAULT_INSTANCE_MODE);
let capabilities = $state<string[]>(capabilitiesForMode(DEFAULT_INSTANCE_MODE));
let instanceName = $state(PRODUCT_NAME);
let hasFetched = $state(false);
let loading = false;
@@ -19,6 +21,9 @@ function createCapabilitiesStore() {
get capabilities() {
return capabilities;
},
get instanceName() {
return instanceName;
},
get hasFetched() {
return hasFetched;
},
@@ -29,6 +34,9 @@ function createCapabilitiesStore() {
mode = next;
capabilities = capabilitiesForMode(next);
},
setInstanceName(next: string) {
instanceName = next.trim() || PRODUCT_NAME;
},
async fetch() {
if (loading) return;
loading = true;
@@ -36,6 +44,7 @@ function createCapabilitiesStore() {
const s = await instanceSettingsApi.get();
mode = coerceInstanceMode(s.mode);
capabilities = s.capabilities ?? capabilitiesForMode(s.mode);
instanceName = s.instance_name?.trim() || PRODUCT_NAME;
hasFetched = true;
} catch {
} finally {
@@ -45,6 +54,7 @@ function createCapabilitiesStore() {
reset() {
mode = DEFAULT_INSTANCE_MODE;
capabilities = capabilitiesForMode(DEFAULT_INSTANCE_MODE);
instanceName = PRODUCT_NAME;
hasFetched = false;
}
};
@@ -17,6 +17,9 @@ function createProxiesStore() {
get hasFetched() {
return hasFetched;
},
get defaultId(): string | null {
return proxies.find((p) => p.is_default && p.is_active)?.id ?? null;
},
async fetch() {
if (isLoading) return;
@@ -0,0 +1,15 @@
import type { Snippet } from 'svelte';
let current = $state<Snippet | null>(null);
export const settingsActions = {
get snippet() {
return current;
},
set(snippet: Snippet) {
current = snippet;
},
clear(snippet: Snippet) {
if (current === snippet) current = null;
}
};
+4
View File
@@ -28,6 +28,9 @@ export interface APIKeyRead {
is_enabled: boolean;
usage_counter: number;
last_used_at: string | null;
last_test_at: string | null;
last_test_ok: boolean | null;
last_test_message: string | null;
created_at: string;
updated_at: string;
meta: ProviderMeta;
@@ -44,6 +47,7 @@ export interface ProviderInfo {
group_label: string;
configured: boolean;
is_enabled: boolean;
testable: boolean;
}
export interface APIKeyCreate {
+18 -24
View File
@@ -1,3 +1,10 @@
import {
CHANNEL_EVENTS,
CHANNEL_LEVELS,
DEFAULT_CHANNEL_EVENTS,
DEFAULT_CHANNEL_LEVEL
} from '$lib/config/notification-events';
export const NOTIF_PROVIDERS = [
'slack',
'discord',
@@ -10,27 +17,14 @@ export const NOTIF_PROVIDERS = [
export type NotifProvider = (typeof NOTIF_PROVIDERS)[number];
export const NOTIF_CATEGORIES = [
{ value: 'scan', label: 'Scans', hint: 'Started, completed, failed' },
{ value: 'vulnerability', label: 'Vulnerabilities', hint: 'New findings by severity' },
{ value: 'target', label: 'Targets and assets', hint: 'Imports, new assets and endpoints' },
{ value: 'security', label: 'Security alerts', hint: 'Logins, account changes' },
{ value: 'system', label: 'System', hint: 'Updates and platform health' },
{ value: 'integration', label: 'Integrations', hint: 'API key and webhook failures' },
{ value: 'resource', label: 'Resources', hint: 'Storage and quota limits' },
{ value: 'watch', label: 'Program watches', hint: 'New in-scope assets on a watched program' },
{
value: 'new_checks',
label: 'New checks',
hint: 'New checks in the library and what they found'
}
] as const;
export const NOTIF_CATEGORIES = CHANNEL_EVENTS.map((event) => ({
value: event.type,
label: event.label,
hint: event.hint,
capability: event.capability
}));
export const NOTIF_SEVERITIES = [
{ value: 'info', label: 'Info and above' },
{ value: 'warning', label: 'Warning and above' },
{ value: 'error', label: 'Errors only' }
] as const;
export const NOTIF_SEVERITIES = CHANNEL_LEVELS;
export interface NotificationPreference {
types: string[];
@@ -49,6 +43,9 @@ export interface NotificationChannelRead {
last_test_at: string | null;
last_test_ok: boolean | null;
last_test_message: string | null;
last_sent_at: string | null;
last_sent_ok: boolean | null;
last_sent_message: string | null;
}
export interface NotificationChannelCreate {
@@ -67,8 +64,5 @@ export interface NotificationChannelUpdate {
}
export function defaultNotificationPreference(): NotificationPreference {
return {
types: ['scan', 'vulnerability', 'target', 'security', 'system', 'watch', 'new_checks'],
min_severity: 'info'
};
return { types: [...DEFAULT_CHANNEL_EVENTS], min_severity: DEFAULT_CHANNEL_LEVEL };
}
+1 -9
View File
@@ -1,5 +1,4 @@
export const PROXY_SCHEMES = ['http', 'https', 'socks5'] as const;
export const PROXY_MODES = ['single', 'rotating'] as const;
export const PROXY_SCHEME_LABELS: Record<(typeof PROXY_SCHEMES)[number], string> = {
http: 'HTTP',
@@ -7,11 +6,6 @@ export const PROXY_SCHEME_LABELS: Record<(typeof PROXY_SCHEMES)[number], string>
socks5: 'SOCKS5'
};
export const PROXY_MODE_LABELS: Record<(typeof PROXY_MODES)[number], string> = {
single: 'Single',
rotating: 'Rotating'
};
export interface ProxyEndpoint {
scheme: string;
host: string;
@@ -33,11 +27,11 @@ export interface ProxyRead {
id: string;
name: string;
description: string | null;
mode: string;
is_active: boolean;
is_default: boolean;
endpoints: ProxyEndpointRead[];
endpoint_count: number;
contexts: number;
created_at: string;
updated_at: string;
last_test_at: string | null;
@@ -48,7 +42,6 @@ export interface ProxyRead {
export interface ProxyCreate {
name: string;
description?: string | null;
mode?: string;
is_active?: boolean;
is_default?: boolean;
endpoints: ProxyEndpoint[];
@@ -57,7 +50,6 @@ export interface ProxyCreate {
export interface ProxyUpdate {
name?: string;
description?: string | null;
mode?: string;
is_active?: boolean;
is_default?: boolean;
endpoints?: ProxyEndpoint[] | null;
+3
View File
@@ -32,6 +32,7 @@ export interface TemplateFilter {
sets: string[];
tags: string[];
fired: boolean;
callback: boolean;
new_since: string | null;
limit: number;
offset: number;
@@ -46,6 +47,7 @@ export function emptyTemplateFilter(): TemplateFilter {
sets: [],
tags: [],
fired: false,
callback: false,
new_since: null,
limit: 50,
offset: 0
@@ -103,6 +105,7 @@ export interface TemplateLibraryStats {
sets: TemplateSetSpec[];
tags: SelectionBreakdown[];
fired: number;
callback: number;
new: number;
seen_at: string | null;
last_synced_at: string | null;
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest';
import { relativeTime, relativeTimeLong, uptime } from './dates';
import { relativeTime, relativeTimeLong, untilTime, uptime } from './dates';
const NOW = new Date('2026-09-11T12:00:00Z');
const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000).toISOString();
@@ -105,3 +105,19 @@ describe('uptime', () => {
expect(relativeTime(ago(3 * HOUR))).toBe(`${uptime(ago(3 * HOUR))} ago`);
});
});
describe('untilTime', () => {
it('reads a future moment in the same buckets', () => {
expect(untilTime(ago(-5 * HOUR))).toBe('in 5h');
expect(untilTime(ago(-2 * DAY))).toBe('in 2d');
});
it('reads a moment under a minute away', () => {
expect(untilTime(new Date(NOW.getTime() + 20_000).toISOString())).toBe('in under a minute');
});
it('returns null once the moment has passed', () => {
expect(untilTime(ago(35))).toBeNull();
expect(untilTime(null)).toBeNull();
});
});
+14
View File
@@ -42,6 +42,20 @@ export function relativeTimeLong(timestamp: string | Date | null | undefined): s
return `${e.count} ${e.long}${e.count === 1 ? '' : 's'} ago`;
}
/** Time until a future moment, as "in 5h", or null once it has passed. */
export function untilTime(timestamp: string | Date | null | undefined): string | null {
if (!timestamp) return null;
const then = new Date(timestamp).getTime();
if (Number.isNaN(then)) return null;
const minutes = Math.floor((then - Date.now()) / 60000);
if (minutes < 0) return null;
for (const [per, short] of UNITS) {
const count = Math.floor(minutes / per);
if (count >= 1) return `in ${count}${short}`;
}
return 'in under a minute';
}
/** How long something has been running, as "3h" or "under a minute". */
export function uptime(timestamp: string | Date | null | undefined): string {
const e = elapsed(timestamp);
+5
View File
@@ -0,0 +1,5 @@
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
export function pageTitle(label: string): string {
return `${label} · ${capabilitiesStore.instanceName}`;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -91,7 +92,7 @@
}
</script>
<svelte:head><title>{routeLabels.agents} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.agents)}</title></svelte:head>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-end justify-between gap-3">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -42,7 +43,7 @@
});
</script>
<svelte:head><title>{routeLabels.arsenal} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.arsenal)}</title></svelte:head>
<div class="space-y-6">
<div>
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { ROUTES, routeLabels } from '$lib/config/routes';
onMount(() => goto(ROUTES.scans));
</script>
<svelte:head><title>{routeLabels.automation} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.automation)}</title></svelte:head>
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
@@ -211,7 +212,7 @@
const total = $derived(scanContextsStore.contexts.length);
</script>
<svelte:head><title>{routeLabels.contexts} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.contexts)}</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { onMount, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
@@ -122,6 +123,18 @@
if (!proxiesStore.hasFetched) untrack(() => proxiesStore.fetch());
});
let proxyPreset = false;
$effect(() => {
const fallback = proxiesStore.defaultId;
if (!isNew || !draft || proxyPreset || !proxiesStore.hasFetched) return;
proxyPreset = true;
if (fallback && !draft.proxy_id) {
untrack(() => {
if (draft) draft.proxy_id = fallback;
});
}
});
$effect(() => {
const project = projectsStore.activeProject;
const id = contextId;
@@ -187,6 +200,7 @@
const templateKey = page.url.searchParams.get('template');
const template = contextTemplate(templateKey);
draft = templateDraft(templateKey);
proxyPreset = false;
loaded = null;
baseline = '';
touchedSecrets.clear();
@@ -349,7 +363,7 @@
}
</script>
<svelte:head><title>{loaded?.name ?? routeLabels.contexts} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(loaded?.name ?? routeLabels.contexts)}</title></svelte:head>
{#snippet form()}
<section class="form">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
@@ -274,7 +275,7 @@
const selectable = $derived(scanEnginesStore.engines.filter((e) => !e.builtin).length);
</script>
<svelte:head><title>{routeLabels.engines} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.engines)}</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import { untrack } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -126,7 +127,7 @@
}
</script>
<svelte:head><title>{routeLabels.schedules} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.schedules)}</title></svelte:head>
<div class="space-y-6">
<div class="flex items-start justify-between">
@@ -1,10 +1,12 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import SettingsIcon from '@lucide/svelte/icons/settings';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import TargetIcon from '@lucide/svelte/icons/target';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import EmptyState from '$lib/components/empty-state.svelte';
@@ -20,6 +22,7 @@
import ProgramSheet from '$lib/components/bounty-hub/program-sheet.svelte';
import WatchingTab from '$lib/components/bounty-hub/watching-tab.svelte';
import WatchSheet from '$lib/components/bounty-hub/watch-sheet.svelte';
import SettingsSheet from '$lib/components/bounty-hub/settings-sheet.svelte';
import { watchesApi } from '$lib/api/watches';
import { watchesStore } from '$lib/stores/watches.svelte';
import { bountyVocabulary } from '$lib/stores/bounty-vocabulary.svelte';
@@ -31,7 +34,14 @@
REFRESH_POLL_MS,
SYNC_INTERVAL_LABELS
} from '$lib/config/bounty-programs';
import { BOUNTY_HUB_TABS, ROUTES, type BountyHubTab } from '$lib/config/routes';
import {
BOUNTY_HUB_TABS,
BOUNTY_SETTINGS_PANEL,
PANEL_PARAM,
ROUTES,
routeLabels,
type BountyHubTab
} from '$lib/config/routes';
import { projectsStore } from '$lib/stores/projects.svelte';
import { relativeTime } from '$lib/utilities/dates';
import type {
@@ -58,6 +68,20 @@
let watchTab = $state<'hosts' | 'activity'>('hosts');
let deepLinkedWatch = $state<string | null>(null);
let stream = $state<StreamStatus | null>(null);
let settingsOpen = $state(page.url.searchParams.get(PANEL_PARAM) === BOUNTY_SETTINGS_PANEL);
$effect(() => {
if (settingsOpen) return;
const params = untrack(() => new URLSearchParams(page.url.searchParams));
if (params.get(PANEL_PARAM) !== BOUNTY_SETTINGS_PANEL) return;
params.delete(PANEL_PARAM);
const qs = params.toString();
try {
replaceState(qs ? `?${qs}` : location.pathname, {});
} catch {
// ignore
}
});
const projectId = $derived(projectsStore.activeProject?.id);
const filterKey = $derived(JSON.stringify(filters));
@@ -255,12 +279,12 @@
}
</script>
<svelte:head><title>Bounty Hub · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels['bounty-hub'])}</title></svelte:head>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold">Bounty Hub</h1>
<h1 class="text-2xl font-semibold tracking-tight">{routeLabels['bounty-hub']}</h1>
<p class="text-sm text-muted-foreground">
{#if status}
{status.programs.toLocaleString()} programs across {status.platforms.filter(
@@ -273,9 +297,13 @@
· synced {relativeTime(status.last_synced_at)}
{/if}
·
<a href={ROUTES.settings('bounty-hub')} class="hover:underline">
<button
type="button"
class="transition-colors hover:text-foreground"
onclick={() => (settingsOpen = true)}
>
{SYNC_INTERVAL_LABELS[status.sync_interval] ?? status.sync_interval}
</a>
</button>
{:else}
Bug bounty programs and their scope
{/if}
@@ -291,6 +319,10 @@
<RefreshCwIcon class="mr-2 size-3.5" />
Refresh all platforms
</LoadingButton>
<Button variant="outline" size="sm" onclick={() => (settingsOpen = true)}>
<SettingsIcon class="size-4" />
Settings
</Button>
</div>
</div>
@@ -401,3 +433,5 @@
}}
/>
{/if}
<SettingsSheet bind:open={settingsOpen} onSaved={() => void loadStatus()} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
@@ -39,7 +40,7 @@
import { bountyProgramsApi } from '$lib/api/bounty-programs';
import { bountyVocabulary } from '$lib/stores/bounty-vocabulary.svelte';
import { breadcrumbStore } from '$lib/stores/breadcrumbs.svelte';
import { ROUTES } from '$lib/config/routes';
import { ROUTES, routeLabels } from '$lib/config/routes';
import { REFRESH_POLLS, REFRESH_POLL_MS } from '$lib/config/bounty-programs';
import {
REPORT_PAGE_SIZE,
@@ -371,7 +372,7 @@
}
</script>
<svelte:head><title>{label} · Bounty Hub · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(`${label} · ${routeLabels['bounty-hub']}`)}</title></svelte:head>
<svelte:window onkeydown={onKey} />
{#snippet sortHead(text: string, key: ReportSort, cls: string)}
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -107,12 +108,12 @@
}
</script>
<svelte:head><title>{routeLabels.connectors} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.connectors)}</title></svelte:head>
<div class="space-y-6 p-4 md:p-6">
<div class="space-y-6">
<header class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-xl font-semibold">{routeLabels.connectors}</h1>
<h1 class="text-2xl font-semibold tracking-tight">{routeLabels.connectors}</h1>
</div>
<Button size="sm" onclick={() => (newOpen = true)}>
<PlusIcon class="size-4" />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { ROUTES, routeLabels } from '$lib/config/routes';
import { SURFACE, SurfaceDimension } from '$lib/config/surface';
import { untrack } from 'svelte';
@@ -168,7 +169,7 @@
}
</script>
<svelte:head><title>{routeLabels.dashboard} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.dashboard)}</title></svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-end justify-between gap-4">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -44,7 +45,7 @@
});
</script>
<svelte:head><title>{routeLabels.exposures} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.exposures)}</title></svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-semibold tracking-tight">{routeLabels.exposures}</h1>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import { untrack } from 'svelte';
import Search from '@lucide/svelte/icons/search';
@@ -119,7 +120,7 @@
}
</script>
<svelte:head><title>{routeLabels.notes} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.notes)}</title></svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-semibold tracking-tight">Notes</h1>
@@ -1,4 +1,6 @@
<script lang="ts">
import { ROLE_LABELS, roleLabel } from '$lib/config/users';
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import { auth } from '$lib/stores/auth.svelte';
import ChangeUsernameCard from '$lib/components/profile/change-username-card.svelte';
@@ -16,7 +18,7 @@
import { getInitials } from '$lib/utilities';
</script>
<svelte:head><title>{routeLabels.profile} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.profile)}</title></svelte:head>
<div class="container max-w-5xl mx-auto space-y-6">
<h1 class="text-2xl font-semibold tracking-tight">Account and security</h1>
@@ -37,7 +39,7 @@
<div class="flex items-baseline gap-2 mb-0.5">
<h2 class="text-xl font-semibold">{auth.user?.username}</h2>
{#if auth.user?.is_superuser}
<Badge variant="secondary" class="h-5 text-xs px-2">Administrator</Badge>
<Badge variant="secondary" class="h-5 text-xs px-2">{ROLE_LABELS.admin}</Badge>
{/if}
</div>
<div class="flex items-center gap-3 text-sm text-muted-foreground">
@@ -89,7 +91,7 @@
<div class="space-y-1">
<p class="text-sm font-medium text-muted-foreground">Account type</p>
<p class="text-sm">
{auth.user?.is_superuser ? 'Administrator' : 'Standard user'}
{roleLabel(auth.user?.is_superuser ?? false)}
</p>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -112,9 +113,9 @@
}
</script>
<svelte:head><title>{routeLabels['remote-control']} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels['remote-control'])}</title></svelte:head>
<div class="flex flex-col gap-4 p-4 md:p-6">
<div class="flex flex-col gap-4">
<h1 class="sr-only">{routeLabels['remote-control']}</h1>
{#if !status}
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -195,7 +196,7 @@
}
</script>
<svelte:head><title>{routeLabels.reports} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.reports)}</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
@@ -162,7 +163,7 @@
}
</script>
<svelte:head><title>{name || 'Report template'} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(name || 'Report template')}</title></svelte:head>
{#if !template}
<div class="space-y-4">
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import { page } from '$app/state';
import { toast } from 'svelte-sonner';
@@ -72,7 +73,7 @@
}
</script>
<svelte:head><title>{routeLabels.scans} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.scans)}</title></svelte:head>
<div class="flex flex-col gap-4">
<h1 class="sr-only">Scans</h1>
@@ -4,15 +4,20 @@
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import KeyRoundIcon from '@lucide/svelte/icons/key-round';
import RouteIcon from '@lucide/svelte/icons/route';
import SatelliteDishIcon from '@lucide/svelte/icons/satellite-dish';
import BellIcon from '@lucide/svelte/icons/bell';
import AwardIcon from '@lucide/svelte/icons/award';
import CpuIcon from '@lucide/svelte/icons/cpu';
import UsersIcon from '@lucide/svelte/icons/users';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { ROUTES, routeLabels, SETTINGS_SECTIONS, type SettingsSection } from '$lib/config/routes';
import { capabilitiesStore } from '$lib/stores/capabilities.svelte';
import { Capability } from '$lib/config/capabilities';
import {
ADMIN_SETTINGS,
ROUTES,
routeLabels,
SETTINGS_SECTIONS,
type SettingsSection
} from '$lib/config/routes';
import { auth } from '$lib/stores/auth.svelte';
import { settingsActions } from '$lib/stores/settings-actions.svelte';
import type { IconComponent } from '$lib/config/icons';
let { children } = $props();
@@ -21,46 +26,50 @@
general: Settings2Icon,
'api-keys': KeyRoundIcon,
proxies: RouteIcon,
oast: SatelliteDishIcon,
notifications: BellIcon,
'bounty-hub': AwardIcon,
ai: CpuIcon
ai: CpuIcon,
users: UsersIcon
};
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const sections = $derived(
SETTINGS_SECTIONS.filter(
(section) => section !== 'bounty-hub' || capabilitiesStore.has(Capability.BOUNTY_PROGRAMS)
)
SETTINGS_SECTIONS.filter((section) => isAdmin || !ADMIN_SETTINGS.includes(section))
);
const active = $derived(
sections.find((section) => page.url.pathname.startsWith(ROUTES.settings(section))) ?? ''
);
</script>
<div class="space-y-6">
<div class="flex flex-col gap-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{routeLabels.settings}</h1>
<p class="mt-1 text-sm text-muted-foreground">Instance-wide configuration</p>
</div>
<Tabs.Root
value={active}
onValueChange={(v) => {
if (v && v !== active) goto(ROUTES.settings(v as SettingsSection));
}}
>
<ScrollArea orientation="horizontal" class="w-full sm:w-fit">
<Tabs.List>
{#each sections as section (section)}
{@const Icon = ICONS[section]}
<Tabs.Trigger value={section} class="gap-1.5">
<Icon class="size-4" />
{routeLabels[section]}
</Tabs.Trigger>
{/each}
</Tabs.List>
</ScrollArea>
</Tabs.Root>
<div class="flex flex-wrap items-center justify-between gap-3">
<Tabs.Root
value={active}
onValueChange={(v) => {
if (v && v !== active) goto(ROUTES.settings(v as SettingsSection));
}}
class="min-w-0"
>
<ScrollArea orientation="horizontal" class="w-full sm:w-fit">
<Tabs.List>
{#each sections as section (section)}
{@const Icon = ICONS[section]}
<Tabs.Trigger value={section} class="gap-1.5">
<Icon class="size-4" />
{routeLabels[section]}
</Tabs.Trigger>
{/each}
</Tabs.List>
</ScrollArea>
</Tabs.Root>
{#if settingsActions.snippet}
<div class="flex items-center gap-2">{@render settingsActions.snippet()}</div>
{/if}
</div>
{@render children()}
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { ROUTES, routeLabels } from '$lib/config/routes';
onMount(() => goto(ROUTES.settings('general')));
</script>
<svelte:head><title>{routeLabels.settings} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.settings)}</title></svelte:head>
+332 -270
View File
@@ -1,70 +1,82 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Alert from '$lib/components/ui/alert/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import FlaskConicalIcon from '@lucide/svelte/icons/flask-conical';
import { toast } from 'svelte-sonner';
import CheckIcon from '@lucide/svelte/icons/check';
import CircleXIcon from '@lucide/svelte/icons/circle-x';
import CpuIcon from '@lucide/svelte/icons/cpu';
import EyeIcon from '@lucide/svelte/icons/eye';
import EyeOffIcon from '@lucide/svelte/icons/eye-off';
import PanelHead from '$lib/components/panel-head.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
import FormField from '$lib/components/form-field.svelte';
import Hint from '$lib/components/hint.svelte';
import LoadingButton from '$lib/components/loading-button.svelte';
import PanelHead from '$lib/components/panel-head.svelte';
import SettingRow from '$lib/components/settings/setting-row.svelte';
import { ai } from '$lib/stores/ai.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { routeLabels } from '$lib/config/routes';
import { relativeTime } from '$lib/utilities/dates';
import { toast } from 'svelte-sonner';
import type { AiTestResult } from '$lib/types/ai';
let provider = $state('anthropic');
const ANTHROPIC = 'anthropic';
let dialogOpen = $state(false);
let provider = $state(ANTHROPIC);
let model = $state('');
let fastModel = $state('');
let workspaceId = $state('');
let apiKey = $state('');
let showKey = $state(false);
let testing = $state(false);
let draftResult = $state<AiTestResult | null>(null);
let result = $state<AiTestResult | null>(null);
let loaded = $state(false);
const status = $derived(ai.status);
const catalog = $derived(ai.catalog);
const isAdmin = $derived(auth.user?.is_superuser ?? false);
const providerSpec = $derived(catalog?.providers.find((p) => p.key === provider));
const isAnthropic = $derived(provider === 'anthropic');
const stored = $derived(catalog?.providers.find((p) => p.key === status?.provider));
$effect(() => {
untrack(() => void ai.fetch());
});
$effect(() => {
const current = status;
if (!current || loaded) return;
loaded = true;
provider = current.provider ?? 'anthropic';
model = current.model ?? '';
fastModel = current.fast_model ?? '';
workspaceId = current.workspace_id ?? '';
});
function modelLabel(spec: typeof stored, id: string | null): string {
if (!id) return 'Not set';
return spec?.models.find((m) => m.id === id)?.label ?? id;
}
function openEdit() {
provider = status?.provider ?? ANTHROPIC;
model = status?.model ?? '';
fastModel = status?.fast_model ?? '';
workspaceId = status?.workspace_id ?? '';
apiKey = '';
showKey = false;
draftResult = null;
dialogOpen = true;
}
function pickProvider(value: string) {
if (!value || value === provider) return;
provider = value;
const spec = catalog?.providers.find((p) => p.key === value);
model = spec?.models[0]?.id ?? '';
fastModel = spec?.models.at(-1)?.id ?? model;
}
async function test() {
async function testDraft() {
testing = true;
result = await ai.test({
draftResult = await ai.test({
provider,
model,
api_key: apiKey.trim() || undefined,
@@ -73,32 +85,40 @@
testing = false;
}
async function testStored() {
testing = true;
result = await ai.test({ provider: status?.provider ?? ANTHROPIC });
testing = false;
}
async function save() {
const ok = await ai.save({
provider,
model,
fast_model: fastModel,
workspace_id: workspaceId.trim(),
workspace_id: provider === ANTHROPIC ? workspaceId.trim() : '',
api_key: apiKey.trim() || undefined
});
if (ok) {
apiKey = '';
toast.success('Saved');
toast.success('Connection saved');
result = null;
dialogOpen = false;
}
}
async function setEnabled(value: boolean) {
const ok = await ai.save({ enabled: value });
if (ok) toast.success(value ? 'AI enabled' : 'AI disabled');
if (await ai.save({ enabled: value })) toast.success(value ? 'AI enabled' : 'AI disabled');
}
async function setFeature(key: string, value: boolean) {
await ai.save({ features: { [key]: value } });
async function setFeature(key: string, label: string, value: boolean) {
if (await ai.save({ features: { [key]: value } })) {
toast.success(`${label} ${value ? 'enabled' : 'disabled'}`);
}
}
async function clearCache() {
const removed = await ai.clearCache();
toast.success(`${removed} cached narratives removed`);
toast.success(`${removed.toLocaleString()} cached narratives removed`);
}
function money(value: number | null): string {
@@ -106,247 +126,289 @@
}
</script>
<svelte:head><title>{routeLabels.ai} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.ai)}</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold tracking-tight">{routeLabels.ai}</h2>
<p class="mt-1 max-w-2xl text-sm text-muted-foreground">
Model connection, feature opt-ins and usage for report narratives
</p>
</div>
{#if status}
<div class="flex items-center gap-3 rounded-md border px-3 py-2">
<span class="text-sm">{status.enabled ? 'On' : 'Off'}</span>
<Switch
checked={status.enabled}
disabled={!isAdmin || !status.configured}
onCheckedChange={setEnabled}
/>
{#if ai.isLoading && !status}
<Card.Root class="gap-3 p-5">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
</Card.Root>
{:else if !status}
<EmptyState compact icon={CpuIcon} title="AI settings not loaded">
<Button variant="outline" size="sm" onclick={() => ai.fetch(true)}>Retry</Button>
</EmptyState>
{:else}
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card.Root class="gap-0 overflow-hidden py-0">
<div id="ai-connection" class="scroll-mt-20"></div>
<PanelHead title="Connection">
<label class="flex items-center gap-2 text-sm text-foreground" for="ai-enabled">
Enabled
<Switch
id="ai-enabled"
checked={status.enabled}
disabled={!isAdmin || !status.configured || ai.isSaving}
onCheckedChange={setEnabled}
/>
</label>
</PanelHead>
<SettingRow label="Provider" class="border-t-0">
<span class="text-sm">{stored?.label ?? 'Not set'}</span>
</SettingRow>
<SettingRow label="Model">
<span class="text-sm">{modelLabel(stored, status.model)}</span>
</SettingRow>
<SettingRow label="Fast model" help="Asset judgement, finding explanations and attack paths">
<span class="text-sm">{modelLabel(stored, status.fast_model)}</span>
</SettingRow>
<SettingRow label="API key">
<code class="font-mono text-xs text-muted-foreground">
{status.key_masked ?? 'Not set'}
</code>
</SettingRow>
{#if status.provider === ANTHROPIC}
<SettingRow label="Workspace ID">
<code class="font-mono text-xs text-muted-foreground">
{status.workspace_id || 'Not set'}
</code>
</SettingRow>
{/if}
<div class="flex flex-wrap items-center gap-2 border-t px-5 py-3">
<Button variant="outline" size="sm" disabled={!isAdmin} onclick={openEdit}>
Edit connection
</Button>
<LoadingButton
variant="ghost"
size="sm"
loading={testing && !dialogOpen}
loadingLabel="Testing"
disabled={!isAdmin || !status.configured || testing}
onclick={testStored}
>
Test connection
</LoadingButton>
{#if result}
<span class="flex min-w-0 items-center gap-1.5 text-xs">
{#if result.success}
<CheckIcon class="size-3.5 shrink-0 text-success" />
{:else}
<CircleXIcon class="size-3.5 shrink-0 text-destructive" />
{/if}
<span class="wrap-anywhere">{result.message}</span>
</span>
{/if}
</div>
{/if}
<div id="ai-features" class="scroll-mt-20"></div>
<div class="border-t px-5 pt-4 pb-2">
<h2 class="text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase">
Features
</h2>
</div>
{#each catalog?.features ?? [] as feature, i (feature.key)}
<SettingRow
label={feature.label}
help={feature.help}
for="ai-feature-{feature.key}"
class={i === 0 ? 'border-t-0' : ''}
>
<Switch
id="ai-feature-{feature.key}"
checked={status.features[feature.key] ?? feature.default}
disabled={!isAdmin || ai.isSaving}
onCheckedChange={(v) => setFeature(feature.key, feature.label, v)}
/>
</SettingRow>
{/each}
{#if !isAdmin}
<div class="border-t px-5 py-2.5 text-xs text-muted-foreground">
Editable by administrators.
</div>
{/if}
</Card.Root>
<Card.Root class="h-fit gap-0 overflow-hidden py-0">
<div id="ai-usage" class="scroll-mt-20"></div>
<PanelHead title="Usage">
{#if status.usage.since}
<span>Since {relativeTime(status.usage.since)}</span>
{/if}
</PanelHead>
<dl class="divide-y text-sm">
<div class="flex items-baseline justify-between px-5 py-2.5">
<dt class="text-muted-foreground">Reports written</dt>
<dd class="tabular-nums">{status.usage.reports.toLocaleString()}</dd>
</div>
<div class="flex items-baseline justify-between px-5 py-2.5">
<dt class="text-muted-foreground">Model calls</dt>
<dd class="tabular-nums">{status.usage.calls.toLocaleString()}</dd>
</div>
<div class="flex items-baseline justify-between px-5 py-2.5">
<dt class="text-muted-foreground">Served from cache</dt>
<dd class="tabular-nums">{status.usage.cached.toLocaleString()}</dd>
</div>
<div class="flex items-baseline justify-between px-5 py-2.5">
<dt class="text-muted-foreground">Tokens in / out</dt>
<dd class="tabular-nums">
{status.usage.input_tokens.toLocaleString()} / {status.usage.output_tokens.toLocaleString()}
</dd>
</div>
<div class="flex items-baseline justify-between px-5 py-2.5">
<dt class="text-muted-foreground">Estimated spend</dt>
<dd class="tabular-nums">{money(status.usage.cost_usd)}</dd>
</div>
<div class="flex items-center justify-between gap-3 px-5 py-2">
<dt class="text-muted-foreground">Cached narratives</dt>
<dd class="flex items-center gap-2 tabular-nums">
{status.cached_narratives.toLocaleString()}
<Hint text="Cleared narratives are rewritten and billed on the next report">
{#snippet child(props)}
<span {...props} class="inline-flex">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
disabled={!isAdmin || !status.cached_narratives}
onclick={clearCache}
>
Clear
</Button>
</span>
{/snippet}
</Hint>
</dd>
</div>
</dl>
</Card.Root>
</div>
{/if}
{#if ai.isLoading && !status}
<Skeleton class="h-64 w-full" />
{:else if status}
<Alert.Root variant="destructive">
<ShieldAlertIcon />
<Alert.Title>Scan data is sent to the configured provider</Alert.Title>
<Alert.Description>
A computed summary of each scan is sent: counts, severity totals, check names and detected
conditions. Request and response bodies, credentials and scan context headers are not sent.
</Alert.Description>
</Alert.Root>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
<Card.Root class="gap-0 py-0">
<div id="ai-connection" class="scroll-mt-20"></div>
<PanelHead title="Connection" description="Provider and model used for narratives" />
<div class="space-y-5 px-5 py-5">
<div class="space-y-2">
<Label class="text-xs">Provider</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
{#each catalog?.providers ?? [] as item (item.key)}
<button
type="button"
class="rounded-md border px-3 py-2 text-sm transition-colors data-[active=true]:border-primary data-[active=true]:bg-muted"
data-active={provider === item.key}
disabled={!isAdmin}
onclick={() => pickProvider(item.key)}
>
{item.label}
</button>
{/each}
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5">
<Label class="text-xs" for="ai-key">API key</Label>
<div class="relative">
<Input
id="ai-key"
type={showKey ? 'text' : 'password'}
bind:value={apiKey}
placeholder={status.key_masked ?? providerSpec?.key_hint ?? 'API key'}
autocomplete="off"
disabled={!isAdmin}
class="h-9 pr-9 font-mono text-xs"
/>
<button
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
onclick={() => (showKey = !showKey)}
aria-label={showKey ? 'Hide key' : 'Show key'}
>
{#if showKey}<EyeOffIcon class="size-4" />{:else}<EyeIcon class="size-4" />{/if}
</button>
</div>
{#if status.configured}
<p class="text-xs text-muted-foreground">
A key is stored. An empty field keeps it.
</p>
{/if}
</div>
{#if isAnthropic}
<div class="space-y-1.5">
<Label class="text-xs" for="ai-workspace">Workspace ID</Label>
<Input
id="ai-workspace"
bind:value={workspaceId}
disabled={!isAdmin}
class="h-9 font-mono text-xs"
/>
<p class="text-xs text-muted-foreground">
Required for keys not scoped to a workspace.
</p>
</div>
{/if}
<div class="space-y-1.5">
<Label class="text-xs">Model for narrative</Label>
<Select.Root type="single" bind:value={model}>
<Select.Trigger class="h-9 w-full" disabled={!isAdmin}>
{providerSpec?.models.find((m) => m.id === model)?.label ??
(model || 'Choose a model')}
</Select.Trigger>
<Select.Content>
{#each providerSpec?.models ?? [] as item (item.id)}
<Select.Item value={item.id}>
<span class="flex flex-col items-start gap-0.5">
<span>{item.label}</span>
{#if item.input_per_mtok}
<span class="text-xs text-muted-foreground">
${item.input_per_mtok} in / ${item.output_per_mtok} out per million tokens
</span>
{/if}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">Model for per-check explanations</Label>
<Select.Root type="single" bind:value={fastModel}>
<Select.Trigger class="h-9 w-full" disabled={!isAdmin}>
{providerSpec?.models.find((m) => m.id === fastModel)?.label ??
(fastModel || 'Choose a model')}
</Select.Trigger>
<Select.Content>
{#each providerSpec?.models ?? [] as item (item.id)}
<Select.Item value={item.id}>{item.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Written once per check and cached.</p>
</div>
</div>
{#if result}
<Alert.Root variant={result.success ? 'default' : 'destructive'}>
{#if result.success}<CheckIcon />{:else}<CircleXIcon />{/if}
<Alert.Title>{result.success ? 'Connected' : 'Connection failed'}</Alert.Title>
<Alert.Description class="wrap-anywhere">{result.message}</Alert.Description>
</Alert.Root>
{/if}
<div class="flex items-center gap-2">
<LoadingButton variant="outline" loading={testing} disabled={!isAdmin} onclick={test}>
<FlaskConicalIcon class="mr-1.5 size-3.5" />
Test connection
</LoadingButton>
<LoadingButton loading={ai.isSaving} disabled={!isAdmin} onclick={save}>
Save
</LoadingButton>
</div>
{#if !isAdmin}
<p class="text-xs text-muted-foreground">Editable by administrators only.</p>
{/if}
</div>
<Separator />
<div id="ai-features" class="scroll-mt-20"></div>
<PanelHead title="Features" description="Opt in per report" />
<div class="space-y-4 px-5 py-5">
{#each catalog?.features ?? [] as feature (feature.key)}
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<span class="text-sm font-medium">{feature.label}</span>
<p class="text-xs text-muted-foreground">{feature.help}</p>
</div>
<Switch
checked={status.features[feature.key] ?? feature.default}
disabled={!isAdmin}
onCheckedChange={(v) => setFeature(feature.key, v)}
/>
</div>
{/each}
</div>
</Card.Root>
<div class="space-y-5">
<Card.Root class="gap-0 py-0">
<div id="ai-usage" class="scroll-mt-20"></div>
<PanelHead title="Usage" description="All reports on this instance" />
<div class="divide-y">
<div class="flex items-baseline justify-between px-5 py-3">
<span class="text-sm text-muted-foreground">Reports written</span>
<span class="font-medium">{status.usage.reports.toLocaleString()}</span>
</div>
<div class="flex items-baseline justify-between px-5 py-3">
<span class="text-sm text-muted-foreground">Model calls</span>
<span class="font-medium">{status.usage.calls.toLocaleString()}</span>
</div>
<div class="flex items-baseline justify-between px-5 py-3">
<span class="text-sm text-muted-foreground">Served from cache</span>
<span class="font-medium">{status.usage.cached.toLocaleString()}</span>
</div>
<div class="flex items-baseline justify-between px-5 py-3">
<span class="text-sm text-muted-foreground">Tokens in / out</span>
<span class="font-medium">
{status.usage.input_tokens.toLocaleString()} / {status.usage.output_tokens.toLocaleString()}
</span>
</div>
<div class="flex items-baseline justify-between px-5 py-3">
<span class="text-sm text-muted-foreground">Estimated spend</span>
<span class="font-medium">{money(status.usage.cost_usd)}</span>
</div>
{#if status.usage.since}
<div class="px-5 py-2.5 text-xs text-muted-foreground">
Since {relativeTime(status.usage.since)}
</div>
{/if}
</div>
</Card.Root>
<Card.Root class="gap-0 py-0">
<PanelHead title="Cached narratives" />
<div class="space-y-3 px-5 py-4">
<div class="flex items-baseline justify-between">
<span class="text-sm text-muted-foreground">Cached</span>
<Badge variant="secondary">{status.cached_narratives.toLocaleString()}</Badge>
</div>
<p class="text-xs text-muted-foreground">
Cleared narratives are rewritten and billed on the next report.
</p>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Edit connection</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-4">
<FormField label="Provider">
{#snippet children({ id })}
<ToggleGroup.Root
{id}
type="single"
variant="outline"
value={provider}
onValueChange={pickProvider}
class="w-fit flex-wrap"
aria-label="Provider"
>
{#each catalog?.providers ?? [] as item (item.key)}
<ToggleGroup.Item value={item.key} class="h-9 px-3 text-sm font-normal">
{item.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
{/snippet}
</FormField>
<FormField label="API key">
{#snippet children({ id })}
<div class="relative">
<Input
{id}
type={showKey ? 'text' : 'password'}
bind:value={apiKey}
placeholder={status?.key_masked ?? providerSpec?.key_hint ?? ''}
autocomplete="off"
class="pr-10 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
class="w-full"
disabled={!isAdmin || !status.cached_narratives}
onclick={clearCache}
type="button"
variant="ghost"
size="icon"
class="absolute top-1/2 right-1.5 size-7 -translate-y-1/2 text-muted-foreground"
aria-label={showKey ? 'Hide key' : 'Show key'}
aria-pressed={showKey}
onclick={() => (showKey = !showKey)}
>
Clear cache
{#if showKey}<EyeOffIcon class="size-4" />{:else}<EyeIcon class="size-4" />{/if}
</Button>
</div>
</Card.Root>
{/snippet}
</FormField>
{#if provider === ANTHROPIC}
<FormField label="Workspace ID" description="Required for keys not scoped to a workspace">
{#snippet children({ id })}
<Input {id} bind:value={workspaceId} autocomplete="off" class="font-mono text-xs" />
{/snippet}
</FormField>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<FormField label="Model">
{#snippet children({ id })}
<Select.Root type="single" bind:value={model}>
<Select.Trigger {id} class="w-full">
{providerSpec?.models.find((m) => m.id === model)?.label ?? 'Not set'}
</Select.Trigger>
<Select.Content>
{#each providerSpec?.models ?? [] as item (item.id)}
<Select.Item value={item.id} label={item.label}>
<span class="flex flex-col items-start gap-0.5">
<span>{item.label}</span>
{#if item.input_per_mtok}
<span class="text-xs text-muted-foreground">
${item.input_per_mtok} in · ${item.output_per_mtok} out per million tokens
</span>
{/if}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</FormField>
<FormField label="Fast model">
{#snippet children({ id })}
<Select.Root type="single" bind:value={fastModel}>
<Select.Trigger {id} class="w-full">
{providerSpec?.models.find((m) => m.id === fastModel)?.label ?? 'Not set'}
</Select.Trigger>
<Select.Content>
{#each providerSpec?.models ?? [] as item (item.id)}
<Select.Item value={item.id} label={item.label}>{item.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</FormField>
</div>
{#if draftResult}
<p class="flex items-start gap-1.5 text-xs">
{#if draftResult.success}
<CheckIcon class="mt-px size-3.5 shrink-0 text-success" />
{:else}
<CircleXIcon class="mt-px size-3.5 shrink-0 text-destructive" />
{/if}
<span class="wrap-anywhere">{draftResult.message}</span>
</p>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="gap-2 sm:justify-between">
<LoadingButton
variant="ghost"
loading={testing && dialogOpen}
loadingLabel="Testing"
disabled={testing || ai.isSaving}
onclick={testDraft}
>
Test
</LoadingButton>
<div class="flex gap-2">
<Button variant="outline" disabled={ai.isSaving} onclick={() => (dialogOpen = false)}>
Cancel
</Button>
<LoadingButton loading={ai.isSaving} loadingLabel="Saving" onclick={save}>
Save connection
</LoadingButton>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import ApiKeysPanel from '$lib/components/settings/api-keys-panel.svelte';
</script>
<svelte:head><title>{routeLabels['api-keys']} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels['api-keys'])}</title></svelte:head>
<ApiKeysPanel />
@@ -1,16 +1,6 @@
<script lang="ts">
import { routeLabels } from '$lib/config/routes';
import BountyHubPanel from '$lib/components/settings/bounty-hub-panel.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { ROUTES } from '$lib/config/routes';
onMount(() => goto(ROUTES.bountyHubSettings(), { replaceState: true }));
</script>
<svelte:head><title>{routeLabels['bounty-hub']} · reNgine</title></svelte:head>
<div class="space-y-6">
<div class="max-w-2xl">
<h2 class="text-lg font-semibold tracking-tight">Bounty Hub</h2>
<p class="mt-1 text-sm text-muted-foreground">
Program sources and the events recorded when a program changes.
</p>
</div>
<BountyHubPanel />
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import InstanceSettingsPanel from '$lib/components/settings/instance-settings-panel.svelte';
</script>
<svelte:head><title>{routeLabels.general} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.general)}</title></svelte:head>
<InstanceSettingsPanel />
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import NotificationChannelsPanel from '$lib/components/settings/notification-channels-panel.svelte';
</script>
<svelte:head><title>{routeLabels.notifications} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.notifications)}</title></svelte:head>
<NotificationChannelsPanel />
@@ -1,8 +1,6 @@
<script lang="ts">
import { routeLabels } from '$lib/config/routes';
import OastPanel from '$lib/components/settings/oast-panel.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { ROUTES } from '$lib/config/routes';
onMount(() => goto(ROUTES.callbackServer(), { replaceState: true }));
</script>
<svelte:head><title>{routeLabels.oast} · reNgine</title></svelte:head>
<OastPanel />
@@ -1,8 +1,9 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { routeLabels } from '$lib/config/routes';
import ProxiesPanel from '$lib/components/settings/proxies-panel.svelte';
</script>
<svelte:head><title>{routeLabels.proxies} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.proxies)}</title></svelte:head>
<ProxiesPanel />
@@ -0,0 +1,9 @@
<script lang="ts">
import { routeLabels } from '$lib/config/routes';
import { pageTitle } from '$lib/utilities/page-title';
import UsersPanel from '$lib/components/settings/users-panel.svelte';
</script>
<svelte:head><title>{pageTitle(routeLabels.users)}</title></svelte:head>
<UsersPanel />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
@@ -53,7 +54,7 @@
}
</script>
<svelte:head><title>{spec?.label ?? routeLabels.surface} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(spec?.label ?? routeLabels.surface)}</title></svelte:head>
{#if !spec}
<EmptyState title="Unknown dimension">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import CorrelationTab from '$lib/components/scans/results/correlation/correlation-tab.svelte';
import ScopeStrip from '$lib/components/surface/scope-strip.svelte';
@@ -27,7 +28,7 @@
}
</script>
<svelte:head><title>{routeLabels.correlation} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.correlation)}</title></svelte:head>
<div class="space-y-6">
<div class="flex flex-col gap-1">
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import Bug from '@lucide/svelte/icons/bug';
@@ -172,7 +173,7 @@
);
</script>
<svelte:head><title>{routeLabels.cves} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.cves)}</title></svelte:head>
<svelte:window onkeydown={onKey} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { untrack } from 'svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -82,7 +83,7 @@
`${n.toLocaleString()} ${n === 1 ? one : many}`;
</script>
<svelte:head><title>{cve || routeLabels.cves} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(cve || routeLabels.cves)}</title></svelte:head>
<div class="space-y-6">
<a
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { targetsStore } from '$lib/stores/targets.svelte';
@@ -673,7 +674,7 @@
}
</script>
<svelte:head><title>{routeLabels.targets} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels.targets)}</title></svelte:head>
<svelte:window onkeydown={onKey} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { goto, replaceState } from '$app/navigation';
import { browser } from '$app/environment';
@@ -850,7 +851,7 @@
}
</script>
<svelte:head><title>{target?.target_value ?? routeLabels.targets} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(target?.target_value ?? routeLabels.targets)}</title></svelte:head>
<svelte:window onkeydown={onKeydown} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { pageTitle } from '$lib/utilities/page-title';
import { page } from '$app/state';
import { goto, replaceState } from '$app/navigation';
import { untrack } from 'svelte';
@@ -903,7 +904,7 @@
}
</script>
<svelte:head><title>{routeLabels['whats-new']} · reNgine</title></svelte:head>
<svelte:head><title>{pageTitle(routeLabels['whats-new'])}</title></svelte:head>
<svelte:window onkeydown={onKey} />
+6 -16
View File
@@ -173,32 +173,22 @@ AI_FEATURES: tuple[AIFeature, ...] = (
AIFeature(
"report_narrative",
"Report narrative",
"Writes the executive summary, risk narrative and remediation plan.",
"Executive summary, risk narrative and remediation plan. Sends the target, "
"counts, check names and the web assets named on attack paths.",
True,
),
AIFeature(
"report_findings",
"Finding explanations",
"Explains what a check means for this estate. Cached per check.",
False,
),
AIFeature(
"asset_judgement",
"Asset judgement",
"Reads hostnames and page titles after a scan and flags exposures with a reason. "
"Response bodies are not sent.",
"Flags exposures after a scan, with a reason. Sends web asset names, status "
"codes, page titles and technologies.",
False,
),
AIFeature(
"rule_suggestions",
"Rule suggestions",
"Proposes exposure rules from judgement results. Each is held for review.",
False,
),
AIFeature(
"attack_paths",
"Attack path narrative",
"Describes how observed weaknesses chain together.",
"Proposes exposure rules from judgement results, each held for review. Sends "
"web asset names and judgement reasons.",
False,
),
)
+74
View File
@@ -0,0 +1,74 @@
"""Event types a notification channel subscribes to, and its minimum level."""
from __future__ import annotations
from dataclasses import dataclass
from shared.definitions.mode_features import CAP_BOUNTY_PROGRAMS, CAP_PROGRAM_WATCHES
from shared.enums.notification import NotificationSeverity, NotificationType
@dataclass(frozen=True)
class ChannelEvent:
type: str
label: str
hint: str
capability: str | None = None
CHANNEL_EVENTS: tuple[ChannelEvent, ...] = (
ChannelEvent(
NotificationType.SCAN.value,
"Scan results",
"Run digest, failed runs, exposures and exploit intelligence changes",
),
ChannelEvent(
NotificationType.VULNERABILITY.value,
"New vulnerabilities",
"New findings and inferred software CVEs",
),
ChannelEvent(
NotificationType.INTEGRATION.value,
"Program changes",
"Scope and status changes on bug bounty programs",
CAP_BOUNTY_PROGRAMS,
),
ChannelEvent(
NotificationType.WATCH.value,
"Program watches",
"New in-scope assets on a watched program",
CAP_PROGRAM_WATCHES,
),
ChannelEvent(
NotificationType.NEW_CHECKS.value,
"New checks",
"Library additions and follow-up run results",
),
ChannelEvent(
NotificationType.SYSTEM.value,
"Reports and exports",
"Ready or failed",
),
ChannelEvent(
NotificationType.TARGET.value,
"Enrichment",
"Failed WHOIS and BGP lookups",
),
)
DEFAULT_CHANNEL_EVENTS: tuple[str, ...] = tuple(event.type for event in CHANNEL_EVENTS)
@dataclass(frozen=True)
class ChannelLevel:
value: str
label: str
CHANNEL_LEVELS: tuple[ChannelLevel, ...] = (
ChannelLevel(NotificationSeverity.INFO.value, "All events"),
ChannelLevel(NotificationSeverity.WARNING.value, "Warning and critical"),
ChannelLevel(NotificationSeverity.ERROR.value, "Critical only"),
)
DEFAULT_CHANNEL_LEVEL = NotificationSeverity.INFO.value
+1 -1
View File
@@ -33,7 +33,7 @@ OAST_MODE_LABELS: dict[str, str] = {
}
OAST_MODE_HELP: dict[str, str] = {
OastMode.OFF.value: "Checks that need a callback run against the response alone.",
OastMode.OFF.value: "Checks that need a callback are skipped.",
OastMode.SELF_HOSTED.value: (
"An interactsh server this instance operates. Callbacks reach that server."
),
+4
View File
@@ -4,6 +4,10 @@ from pathlib import Path
KEEP_FOREVER = 0
SCAN_RETENTION_DAYS: tuple[int, ...] = (30, 60, 90, 180, 365, KEEP_FOREVER)
SCREENSHOT_RETENTION_DAYS: tuple[int, ...] = (7, 14, 30, 60, 90, KEEP_FOREVER)
MAX_SCANS_PER_RUN = 200
KEEP_NEWEST_PER_TARGET = True
-5
View File
@@ -1,11 +1,6 @@
from enum import Enum
class ProxyMode(Enum):
SINGLE = "single"
ROTATING = "rotating"
class ProxyProtocol(Enum):
HTTP = "http"
HTTPS = "https"
+7
View File
@@ -104,6 +104,9 @@ class APIKey(SQLModel, table=True):
is_enabled: bool = Field(default=True)
usage_counter: int = Field(default=0)
last_used_at: datetime | None = Field(default=None)
last_test_at: datetime | None = Field(default=None)
last_test_ok: bool | None = Field(default=None)
last_test_message: str | None = Field(default=None, max_length=500)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
@@ -128,6 +131,9 @@ class APIKeyRead(BaseModel):
is_enabled: bool
usage_counter: int
last_used_at: datetime | None
last_test_at: datetime | None = None
last_test_ok: bool | None = None
last_test_message: str | None = None
created_at: datetime
updated_at: datetime
meta: dict
@@ -144,3 +150,4 @@ class ProviderInfo(BaseModel):
group_label: str
configured: bool = False
is_enabled: bool = False
testable: bool = False
+12 -11
View File
@@ -7,27 +7,22 @@ from sqlalchemy import Column
from sqlalchemy.types import JSON
from sqlmodel import Field, SQLModel
from shared.enums.notification import NotificationSeverity, NotificationType
from shared.definitions.notification_events import (
DEFAULT_CHANNEL_EVENTS,
DEFAULT_CHANNEL_LEVEL,
)
from shared.enums.notification_channel import NotificationProvider
from shared.utils.datetime import utc_now
from shared.utils.validation import clean_name, clean_optional_name
PROVIDERS = tuple(p.value for p in NotificationProvider)
DEFAULT_PREFERENCE_TYPES = [
NotificationType.SCAN.value,
NotificationType.VULNERABILITY.value,
NotificationType.TARGET.value,
NotificationType.SECURITY.value,
NotificationType.SYSTEM.value,
NotificationType.WATCH.value,
NotificationType.NEW_CHECKS.value,
]
DEFAULT_PREFERENCE_TYPES = list(DEFAULT_CHANNEL_EVENTS)
class NotificationPreference(BaseModel):
types: list[str] = Field(default_factory=lambda: list(DEFAULT_PREFERENCE_TYPES))
min_severity: str = NotificationSeverity.INFO.value
min_severity: str = DEFAULT_CHANNEL_LEVEL
class NotificationChannel(SQLModel, table=True):
@@ -45,6 +40,9 @@ class NotificationChannel(SQLModel, table=True):
last_test_at: datetime | None = Field(default=None)
last_test_ok: bool | None = Field(default=None)
last_test_message: str | None = Field(default=None)
last_sent_at: datetime | None = Field(default=None)
last_sent_ok: bool | None = Field(default=None)
last_sent_message: str | None = Field(default=None, max_length=500)
class NotificationChannelCreate(BaseModel):
@@ -78,6 +76,9 @@ class NotificationChannelRead(BaseModel):
last_test_at: datetime | None
last_test_ok: bool | None
last_test_message: str | None
last_sent_at: datetime | None = None
last_sent_ok: bool | None = None
last_sent_message: str | None = None
class NotificationChannelTestConfig(BaseModel):
+2 -6
View File
@@ -5,11 +5,10 @@ from functools import partial
from pydantic import BaseModel, field_validator
from sqlmodel import Field, SQLModel
from shared.enums.proxy import ProxyMode, ProxyProtocol
from shared.enums.proxy import ProxyProtocol
from shared.utils.datetime import utc_now
from shared.utils.validation import clean_name, clean_optional_name
PROXY_MODES = tuple(m.value for m in ProxyMode)
PROXY_SCHEMES = tuple(p.value for p in ProxyProtocol)
@@ -27,7 +26,6 @@ class Proxy(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(max_length=120)
description: str | None = Field(default=None, max_length=500)
mode: str = Field(default=ProxyMode.SINGLE.value)
is_active: bool = Field(default=True)
is_default: bool = Field(default=False)
endpoints_encrypted: str
@@ -52,7 +50,6 @@ class ProxyEndpointRead(BaseModel):
class ProxyCreate(BaseModel):
name: str
description: str | None = None
mode: str = ProxyMode.SINGLE.value
is_active: bool = True
is_default: bool = False
endpoints: list[ProxyEndpoint] = Field(min_length=1)
@@ -63,7 +60,6 @@ class ProxyCreate(BaseModel):
class ProxyUpdate(BaseModel):
name: str | None = None
description: str | None = None
mode: str | None = None
is_active: bool | None = None
is_default: bool | None = None
endpoints: list[ProxyEndpoint] | None = None
@@ -75,11 +71,11 @@ class ProxyRead(BaseModel):
id: uuid.UUID
name: str
description: str | None
mode: str
is_active: bool
is_default: bool
endpoints: list[ProxyEndpointRead]
endpoint_count: int
contexts: int = 0
created_at: datetime
updated_at: datetime
last_test_at: datetime | None
+12
View File
@@ -35,6 +35,18 @@ class UserCreate(SQLModel):
password: str
class UserAdminCreate(SQLModel):
email: str
username: str
password: str
is_superuser: bool = False
class UserAdminUpdate(SQLModel):
is_active: bool | None = None
is_superuser: bool | None = None
class UserRead(UserBase):
id: uuid.UUID
totp_enabled: bool = False
+2
View File
@@ -141,6 +141,7 @@ class TemplateFilter(BaseModel):
sets: list[str] = PydanticField(default_factory=list, max_length=40)
tags: list[str] = PydanticField(default_factory=list, max_length=40)
fired: bool = False
callback: bool = False
new_since: datetime | None = None
limit: int = PydanticField(default=50, ge=1, le=200)
offset: int = PydanticField(default=0, ge=0, le=1_000_000)
@@ -221,6 +222,7 @@ class TemplateLibraryStats(BaseModel):
sets: list[TemplateSetSpec] = PydanticField(default_factory=list)
tags: list[SelectionBreakdown] = PydanticField(default_factory=list)
fired: int = 0
callback: int = 0
new: int = 0
seen_at: datetime | None = None
last_synced_at: datetime | None = None
+13
View File
@@ -62,6 +62,9 @@ class APIKeyService:
is_enabled=api_key.is_enabled,
usage_counter=api_key.usage_counter,
last_used_at=api_key.last_used_at,
last_test_at=api_key.last_test_at,
last_test_ok=api_key.last_test_ok,
last_test_message=api_key.last_test_message,
created_at=api_key.created_at,
updated_at=api_key.updated_at,
meta=API_PROVIDER_META.get(api_key.provider, {}),
@@ -144,6 +147,9 @@ class APIKeyService:
if data.key_value is not None:
api_key.key_value = encrypt_secret(data.key_value)
api_key.last_test_at = None
api_key.last_test_ok = None
api_key.last_test_message = None
if data.is_enabled is not None:
api_key.is_enabled = data.is_enabled
if data.key_meta is not None:
@@ -155,6 +161,13 @@ class APIKeyService:
await self.session.refresh(api_key)
return self._to_read(api_key)
async def record_test(self, api_key: APIKey, ok: bool, message: str) -> None:
api_key.last_test_at = utc_now()
api_key.last_test_ok = ok
api_key.last_test_message = message[:500]
self.session.add(api_key)
await self.session.commit()
async def delete_key(self, key_id: str) -> None:
api_key = await self._get_key_or_404(key_id)
await self.session.delete(api_key)
+2 -5
View File
@@ -25,7 +25,6 @@ from shared.definitions.vulnerabilities import SUPPRESSED_STATES, TemplateOrigin
from shared.enums.scan import SCAN_OPEN_STATUSES, ScanScope, ScanStatus
from shared.logging import get_logger
from shared.models.instance_settings import InstanceSettings
from shared.models.proxy import Proxy
from shared.models.scan import Scan
from shared.models.scan_context import ScanContext
from shared.models.scan_engine import ScanEngine
@@ -37,7 +36,7 @@ from shared.services import locks
from shared.services.celery_dispatch import dispatch_scan_run
from shared.services.focused import focused_overrides
from shared.services.launch_plan import AdHocEngine
from shared.services.proxy_resolve import resolve_proxy_url
from shared.services.proxy_resolve import scan_proxy_url
from shared.services.scan_factory import build_scan_row
from shared.services.scan_resolve import merge_engine_context
from shared.services.scan_surface import split
@@ -218,9 +217,7 @@ def build_run(
)
if context is not None and context.project_id != target.project_id:
context = None
proxy_url = None
if context is not None and context.proxy_id is not None:
proxy_url = resolve_proxy_url(session.get(Proxy, context.proxy_id))
proxy_url = scan_proxy_url(session, context)
overrides = focused_overrides(FOLLOW_UP_STAGES)
overrides[_VULN_STAGE] = {
+60 -5
View File
@@ -7,8 +7,8 @@ import re
from urllib.parse import quote
import apprise
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import Engine, bindparam, select, update
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Session
from shared.enums.api_key import APIProvider
@@ -20,6 +20,7 @@ from shared.enums.notification_channel import (
from shared.http import get_sync_client
from shared.models.notification_channel import NotificationChannel
from shared.utils.crypto import try_decrypt
from shared.utils.datetime import utc_now
logger = logging.getLogger(__name__)
@@ -190,7 +191,8 @@ def _fan_out(
*,
explicit: bool = False,
attach: str | None = None,
) -> None:
) -> list[tuple]:
sent = []
for t in targets:
if not explicit and not wants(t["pref"], ntype, severity):
continue
@@ -199,6 +201,57 @@ def _fan_out(
)
if not ok:
logger.warning("dispatch to channel '%s' failed: %s", t["name"], msg)
sent.append((t["id"], ok, msg))
return sent
_CHANNELS = NotificationChannel.__table__
_RECORD_DELIVERY = (
update(_CHANNELS)
.where(_CHANNELS.c.id == bindparam("cid"))
.values(
last_sent_at=bindparam("at"),
last_sent_ok=bindparam("ok"),
last_sent_message=bindparam("msg"),
)
)
def _delivery_rows(sent: list[tuple]) -> list[dict]:
now = utc_now()
return [
{"cid": cid, "at": now, "ok": ok, "msg": (msg or "")[:500]}
for cid, ok, msg in sent
]
def _record_sync(session: Session, sent: list[tuple]) -> None:
"""Writes on its own connection, outside the caller's transaction."""
if not sent:
return
try:
bind = session.get_bind()
if isinstance(bind, Engine):
with bind.begin() as conn:
conn.execute(_RECORD_DELIVERY, _delivery_rows(sent))
else:
session.execute(_RECORD_DELIVERY, _delivery_rows(sent))
except Exception:
logger.warning("channel delivery status not recorded", exc_info=True)
async def _record_async(session: AsyncSession, sent: list[tuple]) -> None:
if not sent:
return
try:
bind = session.bind
if isinstance(bind, AsyncEngine):
async with bind.begin() as conn:
await conn.execute(_RECORD_DELIVERY, _delivery_rows(sent))
else:
await session.execute(_RECORD_DELIVERY, _delivery_rows(sent))
except Exception:
logger.warning("channel delivery status not recorded", exc_info=True)
def _channel_query(channel_ids=None):
@@ -225,7 +278,7 @@ def dispatch_sync(
targets = _channels_to_targets(list(rows))
_shared_bot_targets(targets, SyncAPIKeyService(session).get_key_for_provider)
if targets:
_fan_out(
sent = _fan_out(
targets,
ntype,
severity,
@@ -234,6 +287,7 @@ def dispatch_sync(
explicit=bool(channel_ids),
attach=attach,
)
_record_sync(session, sent)
async def dispatch_async(
@@ -253,7 +307,7 @@ async def dispatch_async(
if targets:
token = await APIKeyService(session).get_key_for_provider(APIProvider.TELEGRAM)
_shared_bot_targets(targets, lambda _provider: token)
await asyncio.to_thread(
sent = await asyncio.to_thread(
_fan_out,
targets,
ntype,
@@ -263,3 +317,4 @@ async def dispatch_async(
explicit=bool(channel_ids),
attach=attach,
)
await _record_async(session, sent)
+28 -3
View File
@@ -1,8 +1,12 @@
import json
import secrets
from urllib.parse import urlsplit
from sqlalchemy import select
from shared.models.proxy import Proxy, ProxyEndpoint
from shared.utils.crypto import decrypt_stored
from shared.utils.net import host_port
def load_proxy_endpoints(proxy: Proxy) -> list[ProxyEndpoint]:
@@ -35,16 +39,37 @@ def proxy_env(proxy_url: str | None) -> dict[str, str] | None:
def build_proxy_url(ep: ProxyEndpoint) -> str:
authority = host_port(ep.host, ep.port)
if ep.username:
cred = f"{ep.username}:{ep.password}@" if ep.password else f"{ep.username}@"
return f"{ep.scheme}://{cred}{ep.host}:{ep.port}"
return f"{ep.scheme}://{ep.host}:{ep.port}"
return f"{ep.scheme}://{cred}{authority}"
return f"{ep.scheme}://{authority}"
def resolve_proxy_url(proxy: Proxy | None) -> str | None:
"""One endpoint per scan, drawn from the pool when the proxy holds several."""
if proxy is None or not proxy.is_active:
return None
endpoints = load_proxy_endpoints(proxy)
if not endpoints:
return None
return build_proxy_url(endpoints[0])
return build_proxy_url(secrets.choice(endpoints))
def default_proxy(session) -> Proxy | None:
return (
session.execute(
select(Proxy).where(Proxy.is_default.is_(True), Proxy.is_active.is_(True))
)
.scalars()
.first()
)
def scan_proxy_url(session, context) -> str | None:
"""The context's proxy, or the default proxy for a scan without a context."""
if context is None:
return resolve_proxy_url(default_proxy(session))
if context.proxy_id is None:
return None
return resolve_proxy_url(session.get(Proxy, context.proxy_id))
+2 -5
View File
@@ -3,7 +3,7 @@ import uuid
from shared.enums.scan import ScanScope, ScanStatus
from shared.models.scan import Scan
from shared.services import target_seeds
from shared.services.proxy_resolve import resolve_proxy_url
from shared.services.proxy_resolve import scan_proxy_url
from shared.services.scan_resolve import (
ResolvedScanConfig,
_mask_auth,
@@ -75,7 +75,6 @@ def build_scan_for_target_sync(
intensity: str | None = None,
) -> Scan:
"""Resolve engine/context/target on a sync Session and flush a PENDING Scan."""
from shared.models.proxy import Proxy # noqa: PLC0415
from shared.models.scan_context import ScanContext # noqa: PLC0415
from shared.models.scan_engine import ScanEngine # noqa: PLC0415
from shared.models.target import Target # noqa: PLC0415
@@ -97,9 +96,7 @@ def build_scan_for_target_sync(
msg = "target not found"
raise ScanFactoryError(msg)
proxy_url = None
if context is not None and context.proxy_id is not None:
proxy_url = resolve_proxy_url(session.get(Proxy, context.proxy_id))
proxy_url = scan_proxy_url(session, context)
resolved = merge_engine_context(
engine,
+2 -6
View File
@@ -70,7 +70,7 @@ from shared.services.focused import focused_overrides
from shared.services.launch_plan import AdHocEngine
from shared.services.notification_sync import SyncNotificationPublisher
from shared.services.notifier import dispatch_sync
from shared.services.proxy_resolve import resolve_proxy_url
from shared.services.proxy_resolve import scan_proxy_url
from shared.services.proxy_sync import census_in_flight
from shared.services.scan_factory import build_scan_row
from shared.services.scan_resolve import merge_engine_context
@@ -424,11 +424,7 @@ def build_probe_scan(
if engine is None:
engine = AdHocEngine(name=label)
context = context_of(session, watch)
proxy_url = None
if context is not None and context.proxy_id is not None:
from shared.models.proxy import Proxy # noqa: PLC0415
proxy_url = resolve_proxy_url(session.get(Proxy, context.proxy_id))
proxy_url = scan_proxy_url(session, context)
resolved = merge_engine_context(
engine,
context,
+302
View File
@@ -0,0 +1,302 @@
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from fastapi import HTTPException
from app.api.v1.api_keys import list_providers
from app.api.v1.users import create_user, delete_user, update_user
from app.services.instance_settings import InstanceSettingsService, _check_window
from app.services.proxy import ProxyService, _summarize
from shared.definitions.retention import KEEP_FOREVER, SCAN_RETENTION_DAYS
from shared.enums.api_key import APIProvider
from shared.models.api_key import APIKey, APIKeyUpdate
from shared.models.instance_settings import InstanceSettingsUpdate
from shared.models.notification_channel import NotificationChannel
from shared.models.project import Project
from shared.models.proxy import Proxy, ProxyEndpoint, ProxyTestResult
from shared.models.scan_context import ScanContext
from shared.models.user import User, UserAdminCreate, UserAdminUpdate
from shared.services.api_key.async_api_key import APIKeyService
from shared.services.notifier import _RECORD_DELIVERY, _delivery_rows
from shared.services.proxy_resolve import resolve_proxy_url
pytestmark = pytest.mark.api
STRONG = "Vq7#mT9!pLx2@wRz"
@pytest.fixture
def flush_only(session, monkeypatch):
monkeypatch.setattr(session, "commit", session.flush)
return session
async def _admin(estate) -> User:
return await estate.session.get(User, estate.user_id)
async def _member(session, *, superuser: bool = False) -> User:
tag = uuid.uuid4().hex[:8]
user = User(
username=f"ops{tag}",
email=f"ops{tag}@test.local",
hashed_password="x",
is_superuser=superuser,
)
session.add(user)
await session.flush()
return user
def _proxy(name: str, endpoints: list[dict], *, default: bool, created_by) -> Proxy:
return Proxy(
name=name,
is_active=True,
is_default=default,
endpoints_encrypted=json.dumps(endpoints),
endpoint_count=len(endpoints),
created_by=created_by,
)
# ---------- users ----------
async def test_an_admin_creates_an_account_with_a_role(estate, flush_only):
tag = uuid.uuid4().hex[:8]
user = await create_user(
UserAdminCreate(
email=f"New{tag}@Test.local",
username=f"new{tag}",
password=STRONG,
is_superuser=True,
),
session=flush_only,
current_user=await _admin(estate),
)
assert user.is_superuser
assert user.email == f"new{tag}@test.local"
async def test_a_taken_username_is_refused(estate, flush_only):
taken = await _member(flush_only)
with pytest.raises(HTTPException) as err:
await create_user(
UserAdminCreate(
email="other@test.local", username=taken.username, password=STRONG
),
session=flush_only,
current_user=await _admin(estate),
)
assert err.value.status_code == 409
async def test_the_signed_in_account_cannot_disable_itself(estate, flush_only):
admin = await _admin(estate)
with pytest.raises(HTTPException) as err:
await update_user(
admin.id,
UserAdminUpdate(is_active=False),
session=flush_only,
current_user=admin,
)
assert err.value.status_code == 400
async def test_an_admin_disables_and_demotes_another_account(estate, flush_only):
other = await _member(flush_only, superuser=True)
updated = await update_user(
other.id,
UserAdminUpdate(is_active=False, is_superuser=False),
session=flush_only,
current_user=await _admin(estate),
)
assert not updated.is_active
assert not updated.is_superuser
async def test_an_account_that_owns_records_is_not_deleted(estate, flush_only):
owner = await _member(flush_only)
flush_only.add(
Project(
id=uuid.uuid4(),
name="Owned",
slug=f"owned-{owner.id.hex[:8]}",
created_by=owner.id,
)
)
await flush_only.flush()
with pytest.raises(HTTPException) as err:
await delete_user(
str(owner.id), session=flush_only, current_user=await _admin(estate)
)
assert err.value.status_code == 409
assert "Disable the account" in err.value.detail
# ---------- api keys ----------
async def test_a_key_test_is_stored_and_a_new_key_clears_it(estate, flush_only):
key = APIKey(provider=APIProvider.VIEWDNS, key_value="first")
flush_only.add(key)
await flush_only.flush()
service = APIKeyService(flush_only)
await service.record_test(key, True, "API Key is valid.")
read = service._to_read(key)
assert read.last_test_ok is True
assert read.last_test_message == "API Key is valid."
read = await service.update_key(str(key.id), APIKeyUpdate(key_value="second"))
assert read.last_test_at is None
assert read.last_test_ok is None
async def test_providers_state_which_keys_can_be_tested(estate, flush_only):
providers = {
p.provider: p
for p in await list_providers(
_current_user=await _admin(estate), service=APIKeyService(flush_only)
)
}
assert providers[APIProvider.VIEWDNS].testable
assert providers[APIProvider.TELEGRAM].testable
assert not providers[APIProvider.CHAOS].testable
# ---------- proxies ----------
async def test_a_scan_without_a_context_uses_the_default_proxy(estate, flush_only):
fallback = _proxy(
"fallback",
[{"scheme": "http", "host": "10.0.0.9", "port": 3128}],
default=True,
created_by=estate.user_id,
)
chosen = _proxy(
"chosen",
[{"scheme": "socks5", "host": "10.0.0.7", "port": 1080}],
default=False,
created_by=estate.user_id,
)
flush_only.add_all([fallback, chosen])
await flush_only.flush()
service = ProxyService(flush_only)
assert await service.scan_proxy_url(None) == "http://10.0.0.9:3128"
assert (
await service.scan_proxy_url(SimpleNamespace(proxy_id=chosen.id))
== "socks5://10.0.0.7:1080"
)
assert await service.scan_proxy_url(SimpleNamespace(proxy_id=None)) is None
def test_a_pool_hands_each_scan_one_of_its_endpoints():
pool = SimpleNamespace(
name="pool",
is_active=True,
endpoints_encrypted=json.dumps(
[
{"scheme": "http", "host": "10.0.0.1", "port": 8080},
{"scheme": "http", "host": "10.0.0.2", "port": 8080},
{"scheme": "http", "host": "2001:db8::5", "port": 8080},
]
),
)
urls = {resolve_proxy_url(pool) for _ in range(60)}
assert urls <= {
"http://10.0.0.1:8080",
"http://10.0.0.2:8080",
"http://[2001:db8::5]:8080",
}
assert len(urls) > 1
def test_a_pool_test_names_the_endpoint_that_failed():
endpoints = [
ProxyEndpoint(scheme="http", host="10.0.0.1", port=8080),
ProxyEndpoint(scheme="http", host="10.0.0.2", port=8080),
]
result = _summarize(
endpoints,
[
ProxyTestResult(success=True, message="Proxy reachable.", latency_ms=90),
ProxyTestResult(success=False, message="Connection failed: refused"),
],
)
assert not result.success
assert result.message.startswith("1 of 2 endpoints carried the request.")
assert "10.0.0.2:8080" in result.message
async def test_a_deleted_proxy_leaves_its_contexts_direct(estate, flush_only):
doomed = _proxy(
"doomed",
[{"scheme": "http", "host": "10.0.0.3", "port": 8080}],
default=False,
created_by=estate.user_id,
)
flush_only.add(doomed)
await flush_only.flush()
context = ScanContext(
project_id=estate.project_id,
created_by=estate.user_id,
name="through doomed",
proxy_id=doomed.id,
)
flush_only.add(context)
await flush_only.flush()
listed = {p.id: p for p in await ProxyService(flush_only).list()}
assert listed[doomed.id].contexts == 1
await ProxyService(flush_only).delete(doomed.id)
await flush_only.refresh(context)
assert context.proxy_id is None
# ---------- notifications ----------
async def test_a_delivery_is_recorded_on_the_channel(estate, session):
channel = NotificationChannel(
name="soc", provider="slack", config_encrypted="", created_by=estate.user_id
)
session.add(channel)
await session.flush()
await session.execute(
_RECORD_DELIVERY, _delivery_rows([(channel.id, False, "HTTP 500.")])
)
row = (
await session.execute(
sa.select(
NotificationChannel.last_sent_ok, NotificationChannel.last_sent_message
).where(NotificationChannel.id == channel.id)
)
).one()
assert row == (False, "HTTP 500.")
# ---------- general ----------
def test_a_retention_window_must_be_an_offered_one():
_check_window("Scan history", KEEP_FOREVER, SCAN_RETENTION_DAYS)
with pytest.raises(HTTPException) as err:
_check_window("Scan history", 45, SCAN_RETENTION_DAYS)
assert err.value.status_code == 400
async def test_a_blank_instance_name_is_refused(estate, flush_only):
with pytest.raises(HTTPException) as err:
await InstanceSettingsService(flush_only).update(
InstanceSettingsUpdate(instance_name=" ")
)
assert err.value.status_code == 400
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import re
from pathlib import Path
import pytest
from shared.definitions.notification_events import (
CHANNEL_EVENTS,
CHANNEL_LEVELS,
DEFAULT_CHANNEL_LEVEL,
)
from shared.definitions.retention import (
KEEP_FOREVER,
SCAN_RETENTION_DAYS,
SCREENSHOT_RETENTION_DAYS,
)
from shared.enums.notification import NotificationType
from shared.models.notification_channel import DEFAULT_PREFERENCE_TYPES
pytestmark = pytest.mark.grammar
EVENTS = Path("/app/frontend-config/notification-events.ts")
RETENTION = Path("/app/frontend-config/retention.ts")
def _events_mirror() -> list[tuple[str, str, str, str | None]]:
found = re.findall(
r"type: '([^']+)',\s*label: '([^']+)',\s*hint: '([^']+)',\s*"
r"capability: (?:Capability\.(\w+)|null)",
EVENTS.read_text(),
)
return [(t, label, hint, cap.lower() or None) for t, label, hint, cap in found]
def test_channel_events_mirror():
assert _events_mirror() == [
(e.type, e.label, e.hint, e.capability) for e in CHANNEL_EVENTS
]
def test_channel_levels_mirror():
text = EVENTS.read_text()
found = re.findall(r"\{ value: '([^']+)', label: '([^']+)' \}", text)
assert found == [(level.value, level.label) for level in CHANNEL_LEVELS]
assert f"DEFAULT_CHANNEL_LEVEL = '{DEFAULT_CHANNEL_LEVEL}'" in text
def test_every_offered_event_is_one_the_product_sends():
offered = {e.type for e in CHANNEL_EVENTS}
assert NotificationType.SECURITY.value not in offered
assert NotificationType.RESOURCE.value not in offered
assert set(DEFAULT_PREFERENCE_TYPES) == offered
def _days(name: str) -> list[int]:
block = re.search(
rf"export const {name}: RetentionOption\[\] = \[(.*?)\];",
RETENTION.read_text(),
re.S,
)
assert block, f"{name} is missing from the mirror"
values = re.findall(r"value: (?:'(\d+)'|KEEP_FOREVER)", block.group(1))
return [int(v) if v else KEEP_FOREVER for v in values]
def test_retention_mirror():
assert f"KEEP_FOREVER = '{KEEP_FOREVER}'" in RETENTION.read_text()
assert _days("SCAN_RETENTION") == list(SCAN_RETENTION_DAYS)
assert _days("SCREENSHOT_RETENTION") == list(SCREENSHOT_RETENTION_DAYS)