mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-19 23:01:29 +02:00
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:
@@ -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")
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProxyMode(Enum):
|
||||
SINGLE = "single"
|
||||
ROTATING = "rotating"
|
||||
|
||||
|
||||
class ProxyProtocol(Enum):
|
||||
HTTP = "http"
|
||||
HTTPS = "https"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user