From 29762664d7d67c2e281b1b18f19846562c175ac2 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 17 Mar 2026 11:11:08 +0100 Subject: [PATCH] Move conffile_parser from services to utils --- Docs/Tasks.md | 6 ++ backend/app/services/config_file_service.py | 3 +- backend/app/services/file_config_service.py | 14 +-- backend/app/services/geo_service.py | 11 ++- backend/app/services/history_service.py | 7 +- backend/app/services/server_service.py | 74 ++++++++++++---- .../{services => utils}/conffile_parser.py | 0 backend/app/utils/fail2ban_client.py | 46 +++++++--- .../test_services/test_conffile_parser.py | 38 ++++---- frontend/src/hooks/useSetup.ts | 86 +++++++++++++++++++ frontend/src/pages/SetupPage.tsx | 3 +- 11 files changed, 226 insertions(+), 62 deletions(-) rename backend/app/{services => utils}/conffile_parser.py (100%) create mode 100644 frontend/src/hooks/useSetup.ts diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 9b5c492..e7df82e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -77,6 +77,8 @@ This document breaks the entire BanGUI project into development stages, ordered #### TASK B-4 — Move `conffile_parser.py` from `services/` to `utils/` +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §2.2 and Architecture §2.1 — `services/` is for business logic. `conffile_parser.py` is a pure, stateless parsing library with no framework dependencies (no FastAPI, no aiosqlite). It belongs in `utils/`. **Files affected:** @@ -187,6 +189,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK B-10 — Replace `Any` type usage in `history_service.py` +**Status:** Completed ✅ + **Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations. **Files affected:** @@ -203,6 +207,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK B-11 — Reduce `Any` usage in `server_service.py` +**Status:** Completed ✅ + **Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations. **Files affected:** diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index b5dc1eb..ef9ca46 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -54,8 +54,9 @@ from app.models.config import ( JailValidationResult, RollbackResponse, ) -from app.services import conffile_parser, jail_service +from app.services import jail_service from app.services.jail_service import JailNotFoundError as JailNotFoundError +from app.utils import conffile_parser from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError log: structlog.stdlib.BoundLogger = structlog.get_logger() diff --git a/backend/app/services/file_config_service.py b/backend/app/services/file_config_service.py index 271cbc8..e6d6c7d 100644 --- a/backend/app/services/file_config_service.py +++ b/backend/app/services/file_config_service.py @@ -817,7 +817,7 @@ async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig: """Parse a filter definition file and return its structured representation. Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with - :func:`~app.services.conffile_parser.parse_filter_file`, and returns the + :func:`~app.utils.conffile_parser.parse_filter_file`, and returns the result. Args: @@ -831,7 +831,7 @@ async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig: ConfigFileNotFoundError: If no matching file is found. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import parse_filter_file # avoid circular imports + from app.utils.conffile_parser import parse_filter_file # avoid circular imports def _do() -> FilterConfig: filter_d = _resolve_subdir(config_dir, "filter.d") @@ -863,7 +863,7 @@ async def update_parsed_filter_file( ConfigFileWriteError: If the file cannot be written. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import ( # avoid circular imports + from app.utils.conffile_parser import ( # avoid circular imports merge_filter_update, parse_filter_file, serialize_filter_config, @@ -901,7 +901,7 @@ async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig: ConfigFileNotFoundError: If no matching file is found. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import parse_action_file # avoid circular imports + from app.utils.conffile_parser import parse_action_file # avoid circular imports def _do() -> ActionConfig: action_d = _resolve_subdir(config_dir, "action.d") @@ -930,7 +930,7 @@ async def update_parsed_action_file( ConfigFileWriteError: If the file cannot be written. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import ( # avoid circular imports + from app.utils.conffile_parser import ( # avoid circular imports merge_action_update, parse_action_file, serialize_action_config, @@ -963,7 +963,7 @@ async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig ConfigFileNotFoundError: If no matching file is found. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import parse_jail_file # avoid circular imports + from app.utils.conffile_parser import parse_jail_file # avoid circular imports def _do() -> JailFileConfig: jail_d = _resolve_subdir(config_dir, "jail.d") @@ -992,7 +992,7 @@ async def update_parsed_jail_file( ConfigFileWriteError: If the file cannot be written. ConfigDirError: If *config_dir* does not exist. """ - from app.services.conffile_parser import ( # avoid circular imports + from app.utils.conffile_parser import ( # avoid circular imports merge_jail_file_update, parse_jail_file, serialize_jail_file_config, diff --git a/backend/app/services/geo_service.py b/backend/app/services/geo_service.py index fa66b92..f9e2b7f 100644 --- a/backend/app/services/geo_service.py +++ b/backend/app/services/geo_service.py @@ -40,8 +40,9 @@ from __future__ import annotations import asyncio import time +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias import aiohttp import structlog @@ -118,6 +119,14 @@ class GeoInfo: """Organisation name associated with the IP, e.g. ``"Deutsche Telekom"``.""" +GeoEnricher: TypeAlias = Callable[[str], Awaitable[GeoInfo | None]] +"""Async callable used to enrich IPs with :class:`~app.services.geo_service.GeoInfo`. + +This is a shared type alias used by services that optionally accept a geo +lookup callable (for example, :mod:`app.services.history_service`). +""" + + # --------------------------------------------------------------------------- # Internal cache # --------------------------------------------------------------------------- diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py index bad337b..fee58c0 100644 --- a/backend/app/services/history_service.py +++ b/backend/app/services/history_service.py @@ -11,10 +11,11 @@ modifies or locks the fail2ban database. from __future__ import annotations from datetime import UTC, datetime -from typing import Any import structlog +from app.services.geo_service import GeoEnricher + from app.models.ban import TIME_RANGE_SECONDS, TimeRange from app.models.history import ( HistoryBanItem, @@ -61,7 +62,7 @@ async def list_history( ip_filter: str | None = None, page: int = 1, page_size: int = _DEFAULT_PAGE_SIZE, - geo_enricher: Any | None = None, + geo_enricher: GeoEnricher | None = None, ) -> HistoryListResponse: """Return a paginated list of historical ban records with optional filters. @@ -160,7 +161,7 @@ async def get_ip_detail( socket_path: str, ip: str, *, - geo_enricher: Any | None = None, + geo_enricher: GeoEnricher | None = None, ) -> IpDetailResponse | None: """Return the full historical record for a single IP address. diff --git a/backend/app/services/server_service.py b/backend/app/services/server_service.py index 6180aaa..85d5914 100644 --- a/backend/app/services/server_service.py +++ b/backend/app/services/server_service.py @@ -10,18 +10,50 @@ HTTP/FastAPI concerns. from __future__ import annotations -from typing import Any +from typing import cast, TypeAlias import structlog from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate -from app.utils.fail2ban_client import Fail2BanClient +from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + +Fail2BanSettingValue: TypeAlias = str | int | bool +"""Allowed values for server settings commands.""" log: structlog.stdlib.BoundLogger = structlog.get_logger() _SOCKET_TIMEOUT: float = 10.0 +def _to_int(value: object | None, default: int) -> int: + """Convert a raw value to an int, falling back to a default. + + The fail2ban control socket can return either int or str values for some + settings, so we normalise them here in a type-safe way. + """ + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return default + return default + + +def _to_str(value: object | None, default: str) -> str: + """Convert a raw value to a string, falling back to a default.""" + if value is None: + return default + return str(value) + + # --------------------------------------------------------------------------- # Custom exceptions # --------------------------------------------------------------------------- @@ -36,7 +68,7 @@ class ServerOperationError(Exception): # --------------------------------------------------------------------------- -def _ok(response: Any) -> Any: +def _ok(response: Fail2BanResponse) -> object: """Extract payload from a fail2ban ``(code, data)`` response. Args: @@ -59,9 +91,9 @@ def _ok(response: Any) -> Any: async def _safe_get( client: Fail2BanClient, - command: list[Any], - default: Any = None, -) -> Any: + command: Fail2BanCommand, + default: object | None = None, +) -> object | None: """Send a command and silently return *default* on any error. Args: @@ -73,7 +105,8 @@ async def _safe_get( The successful response, or *default*. """ try: - return _ok(await client.send(command)) + response = await client.send(command) + return _ok(cast(Fail2BanResponse, response)) except Exception: return default @@ -118,13 +151,20 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse: _safe_get(client, ["get", "dbmaxmatches"], 10), ) + log_level = _to_str(log_level_raw, "INFO").upper() + log_target = _to_str(log_target_raw, "STDOUT") + syslog_socket = _to_str(syslog_socket_raw, "") or None + db_path = _to_str(db_path_raw, "/var/lib/fail2ban/fail2ban.sqlite3") + db_purge_age = _to_int(db_purge_age_raw, 86400) + db_max_matches = _to_int(db_max_matches_raw, 10) + settings = ServerSettings( - log_level=str(log_level_raw or "INFO").upper(), - log_target=str(log_target_raw or "STDOUT"), - syslog_socket=str(syslog_socket_raw) if syslog_socket_raw else None, - db_path=str(db_path_raw or "/var/lib/fail2ban/fail2ban.sqlite3"), - db_purge_age=int(db_purge_age_raw or 86400), - db_max_matches=int(db_max_matches_raw or 10), + log_level=log_level, + log_target=log_target, + syslog_socket=syslog_socket, + db_path=db_path, + db_purge_age=db_purge_age, + db_max_matches=db_max_matches, ) log.info("server_settings_fetched") @@ -146,9 +186,10 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) - async def _set(key: str, value: Any) -> None: + async def _set(key: str, value: Fail2BanSettingValue) -> None: try: - _ok(await client.send(["set", key, value])) + response = await client.send(["set", key, value]) + _ok(cast(Fail2BanResponse, response)) except ValueError as exc: raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc @@ -182,7 +223,8 @@ async def flush_logs(socket_path: str) -> str: """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) try: - result = _ok(await client.send(["flushlogs"])) + response = await client.send(["flushlogs"]) + result = _ok(cast(Fail2BanResponse, response)) log.info("logs_flushed", result=result) return str(result) except ValueError as exc: diff --git a/backend/app/services/conffile_parser.py b/backend/app/utils/conffile_parser.py similarity index 100% rename from backend/app/services/conffile_parser.py rename to backend/app/utils/conffile_parser.py diff --git a/backend/app/utils/fail2ban_client.py b/backend/app/utils/fail2ban_client.py index 51ebe97..6e84cf6 100644 --- a/backend/app/utils/fail2ban_client.py +++ b/backend/app/utils/fail2ban_client.py @@ -22,7 +22,27 @@ import errno import socket import time from pickle import HIGHEST_PROTOCOL, dumps, loads -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, TypeAlias + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + +Fail2BanToken: TypeAlias = str | int | float | bool | None | dict[str, object] | list[object] +"""A single token in a fail2ban command. + +Fail2ban accepts simple types (str/int/float/bool) plus compound types +(list/dict). Complex objects are stringified before being sent. +""" + +Fail2BanCommand: TypeAlias = list[Fail2BanToken] +"""A command sent to fail2ban over the socket. + +Commands are pickle serialised lists of tokens. +""" + +Fail2BanResponse: TypeAlias = tuple[int, object] +"""A typical fail2ban response containing a status code and payload.""" if TYPE_CHECKING: from types import TracebackType @@ -81,9 +101,9 @@ class Fail2BanProtocolError(Exception): def _send_command_sync( socket_path: str, - command: list[Any], + command: Fail2BanCommand, timeout: float, -) -> Any: +) -> object: """Send a command to fail2ban and return the parsed response. This is a **synchronous** function intended to be called from within @@ -180,7 +200,7 @@ def _send_command_sync( ) from last_oserror -def _coerce_command_token(token: Any) -> Any: +def _coerce_command_token(token: Fail2BanToken) -> Fail2BanToken: """Coerce a command token to a type that fail2ban understands. fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``, @@ -229,7 +249,7 @@ class Fail2BanClient: self.socket_path: str = socket_path self.timeout: float = timeout - async def send(self, command: list[Any]) -> Any: + async def send(self, command: Fail2BanCommand) -> object: """Send a command to fail2ban and return the response. Acquires the module-level concurrency semaphore before dispatching @@ -267,13 +287,13 @@ class Fail2BanClient: log.debug("fail2ban_sending_command", command=command) loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() try: - response: Any = await loop.run_in_executor( - None, - _send_command_sync, - self.socket_path, - command, - self.timeout, - ) + response: object = await loop.run_in_executor( + None, + _send_command_sync, + self.socket_path, + command, + self.timeout, + ) except Fail2BanConnectionError: log.warning( "fail2ban_connection_error", @@ -300,7 +320,7 @@ class Fail2BanClient: ``True`` when the daemon responds correctly, ``False`` otherwise. """ try: - response: Any = await self.send(["ping"]) + response: object = await self.send(["ping"]) return bool(response == 1) # fail2ban returns 1 on successful ping except (Fail2BanConnectionError, Fail2BanProtocolError): return False diff --git a/backend/tests/test_services/test_conffile_parser.py b/backend/tests/test_services/test_conffile_parser.py index e69e4f0..4420dc3 100644 --- a/backend/tests/test_services/test_conffile_parser.py +++ b/backend/tests/test_services/test_conffile_parser.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from app.services.conffile_parser import ( +from app.utils.conffile_parser import ( merge_action_update, merge_filter_update, parse_action_file, @@ -451,7 +451,7 @@ class TestParseJailFile: """Unit tests for parse_jail_file.""" def test_minimal_parses_correctly(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file cfg = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf") assert cfg.filename == "sshd.conf" @@ -463,7 +463,7 @@ class TestParseJailFile: assert jail.logpath == ["/var/log/auth.log"] def test_full_parses_multiple_jails(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file cfg = parse_jail_file(FULL_JAIL) assert len(cfg.jails) == 2 @@ -471,7 +471,7 @@ class TestParseJailFile: assert "nginx-botsearch" in cfg.jails def test_full_jail_numeric_fields(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(FULL_JAIL).jails["sshd"] assert jail.maxretry == 3 @@ -479,7 +479,7 @@ class TestParseJailFile: assert jail.bantime == 3600 def test_full_jail_multiline_logpath(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(FULL_JAIL).jails["sshd"] assert len(jail.logpath) == 2 @@ -487,53 +487,53 @@ class TestParseJailFile: assert "/var/log/syslog" in jail.logpath def test_full_jail_multiline_action(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"] assert len(jail.action) == 2 assert "sendmail-whois" in jail.action def test_enabled_true(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(FULL_JAIL).jails["sshd"] assert jail.enabled is True def test_enabled_false(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"] assert jail.enabled is False def test_extra_keys_captured(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"] assert jail.extra["custom_key"] == "custom_value" assert jail.extra["another_key"] == "42" def test_extra_keys_not_in_named_fields(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"] assert "enabled" not in jail.extra assert "logpath" not in jail.extra def test_empty_file_yields_no_jails(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file cfg = parse_jail_file("") assert cfg.jails == {} def test_invalid_ini_does_not_raise(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file # Should not raise; just parse what it can. cfg = parse_jail_file("@@@ not valid ini @@@", filename="bad.conf") assert isinstance(cfg.jails, dict) def test_default_section_ignored(self) -> None: - from app.services.conffile_parser import parse_jail_file + from app.utils.conffile_parser import parse_jail_file content = "[DEFAULT]\nignoreip = 127.0.0.1\n\n[sshd]\nenabled = true\n" cfg = parse_jail_file(content) @@ -550,7 +550,7 @@ class TestJailFileRoundTrip: """Tests that parse → serialize → parse preserves values.""" def test_minimal_round_trip(self) -> None: - from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config original = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf") serialized = serialize_jail_file_config(original) @@ -560,7 +560,7 @@ class TestJailFileRoundTrip: assert restored.jails["sshd"].logpath == original.jails["sshd"].logpath def test_full_round_trip(self) -> None: - from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config original = parse_jail_file(FULL_JAIL) serialized = serialize_jail_file_config(original) @@ -573,7 +573,7 @@ class TestJailFileRoundTrip: assert sorted(restored_jail.action) == sorted(jail.action) def test_extra_keys_round_trip(self) -> None: - from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config original = parse_jail_file(JAIL_WITH_EXTRA) serialized = serialize_jail_file_config(original) @@ -591,7 +591,7 @@ class TestMergeJailFileUpdate: def test_none_update_returns_original(self) -> None: from app.models.config import JailFileConfigUpdate - from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file cfg = parse_jail_file(FULL_JAIL) update = JailFileConfigUpdate() @@ -600,7 +600,7 @@ class TestMergeJailFileUpdate: def test_update_replaces_jail(self) -> None: from app.models.config import JailFileConfigUpdate, JailSectionConfig - from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file cfg = parse_jail_file(FULL_JAIL) new_sshd = JailSectionConfig(enabled=False, port="2222") @@ -613,7 +613,7 @@ class TestMergeJailFileUpdate: def test_update_adds_new_jail(self) -> None: from app.models.config import JailFileConfigUpdate, JailSectionConfig - from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file cfg = parse_jail_file(MINIMAL_JAIL) new_jail = JailSectionConfig(enabled=True, port="443") diff --git a/frontend/src/hooks/useSetup.ts b/frontend/src/hooks/useSetup.ts new file mode 100644 index 0000000..7ee4a38 --- /dev/null +++ b/frontend/src/hooks/useSetup.ts @@ -0,0 +1,86 @@ +/** + * Hook for the initial BanGUI setup flow. + * + * Exposes the current setup completion status and a submission handler. + */ + +import { useCallback, useEffect, useState } from "react"; +import { ApiError } from "../api/client"; +import { getSetupStatus, submitSetup } from "../api/setup"; +import type { + SetupRequest, + SetupStatusResponse, +} from "../types/setup"; + +export interface UseSetupResult { + /** Known setup status, or null while loading. */ + status: SetupStatusResponse | null; + /** Whether the initial status check is in progress. */ + loading: boolean; + /** User-facing error message from the last status check. */ + error: string | null; + /** Refresh the setup status from the backend. */ + refresh: () => void; + /** Whether a submit request is currently in flight. */ + submitting: boolean; + /** User-facing error message from the last submit attempt. */ + submitError: string | null; + /** Submit the initial setup payload. */ + submit: (payload: SetupRequest) => Promise; +} + +export function useSetup(): UseSetupResult { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const refresh = useCallback(async (): Promise => { + setLoading(true); + setError(null); + + try { + const resp = await getSetupStatus(); + setStatus(resp); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch setup status"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const submit = useCallback(async (payload: SetupRequest): Promise => { + setSubmitting(true); + setSubmitError(null); + + try { + await submitSetup(payload); + } catch (err: unknown) { + if (err instanceof ApiError) { + setSubmitError(err.message); + } else if (err instanceof Error) { + setSubmitError(err.message); + } else { + setSubmitError("An unexpected error occurred."); + } + throw err; + } finally { + setSubmitting(false); + } + }, []); + + return { + status, + loading, + error, + refresh, + submitting, + submitError, + submit, + }; +} diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 47db271..9a10dca 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -20,8 +20,7 @@ import { } from "@fluentui/react-components"; import { useNavigate } from "react-router-dom"; import type { ChangeEvent, FormEvent } from "react"; -import { ApiError } from "../api/client"; -import { getSetupStatus, submitSetup } from "../api/setup"; +import { useSetup } from "../hooks/useSetup"; // --------------------------------------------------------------------------- // Styles