From a548afffa2e43ccc0bc7bbe7c2bb09dd424711b2 Mon Sep 17 00:00:00 2001 From: Yogesh Ojha Date: Sun, 27 Sep 2026 10:01:19 +0530 Subject: [PATCH] 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 --- .../versions/2026_09_27_settings_status.py | 52 + api/app/api/v1/api_keys.py | 12 +- api/app/api/v1/users.py | 92 +- api/app/services/instance_settings.py | 30 +- api/app/services/notification_channel.py | 3 + api/app/services/proxy.py | 107 +- api/app/services/scan.py | 16 +- api/app/services/vuln_template.py | 6 + frontend/src/lib/api/users.ts | 25 + .../arsenal/callback-server-sheet.svelte | 334 ++++++ .../components/arsenal/nuclei-library.svelte | 81 +- .../bounty-hub/settings-sheet.svelte | 251 +++++ .../components/bounty-hub/watching-tab.svelte | 10 +- .../components/contexts/proxy-section.svelte | 2 +- .../onboarding/step-notifications.svelte | 5 +- .../components/onboarding/step-proxy.svelte | 1 - .../scans/launch/launch-context-form.svelte | 16 +- .../settings/api-key-provider-card.svelte | 413 -------- .../components/settings/api-keys-panel.svelte | 823 ++++++++------- .../settings/bounty-hub-panel.svelte | 276 ----- .../settings/certificate-stream-card.svelte | 99 -- .../src/lib/components/settings/columns.ts | 40 + .../settings/instance-settings-panel.svelte | 564 ++++------- .../notification-channels-panel.svelte | 787 +++++++-------- .../lib/components/settings/oast-panel.svelte | 401 -------- .../components/settings/proxies-panel.svelte | 952 +++++++----------- .../components/settings/setting-row.svelte | 35 + .../src/lib/components/settings/status.ts | 15 + .../settings/timezone-picker.svelte | 67 ++ .../components/settings/users-panel.svelte | 320 ++++++ frontend/src/lib/config/command-palette.ts | 19 +- .../src/lib/config/notification-events.ts | 73 ++ frontend/src/lib/config/oast.ts | 4 +- frontend/src/lib/config/retention.ts | 31 + frontend/src/lib/config/routes.ts | 18 +- frontend/src/lib/config/users.ts | 8 + frontend/src/lib/constants.ts | 2 + .../src/lib/stores/capabilities.svelte.ts | 10 + frontend/src/lib/stores/proxies.svelte.ts | 3 + .../src/lib/stores/settings-actions.svelte.ts | 15 + frontend/src/lib/types/api-key.ts | 4 + .../src/lib/types/notification-channel.ts | 42 +- frontend/src/lib/types/proxy.ts | 10 +- frontend/src/lib/types/vuln-template.ts | 3 + frontend/src/lib/utilities/dates.test.ts | 18 +- frontend/src/lib/utilities/dates.ts | 14 + frontend/src/lib/utilities/page-title.ts | 5 + frontend/src/routes/(app)/agents/+page.svelte | 3 +- .../src/routes/(app)/arsenal/+page.svelte | 3 +- .../src/routes/(app)/automation/+page.svelte | 3 +- .../(app)/automation/contexts/+page.svelte | 3 +- .../automation/contexts/[id]/+page.svelte | 16 +- .../(app)/automation/engines/+page.svelte | 3 +- .../(app)/automation/schedules/+page.svelte | 3 +- .../src/routes/(app)/bounty-hub/+page.svelte | 48 +- .../(app)/bounty-hub/[platform]/+page.svelte | 5 +- .../src/routes/(app)/connectors/+page.svelte | 7 +- .../src/routes/(app)/dashboard/+page.svelte | 3 +- .../src/routes/(app)/exposures/+page.svelte | 3 +- frontend/src/routes/(app)/notes/+page.svelte | 3 +- .../src/routes/(app)/profile/+page.svelte | 8 +- .../routes/(app)/remote-control/+page.svelte | 5 +- .../src/routes/(app)/reports/+page.svelte | 3 +- .../(app)/reports/templates/[id]/+page.svelte | 3 +- frontend/src/routes/(app)/scans/+page.svelte | 3 +- .../src/routes/(app)/settings/+layout.svelte | 69 +- .../src/routes/(app)/settings/+page.svelte | 3 +- .../src/routes/(app)/settings/ai/+page.svelte | 602 ++++++----- .../(app)/settings/api-keys/+page.svelte | 3 +- .../(app)/settings/bounty-hub/+page.svelte | 18 +- .../(app)/settings/general/+page.svelte | 3 +- .../(app)/settings/notifications/+page.svelte | 3 +- .../routes/(app)/settings/oast/+page.svelte | 10 +- .../(app)/settings/proxies/+page.svelte | 3 +- .../routes/(app)/settings/users/+page.svelte | 9 + .../(app)/surface/[dimension]/+page.svelte | 3 +- .../(app)/surface/correlation/+page.svelte | 3 +- .../src/routes/(app)/surface/cve/+page.svelte | 3 +- .../(app)/surface/cve/[cve]/+page.svelte | 3 +- .../src/routes/(app)/targets/+page.svelte | 3 +- .../routes/(app)/targets/[id]/+page.svelte | 3 +- .../src/routes/(app)/whats-new/+page.svelte | 3 +- shared/definitions/ai.py | 22 +- shared/definitions/notification_events.py | 74 ++ shared/definitions/oast.py | 2 +- shared/definitions/retention.py | 4 + shared/enums/proxy.py | 5 - shared/models/api_key.py | 7 + shared/models/notification_channel.py | 23 +- shared/models/proxy.py | 8 +- shared/models/user.py | 12 + shared/models/vuln_template.py | 2 + shared/services/api_key/async_api_key.py | 13 + shared/services/new_checks.py | 7 +- shared/services/notifier.py | 65 +- shared/services/proxy_resolve.py | 31 +- shared/services/scan_factory.py | 7 +- shared/services/watch_sync.py | 8 +- tests/api/test_settings_admin.py | 302 ++++++ tests/grammar/test_settings_mirrors.py | 70 ++ 100 files changed, 4203 insertions(+), 3442 deletions(-) create mode 100644 alembic/versions/2026_09_27_settings_status.py create mode 100644 frontend/src/lib/components/arsenal/callback-server-sheet.svelte create mode 100644 frontend/src/lib/components/bounty-hub/settings-sheet.svelte delete mode 100644 frontend/src/lib/components/settings/api-key-provider-card.svelte delete mode 100644 frontend/src/lib/components/settings/bounty-hub-panel.svelte delete mode 100644 frontend/src/lib/components/settings/certificate-stream-card.svelte create mode 100644 frontend/src/lib/components/settings/columns.ts delete mode 100644 frontend/src/lib/components/settings/oast-panel.svelte create mode 100644 frontend/src/lib/components/settings/setting-row.svelte create mode 100644 frontend/src/lib/components/settings/status.ts create mode 100644 frontend/src/lib/components/settings/timezone-picker.svelte create mode 100644 frontend/src/lib/components/settings/users-panel.svelte create mode 100644 frontend/src/lib/config/notification-events.ts create mode 100644 frontend/src/lib/config/retention.ts create mode 100644 frontend/src/lib/config/users.ts create mode 100644 frontend/src/lib/stores/settings-actions.svelte.ts create mode 100644 frontend/src/lib/utilities/page-title.ts create mode 100644 frontend/src/routes/(app)/settings/users/+page.svelte create mode 100644 shared/definitions/notification_events.py create mode 100644 tests/api/test_settings_admin.py create mode 100644 tests/grammar/test_settings_mirrors.py diff --git a/alembic/versions/2026_09_27_settings_status.py b/alembic/versions/2026_09_27_settings_status.py new file mode 100644 index 000000000..8d78c23b7 --- /dev/null +++ b/alembic/versions/2026_09_27_settings_status.py @@ -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") diff --git a/api/app/api/v1/api_keys.py b/api/app/api/v1/api_keys.py index defece03e..89976559d 100644 --- a/api/app/api/v1/api_keys.py +++ b/api/app/api/v1/api_keys.py @@ -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} diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py index 9b1014ff6..509ca1f7f 100644 --- a/api/app/api/v1/users.py +++ b/api/app/api/v1/users.py @@ -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"} diff --git a/api/app/services/instance_settings.py b/api/app/services/instance_settings.py index 62e1a509e..f3cb29fa8 100644 --- a/api/app/services/instance_settings.py +++ b/api/app/services/instance_settings.py @@ -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 diff --git a/api/app/services/notification_channel.py b/api/app/services/notification_channel.py index 5bb7b8970..88e405b93 100644 --- a/api/app/services/notification_channel.py +++ b/api/app/services/notification_channel.py @@ -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, ) diff --git a/api/app/services/proxy.py b/api/app/services/proxy.py index f2c7e4082..c6a740f28 100644 --- a/api/app/services/proxy.py +++ b/api/app/services/proxy.py @@ -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] diff --git a/api/app/services/scan.py b/api/app/services/scan.py index 6e1d14940..bab406417 100644 --- a/api/app/services/scan.py +++ b/api/app/services/scan.py @@ -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), diff --git a/api/app/services/vuln_template.py b/api/app/services/vuln_template.py index 32b4869e5..ea9bf02b5 100644 --- a/api/app/services/vuln_template.py +++ b/api/app/services/vuln_template.py @@ -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, diff --git a/frontend/src/lib/api/users.ts b/frontend/src/lib/api/users.ts index 5c0df0887..f1e7d8117 100644 --- a/frontend/src/lib/api/users.ts +++ b/frontend/src/lib/api/users.ts @@ -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>(); @@ -21,6 +34,18 @@ export const usersApi = { return api.get('/users?limit=500'); }, + create(data: UserAccountCreate): Promise { + return api.post('/users', data); + }, + + update(userId: string, data: UserAccountUpdate): Promise { + return api.patch(`/users/${userId}`, data); + }, + + remove(userId: string): Promise { + return api.delete(`/users/${userId}`); + }, + getSummary(userId: string): Promise { const cached = cache.get(userId); if (cached) return cached; diff --git a/frontend/src/lib/components/arsenal/callback-server-sheet.svelte b/frontend/src/lib/components/arsenal/callback-server-sheet.svelte new file mode 100644 index 000000000..3c8b07476 --- /dev/null +++ b/frontend/src/lib/components/arsenal/callback-server-sheet.svelte @@ -0,0 +1,334 @@ + + + + + + {CALLBACK_SERVER} + {#if settings} + + {settings.checks.toLocaleString()} checks need a callback + + {/if} + + + {#if settings} + +
+
+

Mode

+ v && (mode = v as OastMode)} + disabled={!isAdmin || saving} + class="w-fit" + aria-label="Mode" + > + {#each OAST_MODES as option (option)} + + {OAST_MODE_LABELS[option]} + + {/each} + + {#if mode !== OastMode.PUBLIC} +

{OAST_MODE_HELP[mode]}

+ {/if} + {#if settings.reason && settings.mode !== OastMode.OFF && !dirty} +

{settings.reason}

+ {/if} +
+ + {#if mode === OastMode.SELF_HOSTED} +
+

Server

+ (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} +

{serverError}

+ {/if} +
+ Token + {settings.token_set ? 'Set' : 'Not set'} + + API keys + +
+
+ {/if} + + {#if mode === OastMode.PUBLIC} +
+

Public server

+
+ + {PUBLIC_ACK} +
+ +

+ {settings.public_servers.join(' · ')} +

+
+ {/if} + + {#if mode !== OastMode.OFF} +
+

Wait after each out-of-band batch

+ v !== undefined && (wait = Number(v))} + disabled={!isAdmin || saving} + > + + {waitLabel(wait)} + + + {#each WAIT_STEPS as step (step)} + + {waitLabel(step)} + + {/each} + + +
+ {/if} + + {#if result && !dirty} +
+ {#if result.ok} + + {:else} + + {/if} + {result.detail} +
+ {/if} + + {#if settings.interactions > 0} +
+ {settings.interactions.toLocaleString()} + {settings.interactions === 1 ? 'finding' : 'findings'} with a callback + {#if settings.last_interaction_at} + · most recent {relativeTime(settings.last_interaction_at)} + {/if} +
+ {/if} +
+
+ +
+ + Save + + + Test server + + + +
+ {#if blocked && dirty} +

{blocked}

+ {:else if !isAdmin} +

+ Editable by administrators. +

+ {/if} + {/if} +
+
+ + (resetOpen = v)} + onConfirm={reset} +/> diff --git a/frontend/src/lib/components/arsenal/nuclei-library.svelte b/frontend/src/lib/components/arsenal/nuclei-library.svelte index 06035ceb1..d493fbffc 100644 --- a/frontend/src/lib/components/arsenal/nuclei-library.svelte +++ b/frontend/src/lib/components/arsenal/nuclei-library.svelte @@ -1,6 +1,8 @@ + + + + + {routeLabels['bounty-hub']} settings + + + {#if loadError && !settings} +
+

{loadError}

+ +
+ {:else if !settings} +
+ + + +
+ {:else} + +
+
+

Platforms

+ {#each connectable as platform (platform.platform)} +
+ {platform.label} + {#if platform.configured} + + {platform.programs.toLocaleString()} programs + {#if platform.private_programs > 0} + · {platform.private_programs.toLocaleString()} private + {/if} + + {:else} + + Add key + + {/if} +
+ {/each} +
+ +
+

Sync

+
+
+ Platform APIs + + {schedule(settings.sync_interval, settings.last_synced_at, settings.next_sync_at)} + +
+ v && save({ sync_interval: v }, 'Platform sync')} + disabled={!isAdmin || saving} + > + + {SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval} + + + {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} + {label} + {/each} + + +
+
+
+ Public feed + + {schedule( + settings.feed_interval, + settings.feed_synced_at, + settings.feed_next_sync_at + )} + + + {settings.feed_source} · {settings.feed_license} + +
+ v && save({ feed_interval: v }, 'Feed sync')} + disabled={!isAdmin || saving} + > + + {SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval} + + + {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} + {label} + {/each} + + +
+
+ +
+

Alerts

+ + {#if settings.notify} +
+ {#each specs as spec (spec.kind)} + + {/each} +
+ {/if} +

+ {settings.events_recorded.toLocaleString()} changes recorded · + + Notification channels + +

+
+
+
+ {#if !isAdmin} +

+ Editable by administrators. +

+ {/if} + {/if} +
+
diff --git a/frontend/src/lib/components/bounty-hub/watching-tab.svelte b/frontend/src/lib/components/bounty-hub/watching-tab.svelte index 1d8fc62bd..8c0c48706 100644 --- a/frontend/src/lib/components/bounty-hub/watching-tab.svelte +++ b/frontend/src/lib/components/bounty-hub/watching-tab.svelte @@ -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} Certificate stream not running. Check that the ct-stream service is up. {/if} - Details + {#if stream.last_error} + + {stream.last_error} + {#if stream.last_error_at} + · {relativeTime(stream.last_error_at)} + {/if} + + {/if} {/if} diff --git a/frontend/src/lib/components/contexts/proxy-section.svelte b/frontend/src/lib/components/contexts/proxy-section.svelte index b79b62206..001aa5bcb 100644 --- a/frontend/src/lib/components/contexts/proxy-section.svelte +++ b/frontend/src/lib/components/contexts/proxy-section.svelte @@ -88,7 +88,7 @@ {/if} Manage proxies in Settings diff --git a/frontend/src/lib/components/onboarding/step-notifications.svelte b/frontend/src/lib/components/onboarding/step-notifications.svelte index 4dcc9334f..2f0510dd8 100644 --- a/frontend/src/lib/components/onboarding/step-notifications.svelte +++ b/frontend/src/lib/components/onboarding/step-notifications.svelte @@ -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 { diff --git a/frontend/src/lib/components/onboarding/step-proxy.svelte b/frontend/src/lib/components/onboarding/step-proxy.svelte index 9dd932ffd..2ce12472d 100644 --- a/frontend/src/lib/components/onboarding/step-proxy.svelte +++ b/frontend/src/lib/components/onboarding/step-proxy.svelte @@ -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 diff --git a/frontend/src/lib/components/scans/launch/launch-context-form.svelte b/frontend/src/lib/components/scans/launch/launch-context-form.svelte index 974e7527e..e9295ad7a 100644 --- a/frontend/src/lib/components/scans/launch/launch-context-form.svelte +++ b/frontend/src/lib/components/scans/launch/launch-context-form.svelte @@ -1,5 +1,5 @@ - - - -
-
-
- -
-
- -

- {provider.description} -

-
-
- - {#if apiKey} - onToggle(provider.provider, checked)} - /> - {/if} -
- - {#if apiKey} - - -
- {#if provider.requires_username && apiKey.key_meta?.username} -
- - - {apiKey.key_meta.username} - -
- {/if} -
- - - {apiKey.key_value_masked} - - - { - if (open) reveal(apiKey.id); - else resetReveal(); - }} - > - - {#snippet child({ props })} - - {/snippet} - - -
-
- - {#if revealedKeyValue} - - {/if} -
- {#if revealLoading} -
- -
- {:else if revealedKeyValue} - - {revealedKeyValue} - - {:else if revealError} -
-

Key not loaded

- -
- {/if} -
-
-
- - {#if apiKey.is_enabled} - Active - {:else} - - Disabled - - {/if} -
- -
- - - {#snippet child({ props })} - - - {apiKey.usage_counter} calls - - {/snippet} - - API calls - - - {#if apiKey.last_used_at} - Last used {formatDate(apiKey.last_used_at)} - {:else} - Unused - {/if} -
- -
- { - if (open) { - editOpen = true; - editKeyValue = ''; - editUsername = (apiKey.key_meta?.username as string) ?? ''; - } else { - editOpen = false; - resetEdit(); - } - }} - > - - {#snippet child({ props })} - - {/snippet} - - -
-
-

Update API key

-
- {#if provider.requires_username} - - {#snippet children({ id })} - - {/snippet} - - {/if} - - {#snippet children({ id })} -
- - -
- {/snippet} -
- - Update key - -
-
-
- - - - {#snippet child({ props })} - - {/snippet} - - Test key - - -
- - - - {#snippet child({ props })} - - {/snippet} - - Remove API key - -
-
- {:else} -
- -
- {/if} - - diff --git a/frontend/src/lib/components/settings/api-keys-panel.svelte b/frontend/src/lib/components/settings/api-keys-panel.svelte index 16079fff2..9a16a6c4a 100644 --- a/frontend/src/lib/components/settings/api-keys-panel.svelte +++ b/frontend/src/lib/components/settings/api-keys-panel.svelte @@ -1,71 +1,96 @@ -
-
-
-

API keys

-

- Keys for external intelligence providers. Shared by every project. -

-
- - {configuredKeys.size} / {providers.length} configured - -
- - - - {#if isLoading} -
- {#each Array(6) as _, i (i)} - - -
- -
- - - -
+{#if !isAdmin} + +{:else if loading} + + + + + + +{:else if loadError} + + + +{:else} + +
+
+
Provider
+
Key
+
Status
+
Last tested
+
+ {setCount} of {providers.length} set +
+
+ {#each groups as group (group.group)} +
{group.label}
+ {#each group.items as provider (provider.provider)} + {@const key = keys.get(provider.provider)} + {@const check = stateOf(provider)} +
+
+ + {provider.name} + + + + + {provider.description}
- - +
+ {#if key} +
+ + + {key.key_value_masked} + + {#if key.key_meta?.username} + + {key.key_meta.username} + + {/if} + + { + if (open) void reveal(key); + else revealed = null; + }} + > + + {#snippet child({ props })} + + {/snippet} + + + {#if revealed} +
+ + {revealed} + + +
+ {:else if revealFailed} +

Key not loaded.

+ {:else} +
+ {/if} +
+
+
+ {:else} + Not set + {/if} +
+
+ {#if key} + + {#snippet child(props)} + + {#if testing === provider.provider} + + Testing + {:else} + + + {STATUS_LABEL[check]} + + {/if} + + {/snippet} + + {:else} + + + Not set + + {/if} +
+
+ {key?.last_test_at ? relativeTime(key.last_test_at) : 'Never'} +
+
+ {#if key} + + + {#snippet child({ props })} + + {/snippet} + + + {#if provider.testable} + test(provider)} + > + Test + + {/if} + openDialog(provider)}> + Replace key + + setEnabled(provider, !key.is_enabled)}> + {key.is_enabled ? 'Disable' : 'Enable'} + + + (removing = provider)}> + Remove + + + + {:else} + + {/if} +
+
+ {/each} {/each}
- {:else if loadError} - - - - {:else} -
- {#each providers as provider (provider.provider)} - - {/each} -
- {/if} -
+
+{/if} - + - Add API key - - {#if addDialogProvider} - {@const meta = providers.find((p) => p.provider === addDialogProvider)} - {#if meta} - - Get a {meta.name} key - - {/if} - {/if} - + + {editing ? `${replacing ? 'Replace' : 'Add'} ${editing.name} key` : 'API key'} + + {#if editing} + + + {editing.name} documentation + + + {/if} -
{ e.preventDefault(); - if (addCanSave) handleAdd(); + void saveKey(); }} > -
- {#if addRequiresUsername} - - {#snippet children({ id })} - - {/snippet} - - {/if} - + {#if editing?.requires_username} + {#snippet children({ id })} -
- - -
+ {/snippet}
-
- + {/if} + + {#snippet children({ id })} +
+ + +
+ {/snippet} +
- - Add key + + {replacing ? 'Replace key' : 'Add key'}
- 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} /> - - - - - Test API key - - One call is made against the - {providers.find((p) => p.provider === testDialogProvider)?.name ?? 'provider'} quota. - - - - - { - if (testDialogKeyId) { - testDialogOpen = false; - await handleTest(testDialogKeyId); - } - }} - > - Test - - - - diff --git a/frontend/src/lib/components/settings/bounty-hub-panel.svelte b/frontend/src/lib/components/settings/bounty-hub-panel.svelte deleted file mode 100644 index cfdccdd61..000000000 --- a/frontend/src/lib/components/settings/bounty-hub-panel.svelte +++ /dev/null @@ -1,276 +0,0 @@ - - -{#if loading} -
- {#each Array(3) as _, i (i)} -
- - -
- {#each Array(3) as _, j (j)} -
- - -
- {/each} -
-
- {/each} -
-{:else if settings} -
- - - -
- - {#if settings.last_synced_at} - Last synced {relativeTime(settings.last_synced_at)} - {/if} - {#if settings.next_sync_at} - Next {relativeTime(settings.next_sync_at)} - {/if} - -
- - {#each connected as platform (platform.platform)} -
- {platform.label} - - {platform.programs.toLocaleString()} programs - {#if platform.private_programs > 0} - · {platform.private_programs.toLocaleString()} private - {/if} - -
- {/each} - -
-
- Sync interval -
- v && save({ sync_interval: v })} - > - - {SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval} - - - {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} - {label} - {/each} - - -
- - {#if settings.sync_interval === 'off'} -
- Programs update on manual refresh only. -
- {/if} -
- - -
- - {#if settings.feed_synced_at} - Last synced {relativeTime(settings.feed_synced_at)} - {/if} - {#if settings.feed_next_sync_at} - Next {relativeTime(settings.feed_next_sync_at)} - {/if} - -
- - {#each fedPlatforms as platform (platform.platform)} -
- {platform.label} - - {platform.feed_programs.toLocaleString()} programs - -
- {/each} - -
-
- Sync interval - - From - - {settings.feed_source} - - under {settings.feed_license}. Public programs only. - -
- v && save({ feed_interval: v })} - > - - {SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval} - - - {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} - {label} - {/each} - - -
-
- - - - -
-
- Notify on program changes - - Sent to the inbox and every notification channel. - -
- save({ notify: v })} - /> -
- - {#if settings.notify} -
- {#each specs as spec (spec.kind)} - {@const Icon = assetIcon(spec.icon)} - {@const on = settings.notify_events.includes(spec.kind)} -
-
- - - -
- {spec.label} - {spec.description} -
-
- toggleEvent(spec.kind, v)} - /> -
- {/each} -
- {:else} -
- Changes are recorded in the Updates tab. Nothing is sent. -
- {/if} -
-
-{:else} - - - -{/if} diff --git a/frontend/src/lib/components/settings/certificate-stream-card.svelte b/frontend/src/lib/components/settings/certificate-stream-card.svelte deleted file mode 100644 index 94a8379ad..000000000 --- a/frontend/src/lib/components/settings/certificate-stream-card.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - - -
- - Certificate transparency for the watched apexes - -
-
-
- Stream - {streamState.label} -
-
- Watched apexes - {stream ? stream.items : 'Not read'} -
-
- Last certificate - - {#if !stream} - Not read - {:else} - {stream.last_certificate_at ? relativeTime(stream.last_certificate_at) : 'None yet'} - {/if} - -
-
- Since start - - {#if !stream} - Not read - {:else} - {stream.certificates_seen} - {stream.certificates_seen === 1 ? 'certificate' : 'certificates'} - {#if stream.started_at} - - · {relativeTime(stream.started_at)} - - {/if} - {/if} - -
-
- {#if stream?.last_error} -
- {stream.last_error} - {#if stream.last_error_at} - · {relativeTime(stream.last_error_at)} - {/if} -
- {:else if failed} -
- The API did not respond. Check that the api service is running. -
- {:else if stream && !stream.reachable} -
- The ct-stream service has not reported. Check that it is running. -
- {/if} -
diff --git a/frontend/src/lib/components/settings/columns.ts b/frontend/src/lib/components/settings/columns.ts new file mode 100644 index 000000000..17c2930ad --- /dev/null +++ b/frontend/src/lib/components/settings/columns.ts @@ -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'; diff --git a/frontend/src/lib/components/settings/instance-settings-panel.svelte b/frontend/src/lib/components/settings/instance-settings-panel.svelte index c5abdabe4..467714cbd 100644 --- a/frontend/src/lib/components/settings/instance-settings-panel.svelte +++ b/frontend/src/lib/components/settings/instance-settings-panel.svelte @@ -1,385 +1,239 @@ -
-
-

General

-

- Instance identity, scan concurrency, retention windows and AI analysis. -

-
+{#if loading && !settings} + + + + + + +{:else if !settings} + + + +{:else} + +
+ + e.key === 'Enter' && e.currentTarget.blur()} + /> + + + save({ timezone: zone }, 'Time zone')} + /> + + + v && v !== settings.mode && save({ mode: v }, 'Mode')} + disabled={locked} + > + + {MODE_LABELS[coerceInstanceMode(settings.mode)]} + + + {#each INSTANCE_MODES as mode (mode.value)} + {mode.label} + {/each} + + + - +
+ + + v !== undefined && + Number(v) !== settings.concurrent_scans && + save({ concurrent_scans: Number(v) }, 'Concurrent scans')} + disabled={locked} + > + + {concurrency.find((o) => o.value === String(settings.concurrent_scans))?.label ?? + settings.concurrent_scans} + + + {#each concurrency as option (option.value)} + {option.label} + {/each} + + + + + save({ cert_recheck_enabled: v }, 'Certificate re-check')} + /> + - {#if isLoading} -
- - -
- {:else if loadFailed} - - - - {:else} - - - Instance - Name, time zone and mode. - - -
-
- - -
-
- - - {timezone} - - {#each zoneOptions as tz (tz)} - {tz} - {/each} - - -
-
+
+ + + v !== undefined && + Number(v) !== settings.scan_history_retention_days && + save({ scan_history_retention_days: Number(v) }, 'Scan history retention')} + disabled={locked} + > + + {retentionLabel(SCAN_RETENTION, String(settings.scan_history_retention_days))} + + + {#each SCAN_RETENTION as option (option.value)} + {option.label} + {/each} + + + + + + v !== undefined && + Number(v) !== settings.screenshot_retention_days && + save({ screenshot_retention_days: Number(v) }, 'Evidence retention')} + disabled={locked} + > + + {retentionLabel(SCREENSHOT_RETENTION, String(settings.screenshot_retention_days))} + + + {#each SCREENSHOT_RETENTION as option (option.value)} + {option.label} + {/each} + + + -
- - - {modePlaceholder} - - {#each INSTANCE_MODES as m (m.value)} - {m.label} - {/each} - - -

- Sets which integrations and scoping options appear. -

-
-
-
- - - - Data retention - Runs and their evidence are pruned daily. - - -
-
- - - - {SCAN_RETENTION.find((o) => o.value === scanRetention)?.label ?? '90 days'} - - - {#each SCAN_RETENTION as o (o.value)} - {o.label} - {/each} - - -
-
- - - - {SCREENSHOT_RETENTION.find((o) => o.value === screenshotRetention)?.label ?? - '30 days'} - - - {#each SCREENSHOT_RETENTION as o (o.value)} - {o.label} - {/each} - - -
-
-
-
- - - - Scans - Scans past the limit wait in launch order. - - -
- - - - {concurrencyOptions.find((o) => o.value === concurrentScans)?.label ?? - concurrentScans} - - - {#each concurrencyOptions as o (o.value)} - {o.label} - {/each} - - -

- Automatic follows the stage slots of the running scan workers. -

-
-
-
- - - - Between scans - Opens one TLS connection to each web asset every few hours to refresh certificate expiry. - - -
- - -
-
-
- - - -
- - AI -
- Model connection, feature opt-ins and cost. -
- - - -
- -
- {#if isDirty} - Unsaved changes - {/if} - -
- {/if} -
- - { - showLeaveDialog = o; - if (!o) pendingNav = null; - }} - onConfirm={() => { - showLeaveDialog = false; - const resume = pendingNav; - pendingNav = null; - resume?.(); - }} -/> + {#if !isAdmin} +
+ Editable by administrators. +
+ {/if} + +{/if} diff --git a/frontend/src/lib/components/settings/notification-channels-panel.svelte b/frontend/src/lib/components/settings/notification-channels-panel.svelte index e5786a57f..0addbe327 100644 --- a/frontend/src/lib/components/settings/notification-channels-panel.svelte +++ b/frontend/src/lib/components/settings/notification-channels-panel.svelte @@ -1,58 +1,64 @@ -
-
-
-

Notifications

-

Scan events sent to external channels.

-
+{#snippet addAction()} + +{/snippet} + +{#if !isAdmin} + +{:else if loading} + + + + + +{:else if loadFailed} + + + +{:else if channels.length === 0} + p.name).join(', ')} + > -
- - - - {#if isLoading} -
- {#each Array(3) as _, i (i)} - - -
- -
- - -
-
-
-
- {/each} -
- {:else if channels.length === 0} - - - -
-

No notification channels

-

- {PROVIDERS.map((p) => p.name).join(', ')} -

-
- -
-
- {:else} -
+ +{:else} + +
+
+
Channel
+
Events
+
Minimum level
+
Last delivery
+
+
{#each channels as channel (channel.id)} {@const meta = metaFor(channel.provider)} - {@const Icon = meta.icon} - - -
-
-
- toggleCheck(channel.id)} - aria-label="Select {channel.name}" - /> -
-
- -
-
-

{channel.name}

-

{meta.name}

-
-
- - notificationChannelsStore.update(channel.id, { is_active: checked }).then((u) => { - if (u) toast.success(checked ? 'Channel enabled' : 'Channel disabled'); - })} - /> -
- - - -
- {#each meta.fields.filter((f) => f.kind !== 'bool') as f (f.key)} -
- {f.label} - {String(channel.config_masked[f.key] ?? '—')} -
- {/each} -
- - {summaryLine(channel)} - - {#if channel.last_test_at} - - {#if channel.last_test_ok} - - {:else} - - {/if} - {formatDate(channel.last_test_at)} - - {/if} -
-
- -
- - -
- - - {#snippet child({ props })} - - {/snippet} - - Remove channel - -
-
-
+ {@const sent = delivery(channel)} +
+
+ {channel.name} + {meta.name} +
+
{eventSummary(channel)}
+
{levelOf(channel)}
+
+ + {#snippet child(props)} + + {#if testingId === channel.id} + + Sending + {:else} + + + {sent.label} + + {/if} + + {/snippet} + +
+
+ + + {#snippet child({ props })} + + {/snippet} + + + test(channel)} + > + Send test + + openEdit(channel)}>Edit + setActive(channel, !channel.is_active)}> + {channel.is_active ? 'Disable' : 'Enable'} + + + (removing = channel)}> + Remove + + + +
+
{/each}
- {/if} -
+ +{/if} {editingId ? 'Edit channel' : 'Add channel'} - Connection and events. @@ -507,7 +465,7 @@ {#each PROVIDERS as p (p.provider)} {@const PIcon = p.icon}