Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07f43686e | ||
|
|
3bde741fd2 | ||
|
|
e8ea6857bc | ||
|
|
42774fc138 | ||
|
|
8f273e0201 | ||
|
|
3c8e463dd9 | ||
|
|
c4017c7634 | ||
|
|
a88688170a | ||
|
|
aaccb50b00 | ||
|
|
34758c9850 | ||
|
|
4d2284dc37 | ||
|
|
9f974f2fcf | ||
|
|
37ad9c6933 | ||
|
|
99e7b9a343 | ||
|
|
f061db645b | ||
|
|
fd96b7d6ae | ||
|
|
0e95d99868 |
+20
-2
@@ -1,4 +1,5 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
SQLITE_PATH=./data/homelab.db
|
||||
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
|
||||
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
# Auth — local mode is the backward-compatible default.
|
||||
AUTH_MODE=local
|
||||
# Local credentials: admin / admin by default.
|
||||
# ⚠️ Change before exposing on a network.
|
||||
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
# OpenID Connect (optional alternative to local auth).
|
||||
# Set AUTH_MODE=oidc and configure every value below. The callback must be
|
||||
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
|
||||
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
|
||||
# also requires SECRET_KEY to contain at least 32 bytes.
|
||||
# AUTH_MODE=oidc
|
||||
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=homelable
|
||||
# OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
# OIDC_COOKIE_SECURE=true
|
||||
# OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
|
||||
# Scanner — JSON array of CIDR ranges to scan
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
|
||||
@@ -60,3 +60,20 @@ jobs:
|
||||
- run: ruff check .
|
||||
- run: mypy app/
|
||||
- run: pytest
|
||||
|
||||
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
|
||||
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
|
||||
# test_edge_types_sync.py) — those tests need the frontend sources, so this
|
||||
# job runs from a full checkout rather than the mcp Docker context.
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest
|
||||
|
||||
@@ -31,11 +31,12 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: NPM Audit
|
||||
run: cd frontend && npm audit --audit-level=high
|
||||
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
|
||||
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
|
||||
# bundled or served, so its advisories must not fail release CI.
|
||||
run: cd frontend && npm audit --omit=dev --audit-level=high
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Pip audit
|
||||
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
|
||||
# python-jose forces ecdsa as a dependency with no fixed version available.
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt
|
||||
|
||||
@@ -5,6 +5,25 @@ All notable changes to **Homelable** are documented here.
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.1.2] - 2026-08-01
|
||||
|
||||
### Features
|
||||
|
||||
- Generic OIDC authentication: backend sessions and browser login flow. Thanks @autogpt-elendir. (#306)
|
||||
- Brand icon picker for services, in a dedicated add/edit modal. (#313)
|
||||
- MCP: lightweight node lookup tools. Thanks @Nebesz. (#312)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sync the MCP node and edge type enums with the frontend, gated in CI against drift. Thanks @Nebesz. (#311)
|
||||
- Harden OIDC callback validation. (#306)
|
||||
- Use the official Homelable logo on the login page. (#306)
|
||||
- Clear the seven open dependabot alerts and refresh vulnerable frontend packages. (#317, #306)
|
||||
|
||||
### Docs
|
||||
|
||||
- Document OIDC setup and configuration. (#306)
|
||||
|
||||
## [3.1.1] - 2026-07-19
|
||||
|
||||
### Features
|
||||
|
||||
+18
@@ -29,6 +29,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
15. [Gethomepage Widget](#15-gethomepage-widget-)
|
||||
16. [MCP Server (AI integration)](#16-mcp-server-)
|
||||
17. [Settings & Shortcuts](#17-settings--shortcuts)
|
||||
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
|
||||
|
||||
---
|
||||
|
||||
@@ -252,4 +253,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 18. Authentication (Local / OpenID Connect) 🔒
|
||||
|
||||
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
|
||||
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
|
||||
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
|
||||
|
||||
**Use (OIDC):**
|
||||
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
|
||||
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
|
||||
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
|
||||
|
||||
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
|
||||
|
||||
> OIDC is Full-mode only — the no-backend standalone build has no login.
|
||||
|
||||
---
|
||||
|
||||
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
|
||||
|
||||
+26
-2
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||
>
|
||||
Generate a new hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
|
||||
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
||||
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
|
||||
```
|
||||
|
||||
## Quick Start — Frontend only
|
||||
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
All configuration is done via `.env` (copied from `.env.example`):
|
||||
|
||||
```env
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
|
||||
# Auth — default: admin / admin
|
||||
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
```
|
||||
|
||||
### OpenID Connect (optional)
|
||||
|
||||
OIDC is an exclusive alternative to the local password. Existing installs stay
|
||||
in `AUTH_MODE=local` unless explicitly changed.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
```
|
||||
|
||||
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
|
||||
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
|
||||
backend uses Authorization Code with PKCE and keeps provider tokens out of the
|
||||
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
|
||||
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
|
||||
|
||||
All settings are also editable in-app via the **Scan Network** button.
|
||||
|
||||
---
|
||||
|
||||
+45
-10
@@ -1,31 +1,66 @@
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
from app.core.security import decode_token, get_oidc_session
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
) -> AuthContext:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
return AuthContext(
|
||||
subject="__mcp_service__",
|
||||
display_name="MCP service",
|
||||
auth_method="mcp",
|
||||
)
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return username
|
||||
if credentials is not None:
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return AuthContext(
|
||||
subject=username,
|
||||
display_name=username,
|
||||
auth_method="local",
|
||||
)
|
||||
|
||||
# 3. OIDC session cookie (only active in exclusive OIDC mode)
|
||||
if settings.auth_mode == "oidc":
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is not None:
|
||||
return AuthContext(
|
||||
subject=oidc_session.subject,
|
||||
display_name=oidc_session.display_name,
|
||||
auth_method="oidc",
|
||||
issuer=oidc_session.issuer,
|
||||
csrf_token=oidc_session.csrf_token,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
|
||||
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
|
||||
return context.subject
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.api.deps import AuthContext, get_auth_context
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.core.security import (
|
||||
clear_oidc_session_cookie,
|
||||
create_access_token,
|
||||
create_oidc_session_token,
|
||||
get_oidc_client,
|
||||
set_oidc_session_cookie,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
mode: str
|
||||
oidc_login_url: str | None = None
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def _require_oidc_mode() -> None:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
|
||||
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config() -> AuthConfigResponse:
|
||||
return AuthConfigResponse(
|
||||
mode=settings.auth_mode,
|
||||
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest) -> TokenResponse:
|
||||
if settings.auth_mode != "local":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
|
||||
# Always run both checks to prevent timing-based username enumeration.
|
||||
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
||||
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
||||
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except (OAuthError, JoseError) as exc:
|
||||
logger.warning("OIDC callback rejected: %s", exc.error)
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not isinstance(userinfo, Mapping):
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
|
||||
try:
|
||||
session_token = create_oidc_session_token(userinfo)
|
||||
except ValueError:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
request.session.clear()
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_oidc_session_cookie(response, session_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
|
||||
return CurrentUserResponse(
|
||||
subject=context.subject,
|
||||
display_name=context.display_name,
|
||||
auth_method=context.auth_method,
|
||||
issuer=context.issuer,
|
||||
csrf_token=context.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
clear_oidc_session_cookie(response)
|
||||
return response
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -55,9 +55,15 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
|
||||
|
||||
|
||||
@router.get("", response_model=list[NodeResponse])
|
||||
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
||||
result = await db.execute(select(Node))
|
||||
return list(result.scalars().all())
|
||||
async def list_nodes(
|
||||
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[Node]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
return list((await db.execute(query)).scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -3,7 +3,13 @@ import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
decode_oidc_session_token,
|
||||
decode_token,
|
||||
oidc_session_cookie_name,
|
||||
origin_is_allowed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
await websocket.accept()
|
||||
try:
|
||||
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
if not origin_is_allowed(websocket.headers.get("origin")):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
|
||||
if settings.auth_mode == "oidc":
|
||||
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
|
||||
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
else:
|
||||
# Local mode keeps the existing first-message bearer protocol.
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
_connections.append(websocket)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
|
||||
access_token_expire_minutes: int = 1440 # 24h
|
||||
|
||||
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
||||
auth_mode: Literal["local", "oidc"] = "local"
|
||||
auth_username: str = "admin"
|
||||
auth_password_hash: str = ""
|
||||
|
||||
# OpenID Connect — required only when AUTH_MODE=oidc.
|
||||
oidc_discovery_url: str = ""
|
||||
oidc_client_id: str = ""
|
||||
oidc_client_secret: str = ""
|
||||
oidc_redirect_uri: str = ""
|
||||
oidc_scopes: str = "openid profile email"
|
||||
oidc_cookie_secure: bool = True
|
||||
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
|
||||
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_password_hash(self) -> "Settings":
|
||||
def validate_auth_settings(self) -> "Settings":
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
|
||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||
)
|
||||
if self.auth_mode == "oidc":
|
||||
required = {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_CLIENT_ID": self.oidc_client_id,
|
||||
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}
|
||||
missing = [name for name, value in required.items() if not value]
|
||||
if missing:
|
||||
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
|
||||
if len(self.secret_key.encode("utf-8")) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
|
||||
if "openid" not in self.oidc_scopes.split():
|
||||
raise ValueError("OIDC_SCOPES must contain 'openid'")
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith(("https://", "http://")):
|
||||
raise ValueError(f"{name} must be an HTTP(S) URL")
|
||||
if self.oidc_cookie_secure:
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import hmac
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
||||
from fastapi import Request, Response, status
|
||||
from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OIDCSession:
|
||||
subject: str
|
||||
issuer: str
|
||||
display_name: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return str(sub) if sub is not None else None
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
|
||||
subject = userinfo.get("sub")
|
||||
issuer = userinfo.get("iss")
|
||||
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
|
||||
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
|
||||
|
||||
display_name = next(
|
||||
(
|
||||
value
|
||||
for value in (
|
||||
userinfo.get("preferred_username"),
|
||||
userinfo.get("name"),
|
||||
userinfo.get("email"),
|
||||
subject,
|
||||
)
|
||||
if isinstance(value, str) and value
|
||||
),
|
||||
subject,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"oidc_issuer": issuer,
|
||||
"name": display_name,
|
||||
"csrf": secrets.token_urlsafe(32),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
|
||||
"token_use": OIDC_SESSION_TOKEN_USE,
|
||||
}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_oidc_session_token(token: str) -> OIDCSession | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
issuer = payload.get("oidc_issuer")
|
||||
display_name = payload.get("name")
|
||||
csrf_token = payload.get("csrf")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
return None
|
||||
if not isinstance(issuer, str) or not issuer:
|
||||
return None
|
||||
if not isinstance(display_name, str) or not display_name:
|
||||
return None
|
||||
if not isinstance(csrf_token, str) or not csrf_token:
|
||||
return None
|
||||
return OIDCSession(
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
display_name=display_name,
|
||||
csrf_token=csrf_token,
|
||||
)
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def oidc_session_cookie_name() -> str:
|
||||
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
|
||||
|
||||
|
||||
def set_oidc_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
value=token,
|
||||
max_age=settings.oidc_session_expire_minutes * 60,
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_oidc_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def get_oidc_session(request: Request) -> OIDCSession | None:
|
||||
token = request.cookies.get(oidc_session_cookie_name())
|
||||
return decode_oidc_session_token(token) if token else None
|
||||
|
||||
|
||||
def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
# Explicit non-cookie credentials take precedence in get_current_user,
|
||||
# so their requests keep the existing CSRF-free API behavior.
|
||||
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
|
||||
return await call_next(request)
|
||||
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is None:
|
||||
return await call_next(request)
|
||||
|
||||
csrf_token = request.headers.get("X-Homelable-CSRF", "")
|
||||
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
|
||||
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF validation failed"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _build_oidc_client(
|
||||
discovery_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> StarletteOAuth2App:
|
||||
oauth = OAuth()
|
||||
client = oauth.register(
|
||||
name="oidc",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": scopes,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
if client is None:
|
||||
raise RuntimeError("Failed to register OIDC client")
|
||||
return client
|
||||
|
||||
|
||||
def get_oidc_client() -> StarletteOAuth2App:
|
||||
return _build_oidc_client(
|
||||
settings.oidc_discovery_url,
|
||||
settings.oidc_client_id,
|
||||
settings.oidc_client_secret,
|
||||
settings.oidc_scopes,
|
||||
)
|
||||
|
||||
+12
-1
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.routes import (
|
||||
auth,
|
||||
@@ -25,6 +26,7 @@ from app.api.routes import (
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.core.security import OIDCCSRFMiddleware
|
||||
from app.db.database import init_db
|
||||
|
||||
|
||||
@@ -53,12 +55,21 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(OIDCCSRFMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
|
||||
max_age=settings.oidc_transaction_expire_seconds,
|
||||
same_site="lax",
|
||||
https_only=settings.oidc_cookie_secure,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
|
||||
alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
PyJWT==2.13.0
|
||||
bcrypt==4.2.1
|
||||
authlib==1.7.2
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
|
||||
+288
-3
@@ -1,5 +1,56 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import ExpiredTokenError, JoseError
|
||||
from fastapi import WebSocketDisconnect
|
||||
from httpx import AsyncClient
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
class FakeOIDCClient:
|
||||
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
|
||||
self.token = token or {}
|
||||
self.error = error
|
||||
self.redirect_uri: str | None = None
|
||||
|
||||
async def authorize_redirect(self, _request, redirect_uri: str):
|
||||
self.redirect_uri = redirect_uri
|
||||
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
|
||||
|
||||
async def authorize_access_token(self, _request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_settings():
|
||||
from app.core.config import settings
|
||||
from app.core.security import _build_oidc_client
|
||||
|
||||
values = {
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "test-client-secret",
|
||||
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
|
||||
"oidc_scopes": "openid profile email",
|
||||
"oidc_cookie_secure": False,
|
||||
"oidc_session_expire_minutes": 60,
|
||||
"cors_origins": ["http://test"],
|
||||
}
|
||||
original = {name: getattr(settings, name) for name in values}
|
||||
for name, value in values.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
yield settings
|
||||
for name, value in original.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient):
|
||||
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_auth_config_defaults_to_local(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"mode": "local", "oidc_login_url": None}
|
||||
|
||||
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
|
||||
"""A JWT whose `exp` is in the past must be refused."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||
"""A token signed with a different key must not be accepted."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
|
||||
# --- OpenID Connect ---
|
||||
|
||||
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"mode": "oidc",
|
||||
"oidc_login_url": "/api/v1/auth/oidc/login",
|
||||
}
|
||||
|
||||
|
||||
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient()
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"].startswith("https://idp.example/")
|
||||
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
|
||||
|
||||
|
||||
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
|
||||
from app.core.security import get_oidc_client
|
||||
|
||||
oidc_client = get_oidc_client()
|
||||
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
|
||||
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
|
||||
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
|
||||
|
||||
query = parse_qs(urlparse(authorization["url"]).query)
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert "code_challenge" in query
|
||||
assert "code_verifier" not in query
|
||||
assert query["nonce"] == [authorization["nonce"]]
|
||||
assert authorization["code_verifier"]
|
||||
|
||||
|
||||
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
callback = await client.get("/api/v1/auth/oidc/callback")
|
||||
|
||||
assert callback.status_code == 303
|
||||
assert callback.headers["location"] == "/"
|
||||
cookie = callback.headers["set-cookie"]
|
||||
assert "homelable-session=" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
|
||||
me = await client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["subject"] == "user-123"
|
||||
assert me.json()["display_name"] == "alice"
|
||||
assert me.json()["auth_method"] == "oidc"
|
||||
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
|
||||
assert me.json()["csrf_token"]
|
||||
|
||||
protected = await client.get("/api/v1/nodes")
|
||||
assert protected.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
|
||||
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
|
||||
token = {} if userinfo is None else {"userinfo": userinfo}
|
||||
fake_client = FakeOIDCClient(token=token)
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "sensitive detail" not in res.text
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=ExpiredTokenError())
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "expired" not in res.text.lower()
|
||||
|
||||
|
||||
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
|
||||
from app.core.security import create_oidc_session_token
|
||||
|
||||
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
missing = await client.post("/api/v1/auth/logout")
|
||||
assert missing.status_code == 403
|
||||
wrong_origin = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
valid = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert valid.status_code == 204
|
||||
assert "Max-Age=0" in valid.headers["set-cookie"]
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
|
||||
# --- WebSocket authentication ---
|
||||
|
||||
def _wait_for_connection_count(expected: int) -> None:
|
||||
from app.api.routes import status as status_routes
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while len(status_routes._connections) != expected and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(status_routes._connections) == expected
|
||||
|
||||
|
||||
def test_local_websocket_keeps_first_message_bearer_flow():
|
||||
from app.core.security import create_access_token
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://localhost:3000"},
|
||||
) as websocket:
|
||||
websocket.send_json({"token": create_access_token("admin")})
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_websocket_rejects_untrusted_origin_before_auth():
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "https://evil.example"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
|
||||
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
|
||||
from app.main import app
|
||||
|
||||
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
test_client = TestClient(app)
|
||||
test_client.cookies.set(oidc_session_cookie_name(), session_token)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
):
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
@@ -363,3 +363,43 @@ async def test_create_node_explicit_zero_position_preserved(client: AsyncClient,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
|
||||
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
|
||||
for label in ("A", "B", "C"):
|
||||
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 3
|
||||
|
||||
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
|
||||
assert written["zigbee_sync_enabled"] is True
|
||||
assert written["zigbee_sync_interval"] == 900
|
||||
assert written["zwave_sync_interval"] == 1200
|
||||
|
||||
|
||||
def _valid_oidc_settings(**overrides):
|
||||
values = {
|
||||
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "client-secret",
|
||||
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
|
||||
"cors_origins": ["https://homelable.example"],
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_complete_client_configuration():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
|
||||
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_openid_scope():
|
||||
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
|
||||
_valid_oidc_settings(oidc_scopes="profile email")
|
||||
|
||||
|
||||
def test_oidc_mode_requires_strong_session_secret():
|
||||
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
||||
_valid_oidc_settings(secret_key="too-short")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_callback():
|
||||
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_discovery():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
|
||||
_valid_oidc_settings(
|
||||
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
|
||||
def test_oidc_insecure_dev_mode_allows_http_urls():
|
||||
configured = _valid_oidc_settings(
|
||||
oidc_cookie_secure=False,
|
||||
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
|
||||
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
|
||||
)
|
||||
assert configured.oidc_cookie_secure is False
|
||||
|
||||
|
||||
def test_oidc_mode_rejects_wildcard_cors():
|
||||
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
|
||||
_valid_oidc_settings(cors_origins=["*"])
|
||||
|
||||
|
||||
def test_valid_oidc_configuration_is_accepted():
|
||||
configured = _valid_oidc_settings()
|
||||
assert configured.auth_mode == "oidc"
|
||||
assert configured.oidc_cookie_secure is True
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
Homelable can delegate login to your own identity provider (IdP) instead of the
|
||||
built-in username/password. Sign-in uses the standard **Authorization Code flow
|
||||
with PKCE**: the browser is redirected to your IdP, and on return Homelable
|
||||
issues its own short-lived session cookie. Provider access/ID tokens are
|
||||
exchanged server-side and **never** reach the browser.
|
||||
|
||||
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
|
||||
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
|
||||
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
|
||||
|
||||
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
|
||||
> in the no-backend standalone/demo build, which has no login at all.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
|
||||
login screen shows **Sign in with OpenID Connect**.
|
||||
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
|
||||
and a PKCE `code_challenge` (S256).
|
||||
3. The IdP authenticates the user and redirects back to
|
||||
`GET /api/v1/auth/oidc/callback`.
|
||||
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
|
||||
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
|
||||
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
|
||||
cookie. The browser never sees the provider tokens.
|
||||
|
||||
The display name shown in the app is the first present of
|
||||
`preferred_username` → `name` → `email` → `sub`.
|
||||
|
||||
Local Bearer authentication and the MCP service key keep working unchanged, so
|
||||
the API and the MCP server are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A reachable **OpenID Connect** provider with a discovery document
|
||||
(`.well-known/openid-configuration`).
|
||||
2. Homelable served over **HTTPS** in production (required for the secure
|
||||
session cookie).
|
||||
3. A **confidential** OIDC client (client id **and** secret) registered at the
|
||||
IdP, with Homelable's callback URL allow-listed as a redirect URI.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `.env` (see `.env.example`). Switching to OIDC is
|
||||
exclusive — it replaces local login. Existing installs stay on
|
||||
`AUTH_MODE=local` until you change this.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
|
||||
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
|
||||
# From your IdP:
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
|
||||
# Must EXACTLY match a redirect URI registered at the IdP.
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
```
|
||||
|
||||
Then restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Reference
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
|
||||
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
|
||||
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
|
||||
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
|
||||
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
|
||||
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
|
||||
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
|
||||
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
|
||||
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
|
||||
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
|
||||
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
|
||||
|
||||
### Fail-closed validation
|
||||
|
||||
The backend refuses to start in OIDC mode unless the configuration is complete
|
||||
and safe. It errors if any of the following is true:
|
||||
|
||||
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
|
||||
`OIDC_REDIRECT_URI` is missing;
|
||||
- `SECRET_KEY` is shorter than 32 bytes;
|
||||
- `OIDC_SCOPES` does not contain `openid`;
|
||||
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
|
||||
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
|
||||
- `CORS_ORIGINS` contains `*`.
|
||||
|
||||
---
|
||||
|
||||
## Provider setup
|
||||
|
||||
The callback URL to register everywhere is:
|
||||
|
||||
```
|
||||
https://<your-host>/api/v1/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Authentik
|
||||
|
||||
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
|
||||
2. Client type **Confidential**; add the redirect URI above.
|
||||
3. Note the client id/secret and the discovery URL:
|
||||
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
|
||||
4. Create an **Application** bound to that provider.
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
|
||||
authentication ON** (confidential).
|
||||
2. Set **Valid redirect URIs** to the callback URL.
|
||||
3. Discovery URL:
|
||||
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
|
||||
4. Copy the secret from the **Credentials** tab.
|
||||
|
||||
### Authelia
|
||||
|
||||
1. Add Homelable to the `identity_providers.oidc.clients` list with a
|
||||
`client_secret`, the redirect URI, and scopes `openid profile email`.
|
||||
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
|
||||
|
||||
### Google
|
||||
|
||||
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
|
||||
(type *Web application*).
|
||||
2. Add the callback URL under **Authorized redirect URIs** (Google requires
|
||||
HTTPS).
|
||||
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
|
||||
|
||||
Any other spec-compliant provider works the same way — point
|
||||
`OIDC_DISCOVERY_URL` at its discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
|
||||
**public** HTTPS URL your users reach — not the internal container address. Make
|
||||
sure the proxy:
|
||||
|
||||
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
|
||||
- passes the original `Host` and `X-Forwarded-Proto` headers;
|
||||
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
|
||||
|
||||
---
|
||||
|
||||
## Logout & sessions
|
||||
|
||||
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
|
||||
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
|
||||
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
|
||||
elapses. Keep the lifetime modest for sensitive deployments.
|
||||
- Logout is local to Homelable; it does not sign the user out at the IdP.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---|---|
|
||||
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
|
||||
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
|
||||
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
|
||||
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
|
||||
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
|
||||
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
|
||||
|
||||
---
|
||||
|
||||
## Reverting to local auth
|
||||
|
||||
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
|
||||
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
|
||||
ignored while in local mode.
|
||||
Generated
+515
-121
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -31,7 +31,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"shadcn": "^4.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -60,6 +59,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
|
||||
+16
-1
@@ -51,6 +51,7 @@ import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
|
||||
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
|
||||
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import { bootstrapAuth } from '@/auth/bootstrap'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
@@ -62,12 +63,19 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { isAuthenticated, isInitialized } = useAuthStore()
|
||||
const authBootstrapStarted = useRef(false)
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
useEffect(() => {
|
||||
if (STANDALONE || authBootstrapStarted.current) return
|
||||
authBootstrapStarted.current = true
|
||||
void bootstrapAuth()
|
||||
}, [])
|
||||
|
||||
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||
|
||||
@@ -922,6 +930,13 @@ export default function App() {
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
|
||||
if (!STANDALONE && !isInitialized) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-sm text-muted-foreground">
|
||||
Loading authentication…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ type Interceptor<T> = {
|
||||
}
|
||||
|
||||
interface MockInstance {
|
||||
defaults: { baseURL?: string }
|
||||
defaults: { baseURL?: string; withCredentials?: boolean }
|
||||
interceptors: {
|
||||
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: (cfg: { baseURL?: string }) => {
|
||||
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
|
||||
const inst: MockInstance = {
|
||||
defaults: { baseURL: cfg?.baseURL },
|
||||
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
|
||||
interceptors: {
|
||||
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||
@@ -54,7 +54,7 @@ describe('api/client', () => {
|
||||
const [api, publicApi] = instances
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
|
||||
api.get.mockClear()
|
||||
api.post.mockClear()
|
||||
api.patch.mockClear()
|
||||
@@ -66,6 +66,7 @@ describe('api/client', () => {
|
||||
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||
expect(api.defaults.withCredentials).toBe(true)
|
||||
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||
})
|
||||
|
||||
@@ -86,6 +87,18 @@ describe('api/client', () => {
|
||||
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
|
||||
useAuthStore.setState({ csrfToken: 'csrf-123' })
|
||||
const post = { method: 'post', headers: {} as Record<string, string> }
|
||||
const get = { method: 'get', headers: {} as Record<string, string> }
|
||||
|
||||
api.__req.fulfilled!(post)
|
||||
api.__req.fulfilled!(get)
|
||||
|
||||
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
|
||||
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('response interceptor passes through 2xx responses', () => {
|
||||
const r = { status: 200, data: { ok: true } }
|
||||
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||
@@ -125,6 +138,15 @@ describe('api/client', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||
})
|
||||
|
||||
it('authApi discovers auth publicly and manages the current session', () => {
|
||||
mod.authApi.config()
|
||||
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
|
||||
mod.authApi.me()
|
||||
expect(api.get).toHaveBeenCalledWith('/auth/me')
|
||||
mod.authApi.logout()
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthMode, AuthUser } from '@/stores/authStore'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
|
||||
const publicApi = axios.create({ baseURL: '/api/v1' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
const { token, csrfToken } = useAuthStore.getState()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
const method = config.method?.toUpperCase() ?? 'GET'
|
||||
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
config.headers['X-Homelable-CSRF'] = csrfToken
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -23,8 +29,11 @@ api.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ access_token: string }>('/auth/login', { username, password }),
|
||||
me: () => api.get<AuthUser>('/auth/me'),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
}
|
||||
|
||||
export const canvasApi = {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
config: vi.fn(),
|
||||
me: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/client'
|
||||
import { bootstrapAuth } from '../bootstrap'
|
||||
|
||||
describe('bootstrapAuth', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
})
|
||||
vi.mocked(authApi.config).mockReset()
|
||||
vi.mocked(authApi.me).mockReset()
|
||||
})
|
||||
|
||||
it('restores an OIDC cookie session without storing an IdP token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'user-1',
|
||||
display_name: 'Alice',
|
||||
auth_method: 'oidc',
|
||||
issuer: 'https://id.example/application/o/homelable/',
|
||||
csrf_token: 'csrf-123',
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(authApi.me).toHaveBeenCalledOnce()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.authMethod).toBe('oidc')
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.csrfToken).toBe('csrf-123')
|
||||
})
|
||||
|
||||
it('does not probe /auth/me for local mode without a persisted token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(authApi.me).not.toHaveBeenCalled()
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: 'local',
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates and retains a persisted local bearer token', async () => {
|
||||
useAuthStore.setState({ token: 'local-token' })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'admin',
|
||||
display_name: 'admin',
|
||||
auth_method: 'local',
|
||||
issuer: null,
|
||||
csrf_token: null,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: 'local-token',
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the backend rejects the session', async () => {
|
||||
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the auth mode unknown when discovery is unavailable', async () => {
|
||||
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/**
|
||||
* Discover the configured authentication mode and restore a valid session.
|
||||
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
|
||||
* sessions retain their existing bearer token until /auth/me validates it.
|
||||
*/
|
||||
export async function bootstrapAuth(): Promise<void> {
|
||||
const store = useAuthStore.getState()
|
||||
|
||||
try {
|
||||
const { data: config } = await authApi.config()
|
||||
store.setAuthConfig(config.mode, config.oidc_login_url)
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (config.mode === 'oidc' || token) {
|
||||
try {
|
||||
const { data: user } = await authApi.me()
|
||||
useAuthStore.getState().authenticate(user)
|
||||
} catch {
|
||||
// The response interceptor clears invalid credentials on 401. Also
|
||||
// clear them for malformed/unreachable session checks, failing closed.
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep authMode unknown when discovery is unreachable. The login screen
|
||||
// reports that state without guessing which authentication mode is active.
|
||||
} finally {
|
||||
useAuthStore.getState().finishInitialization()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Network, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const { authMode, oidcLoginUrl, login } = useAuthStore()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -42,62 +44,92 @@ export function LoginPage() {
|
||||
<div className="relative w-full max-w-sm px-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
|
||||
<Network size={28} className="text-[#00d4ff]" />
|
||||
</div>
|
||||
<Logo size={64} showText={false} />
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
<span className="text-[#e6edf3]">Home</span>
|
||||
<span className="text-[#00d4ff]">lable</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
{authMode === 'local' && (
|
||||
<>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authMode === 'oidc' && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue through the configured OpenID Connect provider.
|
||||
</p>
|
||||
<a
|
||||
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
|
||||
)}
|
||||
>
|
||||
Sign in with OpenID Connect
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
{authMode === null && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
|
||||
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LoginPage } from '../LoginPage'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
authMode: 'local',
|
||||
oidcLoginUrl: null,
|
||||
})
|
||||
vi.mocked(authApi.login).mockReset()
|
||||
})
|
||||
|
||||
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
|
||||
render(<LoginPage />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
expect(svg.classList.contains('lucide')).toBe(false)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
|
||||
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders only the provider redirect in OIDC mode', () => {
|
||||
useAuthStore.setState({
|
||||
authMode: 'oidc',
|
||||
oidcLoginUrl: '/api/v1/auth/oidc/login',
|
||||
})
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.queryByLabelText('Password')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
|
||||
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
|
||||
})
|
||||
|
||||
it('does not guess an auth mode when discovery failed', () => {
|
||||
useAuthStore.setState({ authMode: null })
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Security checks ──────────────────────────────────────────────────────
|
||||
|
||||
it('password field type is "password" — not rendered as plain text', () => {
|
||||
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
await act(async () => {
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
})
|
||||
})
|
||||
|
||||
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
|
||||
|
||||
@@ -44,9 +44,11 @@ vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
// Partial mock: node-type icon resolution is stubbed, but the brand/lucide
|
||||
// resolver stays real so service icons render as they do in the app.
|
||||
vi.mock('@/utils/nodeIcons', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/utils/nodeIcons')>()),
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
@@ -252,6 +254,27 @@ describe('BaseNode — services visibility toggle', () => {
|
||||
|
||||
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the brand icon of a service that has one', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'plex', port: 32400, protocol: 'tcp', icon: 'brand:plex' }],
|
||||
})
|
||||
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no service icon when the service has none', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { ServiceIcon } from '@/components/ui/ServiceIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
@@ -172,13 +173,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
{/* LEFT: service name */}
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
{/* LEFT: icon + service name */}
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<ServiceIcon iconKey={svc.icon} size={11} />
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* RIGHT: path + port */}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
|
||||
interface IconPickerPanelProps {
|
||||
/** Current icon key — a lucide registry key or `brand:<slug>`. */
|
||||
value?: string
|
||||
/** Called with the new key, or `undefined` when the current generic icon is toggled off. */
|
||||
onSelect: (key: string | undefined) => void
|
||||
/** Focus the generic-icon search field on mount. */
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Generic/Brand icon picker body. Used by NodeModal and ServiceModal so
|
||||
* both surfaces offer the same lucide registry + dashboard-icons brand catalog.
|
||||
*/
|
||||
export function IconPickerPanel({ value, onSelect, autoFocus }: IconPickerPanelProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<'generic' | 'brand'>(isBrandIconKey(value) ? 'brand' : 'generic')
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
active ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button type="button" role="tab" aria-selected={tab === 'generic'} onClick={() => setTab('generic')} className={tabClass(tab === 'generic')}>
|
||||
Generic
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'brand'} onClick={() => setTab('brand')} className={tabClass(tab === 'brand')}>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'brand' ? (
|
||||
<BrandIconPicker value={value} onSelect={onSelect} />
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const q = search.toLowerCase()
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat && (search === '' || e.label.toLowerCase().includes(q) || e.key.includes(q))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = value === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => onSelect(isSelected ? undefined : entry.key)}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
@@ -142,9 +142,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const merged = { ...DEFAULT_DATA, ...initial }
|
||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
@@ -298,85 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'generic'}
|
||||
onClick={() => setIconTab('generic')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Generic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'brand'}
|
||||
onClick={() => setIconTab('brand')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
{iconTab === 'brand' ? (
|
||||
<BrandIconPicker
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
<div className="col-span-2">
|
||||
<IconPickerPanel
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat &&
|
||||
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = form.custom_icon === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { ChevronDown, RotateCcw, Circle } from 'lucide-react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ICON_REGISTRY, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { EMPTY_SERVICE_FORM, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
|
||||
interface ServiceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: ServiceSubmitData) => void
|
||||
initial?: ServiceFormData
|
||||
title?: string
|
||||
confirmLabel?: string
|
||||
}
|
||||
|
||||
export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Service', confirmLabel = 'Add' }: ServiceModalProps) {
|
||||
const [form, setForm] = useState<ServiceFormData>(initial ?? EMPTY_SERVICE_FORM)
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [nameError, setNameError] = useState(false)
|
||||
|
||||
const set = <K extends keyof ServiceFormData>(key: K, value: ServiceFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const setPort = (value: string) => set('port', value.replace(/\D/g, '').slice(0, 5))
|
||||
|
||||
const clampPort = () => {
|
||||
if (!form.port) return
|
||||
const parsed = Number.parseInt(form.port, 10)
|
||||
set('port', Number.isFinite(parsed) ? String(Math.max(1, Math.min(65535, parsed))) : '')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const name = form.service_name.trim()
|
||||
if (!name) {
|
||||
setNameError(true)
|
||||
return
|
||||
}
|
||||
const trimmedPort = form.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
|
||||
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const path = form.path.trim()
|
||||
onSubmit({
|
||||
service_name: name,
|
||||
protocol: form.protocol,
|
||||
port,
|
||||
path: path || undefined,
|
||||
icon: form.icon,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const iconPreview = () => {
|
||||
if (isBrandIconKey(form.icon)) {
|
||||
const slug = brandIconSlug(form.icon!)
|
||||
return (
|
||||
<>
|
||||
<img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} />
|
||||
<span className="text-foreground truncate">{slug}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const entry = ICON_REGISTRY.find((e) => e.key === form.icon)
|
||||
if (entry) {
|
||||
return (
|
||||
<>
|
||||
{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}
|
||||
<span className="text-foreground truncate">{entry.label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A key we can't resolve (e.g. a legacy scanner signature name) is still
|
||||
// shown verbatim so saving doesn't look like it silently dropped it.
|
||||
return (
|
||||
<>
|
||||
{createElement(Circle, { size: 13, className: 'text-muted-foreground shrink-0' })}
|
||||
<span className="text-muted-foreground truncate">{form.icon ?? 'None'}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||
{/* Name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Name *</Label>
|
||||
<Input
|
||||
value={form.service_name}
|
||||
onChange={(e) => { set('service_name', e.target.value); if (nameError) setNameError(false) }}
|
||||
placeholder="Service name"
|
||||
autoFocus
|
||||
className={`bg-[#21262d] text-sm h-8 ${nameError ? 'border-[#f85149]' : 'border-[#30363d]'}`}
|
||||
/>
|
||||
{nameError && <span className="text-[10px] text-[#f85149]">Name is required</span>}
|
||||
</div>
|
||||
|
||||
{/* Port + protocol */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={clampPort}
|
||||
placeholder="Port"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Protocol</Label>
|
||||
<select
|
||||
value={form.protocol}
|
||||
aria-label="Protocol"
|
||||
onChange={(e) => set('protocol', e.target.value as 'tcp' | 'udp')}
|
||||
className={`bg-[#21262d] border border-[#30363d] text-sm h-8 px-2 text-foreground ${modalStyles['modal-radius']}`}
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Path</Label>
|
||||
<Input
|
||||
value={form.path}
|
||||
onChange={(e) => set('path', e.target.value)}
|
||||
placeholder="Path (/admin)"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||
{form.icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { set('icon', undefined); setIconPickerOpen(false) }}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIconPickerOpen((o) => !o)}
|
||||
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
|
||||
aria-label="Icon picker trigger"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">{iconPreview()}</span>
|
||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{iconPickerOpen && (
|
||||
<IconPickerPanel
|
||||
value={form.icon}
|
||||
onSelect={(key) => { set('icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { IconPickerPanel } from '../IconPickerPanel'
|
||||
|
||||
describe('IconPickerPanel', () => {
|
||||
it('opens on the Generic tab by default', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: 'Generic' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('opens on the Brand tab when the current value is a brand key', () => {
|
||||
render(<IconPickerPanel value="brand:plex" onSelect={vi.fn()} />)
|
||||
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('emits the lucide key when a generic icon is picked', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
expect(onSelect).toHaveBeenCalledWith('database')
|
||||
})
|
||||
|
||||
it('emits undefined when the already-selected generic icon is clicked again', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel value="database" onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('filters the generic grid by search text', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
|
||||
expect(screen.getByLabelText('Select icon Grafana / Kibana')).toBeDefined()
|
||||
expect(screen.queryByLabelText('Select icon Database (SQL/NoSQL)')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits a prefixed key when a brand icon is picked', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<IconPickerPanel onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
expect(onSelect).toHaveBeenCalledWith('brand:plex')
|
||||
})
|
||||
|
||||
it('keeps the generic search box out of the Brand tab', () => {
|
||||
render(<IconPickerPanel onSelect={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ServiceModal } from '../ServiceModal'
|
||||
import { serviceToForm } from '@/utils/serviceForm'
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof ServiceModal>> = {}) {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<ServiceModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onSubmit, onClose }
|
||||
}
|
||||
|
||||
const submit = (label = 'Add') =>
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
describe('ServiceModal', () => {
|
||||
describe('form submission', () => {
|
||||
it('submits name, port, protocol and path', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByLabelText('Protocol'), { target: { value: 'udp' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
submit()
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
service_name: 'nginx',
|
||||
protocol: 'udp',
|
||||
port: 80,
|
||||
path: '/admin',
|
||||
icon: undefined,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits without a port', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'health', port: undefined, path: undefined }))
|
||||
})
|
||||
|
||||
it('trims whitespace off the name and path', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' nginx ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: ' /admin ' } })
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ service_name: 'nginx', path: '/admin' }))
|
||||
})
|
||||
|
||||
it('blocks submission and flags the field when the name is blank', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: ' ' } })
|
||||
submit()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Name is required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('clears the name error once the user types', () => {
|
||||
setup()
|
||||
submit()
|
||||
expect(screen.getByText('Name is required')).toBeDefined()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'n' } })
|
||||
expect(screen.queryByText('Name is required')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips non-digits from the port field', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '8a0b80' } })
|
||||
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('8080')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range port on blur', () => {
|
||||
setup()
|
||||
const port = screen.getByPlaceholderText('Port')
|
||||
fireEvent.change(port, { target: { value: '99999' } })
|
||||
fireEvent.blur(port)
|
||||
expect((port as HTMLInputElement).value).toBe('65535')
|
||||
})
|
||||
|
||||
it('calls onClose without submitting on Cancel', () => {
|
||||
const { onSubmit, onClose } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
const initial = serviceToForm({ port: 80, protocol: 'tcp', service_name: 'nginx', path: '/admin', icon: 'brand:nginx' })
|
||||
|
||||
it('prefills the form from an existing service', () => {
|
||||
setup({ initial, title: 'Edit Service', confirmLabel: 'Save' })
|
||||
expect((screen.getByPlaceholderText('Service name') as HTMLInputElement).value).toBe('nginx')
|
||||
expect((screen.getByPlaceholderText('Port') as HTMLInputElement).value).toBe('80')
|
||||
expect((screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement).value).toBe('/admin')
|
||||
expect(screen.getByText('Edit Service')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the existing icon when nothing else changes', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:nginx' }))
|
||||
})
|
||||
|
||||
it('clears a port that the user emptied', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '' } })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ port: undefined }))
|
||||
})
|
||||
|
||||
it('clears a path that the user emptied', () => {
|
||||
const { onSubmit } = setup({ initial, confirmLabel: 'Save' })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '' } })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon picker', () => {
|
||||
it('shows "None" until an icon is picked', () => {
|
||||
setup()
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('None')
|
||||
})
|
||||
|
||||
it('shows an unresolvable legacy key verbatim rather than "None"', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }) })
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('bar-chart-2')
|
||||
})
|
||||
|
||||
it('keeps an unresolvable legacy key on save instead of dropping it', () => {
|
||||
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'grafana', icon: 'bar-chart-2' }), confirmLabel: 'Save' })
|
||||
submit('Save')
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'bar-chart-2' }))
|
||||
})
|
||||
|
||||
it('selects a generic lucide icon', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'db' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByLabelText('Select icon Database (SQL/NoSQL)'))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'database' }))
|
||||
})
|
||||
|
||||
it('selects a brand icon from the Brand tab', () => {
|
||||
const { onSubmit } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: 'brand:plex' }))
|
||||
})
|
||||
|
||||
it('opens on the Brand tab when the service already carries a brand icon', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
expect(screen.getByRole('tab', { name: 'Brand' }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('previews the selected brand icon on the trigger', () => {
|
||||
setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
expect(screen.getByLabelText('Icon picker trigger').textContent).toContain('plex')
|
||||
})
|
||||
|
||||
it('resets the icon back to none', () => {
|
||||
const { onSubmit } = setup({ initial: serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reset/ }))
|
||||
submit()
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ icon: undefined }))
|
||||
})
|
||||
|
||||
it('hides the Reset button when no icon is set', () => {
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: /Reset/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,14 +9,17 @@ import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { formatTimestamp } from '@/utils/timeFormat'
|
||||
import { ServiceModal } from '@/components/modals/ServiceModal'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { ServiceIcon } from '@/components/ui/ServiceIcon'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
|
||||
/** Open service editor: `index === null` means "add a new service". */
|
||||
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
|
||||
|
||||
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
@@ -25,10 +28,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||||
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [svcModal, setSvcModal] = useState<SvcModalState | null>(null)
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||
|
||||
@@ -89,8 +89,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
|
||||
// Normal single-node panel
|
||||
const addingService = addingForNode === node.id
|
||||
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
||||
const openSvcModal = svcModal?.nodeId === node.id ? svcModal : null
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
@@ -104,60 +103,46 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddService = () => {
|
||||
const trimmedPort = newSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!newSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
const handleSubmitService = (data: ServiceSubmitData) => {
|
||||
if (!openSvcModal) return
|
||||
snapshotHistory()
|
||||
const path = newSvc.path.trim()
|
||||
const svc: ServiceInfo = {
|
||||
...(port != null ? { port } : {}),
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
...(path ? { path } : {}),
|
||||
if (openSvcModal.index === null) {
|
||||
const svc: ServiceInfo = {
|
||||
...(data.port != null ? { port: data.port } : {}),
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
...(data.path ? { path: data.path } : {}),
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
return
|
||||
}
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
const updated = services.map((svc, i) =>
|
||||
i === openSvcModal.index
|
||||
? {
|
||||
...svc,
|
||||
protocol: data.protocol,
|
||||
service_name: data.service_name,
|
||||
port: data.port,
|
||||
path: data.path,
|
||||
icon: data.icon,
|
||||
}
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
snapshotHistory()
|
||||
const updated = services.filter((_, i) => i !== index)
|
||||
updateNode(node.id, { services: updated })
|
||||
if (editingIndex === index) setEditingFor(null)
|
||||
if (openSvcModal?.index === index) setSvcModal(null)
|
||||
}
|
||||
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
|
||||
setEditingFor({ nodeId: node.id, index })
|
||||
setAddingForNode(null)
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null) return
|
||||
const trimmedPort = editSvc.port.trim()
|
||||
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
|
||||
if (!editSvc.service_name.trim()) return
|
||||
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
|
||||
snapshotHistory()
|
||||
const path = editSvc.path.trim()
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex
|
||||
? {
|
||||
...svc,
|
||||
protocol: editSvc.protocol,
|
||||
service_name: editSvc.service_name.trim(),
|
||||
...(port != null ? { port } : { port: undefined }),
|
||||
...(path ? { path } : { path: undefined }),
|
||||
}
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
setSvcModal({ nodeId: node.id, index, form: serviceToForm(svc) })
|
||||
}
|
||||
|
||||
const handleReorderService = (from: number, to: number) => {
|
||||
@@ -351,17 +336,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||
<button onClick={() => setSvcModal({ nodeId: node.id, index: null })} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||
<Plus size={10} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
|
||||
{services.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{services.map((svc, i) =>
|
||||
editingIndex === i ? (
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
{services.map((svc, i) => (
|
||||
<ServiceBadge
|
||||
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||
svc={svc}
|
||||
@@ -381,13 +362,24 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||
{services.length === 0 && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||
</div>
|
||||
|
||||
{openSvcModal && (
|
||||
<ServiceModal
|
||||
key={`svc-${openSvcModal.index ?? 'new'}`}
|
||||
open
|
||||
onClose={() => setSvcModal(null)}
|
||||
onSubmit={handleSubmitService}
|
||||
initial={openSvcModal.form}
|
||||
title={openSvcModal.index === null ? 'Add Service' : 'Edit Service'}
|
||||
confirmLabel={openSvcModal.index === null ? 'Add' : 'Save'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.notes && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||||
@@ -697,55 +689,6 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const setPort = (value: string) => {
|
||||
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
|
||||
onChange({ ...form, port: digitsOnly })
|
||||
}
|
||||
|
||||
const clampPort = (value: string) => {
|
||||
if (!value) return ''
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return ''
|
||||
return String(Math.max(1, Math.min(65535, parsed)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
|
||||
placeholder="Port"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Property components ---
|
||||
|
||||
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
||||
@@ -926,6 +869,7 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<ServiceIcon iconKey={svc.icon} size={12} className="shrink-0" color={color} />
|
||||
|
||||
{url ? (
|
||||
<a
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { designsApi, mediaApi } from '@/api/client'
|
||||
import { authApi, designsApi, mediaApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||
@@ -47,6 +47,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
|
||||
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
logout()
|
||||
} catch {
|
||||
toast.error('Could not end the current session')
|
||||
}
|
||||
}, [logout])
|
||||
|
||||
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
|
||||
setOpenSeq((s) => s + 1)
|
||||
setDesignModal(m)
|
||||
@@ -294,7 +303,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
icon={LogOut}
|
||||
label="Logout"
|
||||
collapsed={collapsed}
|
||||
onClick={logout}
|
||||
onClick={() => void handleLogout()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -343,15 +343,58 @@ describe('DetailPanel', () => {
|
||||
})
|
||||
|
||||
describe('Services — add/remove', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
it('opens the Add Service modal when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Two "Add" buttons: first = properties, second = services
|
||||
const addButtons = screen.getAllByText('Add')
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(screen.getByText('Add Service')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders no service form until the modal is opened', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the icon chosen in the modal', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'plex' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plex' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'plex', icon: 'brand:plex' })
|
||||
})
|
||||
|
||||
it('closes the modal after adding', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
fireEvent.click(addHeaders[addHeaders.length - 1])
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
expect(screen.queryByPlaceholderText('Service name')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls updateNode with new service on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
@@ -372,7 +415,7 @@ describe('DetailPanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
|
||||
})
|
||||
@@ -473,12 +516,13 @@ describe('DetailPanel', () => {
|
||||
describe('Services — edit', () => {
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
|
||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||
it('opens the Edit Service modal pre-filled when pencil is clicked', () => {
|
||||
setupStore({ services: [{ ...svc, path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||
const editBtn = screen.getByTitle('Edit service')
|
||||
fireEvent.click(editBtn)
|
||||
expect(screen.getByText('Edit Service')).toBeDefined()
|
||||
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
|
||||
expect(nameInput.value).toBe('nginx')
|
||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||
@@ -512,6 +556,46 @@ describe('DetailPanel', () => {
|
||||
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
|
||||
})
|
||||
|
||||
it('keeps fields the modal does not own, like category', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [{ ...svc, category: 'web' }] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0].category).toBe('web')
|
||||
})
|
||||
|
||||
it('saves a brand icon picked while editing', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [svc] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
fireEvent.click(screen.getByLabelText('Icon picker trigger'))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Brand' }))
|
||||
fireEvent.change(screen.getByLabelText('Brand icon search'), { target: { value: 'nginx' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nginx' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode.mock.calls[0][1].services[0].icon).toBe('brand:nginx')
|
||||
})
|
||||
|
||||
it('cancels edit without updating', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
@@ -586,6 +670,25 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders a brand icon image when the service carries one', () => {
|
||||
setupStore({ services: [{ port: 32400, protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no icon image when the service has no icon', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no icon image for an unresolvable legacy icon key', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx', icon: 'bar-chart-2' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders path label when path is set', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
logout: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
@@ -272,10 +275,14 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when Logout is clicked', () => {
|
||||
it('ends the backend session before clearing local auth state', async () => {
|
||||
const { authApi } = await import('@/api/client')
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Logout'))
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(authApi.logout).toHaveBeenCalledOnce()
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
|
||||
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
|
||||
*/
|
||||
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
|
||||
// Colons are legal in a URL fragment but confuse some tooling — strip them.
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const bgGlow = `homelable-bg-glow-${uid}`;
|
||||
const nodeGlow = `homelable-node-glow-${uid}`;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<svg
|
||||
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-testid="logo-mark"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
|
||||
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<filter id={nodeGlow}>
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Background */}
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117" />
|
||||
<circle cx="32" cy="32" r="32" fill={`url(#${bgGlow})`} />
|
||||
|
||||
{/* House body */}
|
||||
<path
|
||||
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
|
||||
<circle cx="22" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="42" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="24" r="2" fill="#a855f7" />
|
||||
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
|
||||
|
||||
{/* Floor line (subtle) */}
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
|
||||
|
||||
{/* Door */}
|
||||
<rect
|
||||
x="27"
|
||||
y="40"
|
||||
width="10"
|
||||
height="13"
|
||||
rx="1.5"
|
||||
fill="#0d1117"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1"
|
||||
opacity="0.9"
|
||||
/>
|
||||
|
||||
{/* Network lines (drawn under nodes) */}
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
|
||||
{/* Center hub */}
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
|
||||
|
||||
{/* Peripheral nodes */}
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="font-bold tracking-tight"
|
||||
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<span style={{ color: '#00d4ff' }}>Home</span>
|
||||
<span style={{ color: '#ffffff' }}>lable</span>
|
||||
<span style={{ color: '#e6edf3' }}>Home</span>
|
||||
<span style={{ color: '#00d4ff' }}>lable</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createElement } from 'react'
|
||||
import { resolveCustomIcon } from '@/utils/nodeIcons'
|
||||
|
||||
interface ServiceIconProps {
|
||||
/** Icon key on the service — a lucide registry key or `brand:<slug>`. */
|
||||
iconKey?: string
|
||||
size?: number
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a service's icon, or nothing when the service has no icon (or carries
|
||||
* a key we can't resolve — e.g. a legacy scanner signature name).
|
||||
*/
|
||||
export function ServiceIcon({ iconKey, size = 12, className, color }: ServiceIconProps) {
|
||||
const resolved = resolveCustomIcon(iconKey)
|
||||
if (!resolved) return null
|
||||
if (resolved.kind === 'brand') {
|
||||
return (
|
||||
<img
|
||||
src={resolved.url}
|
||||
alt={resolved.slug}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return createElement(resolved.icon, { size, className, color })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Logo } from '../Logo'
|
||||
|
||||
describe('Logo', () => {
|
||||
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
|
||||
render(<Logo />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// Canonical house outline from docs/logo/icon.svg
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
|
||||
expect(svg.querySelectorAll('circle').length).toBe(7)
|
||||
})
|
||||
|
||||
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
|
||||
render(<Logo showText />)
|
||||
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
|
||||
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
|
||||
})
|
||||
|
||||
it('hides the wordmark when showText is false', () => {
|
||||
render(<Logo showText={false} />)
|
||||
expect(screen.queryByText('Home')).toBeNull()
|
||||
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scales the svg to the requested size', () => {
|
||||
render(<Logo size={64} />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
expect(svg.getAttribute('width')).toBe('64')
|
||||
expect(svg.getAttribute('height')).toBe('64')
|
||||
})
|
||||
|
||||
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
|
||||
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
|
||||
// defs ids would make one instance reference the other's gradient.
|
||||
render(<><Logo /><Logo /></>)
|
||||
const [first, second] = screen.getAllByTestId('logo-mark')
|
||||
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
|
||||
expect(idOf(first)).not.toBe(idOf(second))
|
||||
|
||||
// Each instance must point at its own gradient
|
||||
const gradientRef = (svg: HTMLElement) =>
|
||||
svg.querySelectorAll('circle')[1].getAttribute('fill')
|
||||
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
|
||||
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
|
||||
})
|
||||
|
||||
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
|
||||
render(<Logo />)
|
||||
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('forwards className to the wrapper', () => {
|
||||
const { container } = render(<Logo className="my-class" />)
|
||||
expect(container.firstElementChild).toHaveClass('my-class')
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ describe('useStatusPolling', () => {
|
||||
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
token: 'test-token',
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
|
||||
@@ -58,6 +59,7 @@ describe('useStatusPolling', () => {
|
||||
it('does not open WebSocket when not authenticated', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
authMethod: null,
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
renderHook(() => useStatusPolling())
|
||||
@@ -67,12 +69,28 @@ describe('useStatusPolling', () => {
|
||||
it('does not open WebSocket when token is missing', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
renderHook(() => useStatusPolling())
|
||||
expect(MockWebSocket.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the HttpOnly cookie and sends no credential in OIDC mode', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'oidc',
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
|
||||
renderHook(() => useStatusPolling())
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1)
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onopen?.()
|
||||
expect(ws.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connects to correct ws:// URL', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
expect(MockWebSocket.instances).toHaveLength(1)
|
||||
|
||||
@@ -25,10 +25,10 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
const { isAuthenticated, authMethod, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (STANDALONE || !isAuthenticated || !token) return
|
||||
if (STANDALONE || !isAuthenticated || (authMethod !== 'oidc' && !token)) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host // includes port when non-standard
|
||||
@@ -37,9 +37,13 @@ export function useStatusPolling() {
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
// Send token as first message (not in URL to avoid log/history exposure)
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token }))
|
||||
// Local mode sends the bearer token as the first message (not in the URL
|
||||
// where proxies may log it). OIDC mode is authenticated during the upgrade
|
||||
// with the backend's HttpOnly session cookie, so React sends no credential.
|
||||
if (token) {
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -71,5 +75,5 @@ export function useStatusPolling() {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
|
||||
}, [isAuthenticated, authMethod, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
sessionStorage.clear()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts unauthenticated', () => {
|
||||
@@ -18,6 +28,8 @@ describe('authStore', () => {
|
||||
const { token, isAuthenticated } = useAuthStore.getState()
|
||||
expect(token).toBe('my-jwt-token')
|
||||
expect(isAuthenticated).toBe(true)
|
||||
expect(useAuthStore.getState().authMethod).toBe('local')
|
||||
expect(useAuthStore.getState().isInitialized).toBe(true)
|
||||
})
|
||||
|
||||
it('logout clears token and isAuthenticated', () => {
|
||||
@@ -27,4 +39,36 @@ describe('authStore', () => {
|
||||
expect(token).toBeNull()
|
||||
expect(isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('stores OIDC identity and CSRF state without a browser-readable credential', () => {
|
||||
useAuthStore.getState().setAuthConfig('oidc', '/api/v1/auth/oidc/login')
|
||||
useAuthStore.getState().authenticate({
|
||||
subject: 'user-1',
|
||||
display_name: 'Alice',
|
||||
auth_method: 'oidc',
|
||||
issuer: 'https://id.example/application/o/homelable/',
|
||||
csrf_token: 'csrf-123',
|
||||
})
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.authMethod).toBe('oidc')
|
||||
expect(state.displayName).toBe('Alice')
|
||||
expect(state.csrfToken).toBe('csrf-123')
|
||||
expect(sessionStorage.getItem('homelable-auth') ?? '').not.toContain('csrf-123')
|
||||
})
|
||||
|
||||
it('keeps a validated local token when applying /auth/me identity', () => {
|
||||
useAuthStore.getState().login('local-token')
|
||||
useAuthStore.getState().authenticate({
|
||||
subject: 'admin',
|
||||
display_name: 'admin',
|
||||
auth_method: 'local',
|
||||
issuer: null,
|
||||
csrf_token: null,
|
||||
})
|
||||
|
||||
expect(useAuthStore.getState().token).toBe('local-token')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
|
||||
export type AuthMode = 'local' | 'oidc'
|
||||
export type AuthMethod = AuthMode
|
||||
|
||||
export interface AuthUser {
|
||||
subject: string
|
||||
display_name: string
|
||||
auth_method: AuthMethod
|
||||
issuer: string | null
|
||||
csrf_token: string | null
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
isInitialized: boolean
|
||||
authMode: AuthMode | null
|
||||
authMethod: AuthMethod | null
|
||||
oidcLoginUrl: string | null
|
||||
displayName: string | null
|
||||
csrfToken: string | null
|
||||
setAuthConfig: (mode: AuthMode, oidcLoginUrl: string | null) => void
|
||||
authenticate: (user: AuthUser) => void
|
||||
finishInitialization: () => void
|
||||
login: (token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
@@ -13,14 +33,56 @@ export const useAuthStore = create<AuthState>()(
|
||||
(set) => ({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
login: (token) => set({ token, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, isAuthenticated: false }),
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
setAuthConfig: (authMode, oidcLoginUrl) => set({ authMode, oidcLoginUrl }),
|
||||
authenticate: (user) => set((state) => ({
|
||||
token: user.auth_method === 'oidc' ? null : state.token,
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
authMethod: user.auth_method,
|
||||
displayName: user.display_name,
|
||||
csrfToken: user.csrf_token,
|
||||
})),
|
||||
finishInitialization: () => set({ isInitialized: true }),
|
||||
login: (token) => set({
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
authMethod: 'local',
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
}),
|
||||
logout: () => set({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
authMethod: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'homelable-auth',
|
||||
// sessionStorage: scoped to the tab, cleared on browser close.
|
||||
// Prevents XSS from other tabs stealing the token via localStorage.
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
// OIDC credentials stay in HttpOnly cookies. Its CSRF token is deliberately
|
||||
// memory-only and is recovered from /auth/me after every page load.
|
||||
partialize: (state) => ({ token: state.token }),
|
||||
// Ignore authentication flags written by older versions of the store. A
|
||||
// persisted local token is revalidated by the bootstrap request instead.
|
||||
merge: (persisted, current) => {
|
||||
const token = (persisted as { token?: unknown } | null)?.token
|
||||
return {
|
||||
...current,
|
||||
token: typeof token === 'string' ? token : null,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm'
|
||||
|
||||
describe('serviceToForm', () => {
|
||||
it('maps a service onto string form fields', () => {
|
||||
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '443',
|
||||
protocol: 'udp',
|
||||
path: '/x',
|
||||
icon: 'database',
|
||||
})
|
||||
})
|
||||
|
||||
it('blanks out absent port and path', () => {
|
||||
expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
path: '',
|
||||
icon: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a brand icon key intact', () => {
|
||||
expect(serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }).icon).toBe('brand:plex')
|
||||
})
|
||||
|
||||
it('starts from a blank tcp form', () => {
|
||||
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ServiceInfo } from '@/types'
|
||||
|
||||
/** String-based shape the service editor works with. */
|
||||
export interface ServiceFormData {
|
||||
service_name: string
|
||||
port: string
|
||||
protocol: 'tcp' | 'udp'
|
||||
path: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** Fields a submitted service form contributes. `undefined` clears the field. */
|
||||
export interface ServiceSubmitData {
|
||||
service_name: string
|
||||
protocol: 'tcp' | 'udp'
|
||||
port: number | undefined
|
||||
path: string | undefined
|
||||
icon: string | undefined
|
||||
}
|
||||
|
||||
export const EMPTY_SERVICE_FORM: ServiceFormData = {
|
||||
service_name: '',
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
path: '',
|
||||
}
|
||||
|
||||
/** Turn a stored service into the string-based form shape. */
|
||||
export function serviceToForm(svc: ServiceInfo): ServiceFormData {
|
||||
return {
|
||||
service_name: svc.service_name,
|
||||
port: svc.port != null ? String(svc.port) : '',
|
||||
protocol: svc.protocol,
|
||||
path: svc.path ?? '',
|
||||
icon: svc.icon,
|
||||
}
|
||||
}
|
||||
+48
-2
@@ -1,10 +1,27 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from .backend_client import backend
|
||||
|
||||
|
||||
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
|
||||
# Kept in sync manually with frontend/src/types/index.ts NodeType (device types only —
|
||||
# group/groupRect/text are canvas annotations created via dedicated UI actions, not create_node).
|
||||
# Sync is enforced automatically by test_node_types_in_sync_with_frontend (mcp/tests/test_node_types_sync.py).
|
||||
NODE_TYPES = [
|
||||
"isp", "router", "firewall", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap",
|
||||
"camera", "printer", "computer", "laptop", "mobile", "cpl", "docker_host", "docker_container",
|
||||
"generic", "zigbee_coordinator", "zigbee_router", "zigbee_enddevice",
|
||||
"zwave_coordinator", "zwave_router", "zwave_enddevice", "grid", "ups", "battery", "generator",
|
||||
"solar_panel", "inverter", "circuit_breaker", "contactor", "electrical_switch", "socket",
|
||||
"light", "meter", "transformer", "load",
|
||||
]
|
||||
|
||||
# Kept in sync manually with frontend/src/types/index.ts EdgeType.
|
||||
# Sync is enforced automatically by test_edge_types_in_sync_with_frontend (mcp/tests/test_edge_types_sync.py).
|
||||
EDGE_TYPES = [
|
||||
"ethernet", "wifi", "iot", "vlan", "virtual", "cluster", "fibre", "electrical",
|
||||
]
|
||||
|
||||
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
|
||||
# create_node and update_node both expose these so the MCP is symmetric with what the
|
||||
@@ -92,7 +109,7 @@ def _build_tools() -> list[Tool]:
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
|
||||
"type": {"type": "string", "enum": EDGE_TYPES, "default": "ethernet"},
|
||||
"label": {"type": "string"},
|
||||
**_DESIGN_ID_FIELD,
|
||||
},
|
||||
@@ -131,6 +148,17 @@ def _build_tools() -> list[Tool]:
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_node_summaries", description="List all nodes with only id/label/type/status — a lighter, lower-token alternative to list_nodes or get_canvas for when full node detail (ip, services, notes, hardware, properties, ...) isn't needed.", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="get_node", description="Get node(s) by id or by label. Provide exactly one of the two. Lookup by 'id' returns a single full node object. Lookup by 'label' is a case-insensitive substring match and returns a list of full node objects, since labels are not unique.", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Node id. Returns a single node object."},
|
||||
"label": {"type": "string", "description": "Case-insensitive substring match on label. Returns a list of matching node objects."},
|
||||
},
|
||||
}),
|
||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -213,6 +241,11 @@ def _slim_canvas(raw: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _slim_node_summary(n: dict) -> dict:
|
||||
"""Only the fields needed to identify a node and reference it in later calls."""
|
||||
return {"id": n.get("id"), "label": n.get("label"), "type": n.get("type"), "status": n.get("status")}
|
||||
|
||||
|
||||
async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "create_node":
|
||||
return await backend.post("/api/v1/nodes", args)
|
||||
@@ -250,6 +283,19 @@ async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "list_nodes":
|
||||
return await backend.get("/api/v1/nodes")
|
||||
|
||||
if name == "list_node_summaries":
|
||||
nodes = await backend.get("/api/v1/nodes")
|
||||
return [_slim_node_summary(n) for n in nodes]
|
||||
|
||||
if name == "get_node":
|
||||
node_id = args.get("id")
|
||||
label = args.get("label")
|
||||
if node_id:
|
||||
return await backend.get(f"/api/v1/nodes/{node_id}")
|
||||
if label:
|
||||
return await backend.get(f"/api/v1/nodes?label={quote(label)}")
|
||||
raise ValueError("get_node requires either 'id' or 'label'")
|
||||
|
||||
if name == "list_pending_devices":
|
||||
# Backend /scan/pending returns the whole inventory: approved rows stay
|
||||
# listed so the frontend can show a canvas-presence badge. This tool
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
mcp[cli]>=1.0
|
||||
# Upper bound is load-bearing: mcp 2.0 dropped the low-level `Server.list_resources`
|
||||
# / `Server.list_tools` decorator API that app/resources.py and app/tools.py use.
|
||||
# Removing the bound breaks both the tests and the Docker image at import time.
|
||||
mcp[cli]>=1.26,<2
|
||||
httpx>=0.27
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.0
|
||||
|
||||
# Test-only, kept here for the same reason backend/requirements.txt carries its
|
||||
# own: CI installs a single requirements file then runs pytest.
|
||||
# pytest-anyio provides the `anyio` marker and parametrises asyncio + trio.
|
||||
pytest>=9.0
|
||||
pytest-anyio>=0.0.0
|
||||
trio>=0.27
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.tools import EDGE_TYPES
|
||||
|
||||
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
|
||||
|
||||
|
||||
def _frontend_edge_types() -> set[str]:
|
||||
src = FRONTEND_TYPES_FILE.read_text()
|
||||
match = re.search(r"export type EdgeType =([^\n]+)", src)
|
||||
assert match, (
|
||||
"Could not locate the EdgeType union in frontend/src/types/index.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_edge_types_in_sync_with_frontend():
|
||||
frontend_types = _frontend_edge_types()
|
||||
mcp_types = set(EDGE_TYPES)
|
||||
assert mcp_types == frontend_types, (
|
||||
"mcp/app/tools.py EDGE_TYPES is out of sync with frontend/src/types/index.ts EdgeType.\n"
|
||||
f"Missing from EDGE_TYPES: {sorted(frontend_types - mcp_types)}\n"
|
||||
f"Extra in EDGE_TYPES (no longer an edge type?): {sorted(mcp_types - frontend_types)}"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.tools import NODE_TYPES
|
||||
|
||||
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
|
||||
|
||||
# Canvas annotations, not devices — created via dedicated UI actions (grouping a
|
||||
# selection, "Add Zone", "Add Text"), never through create_node/approve_device.
|
||||
ANNOTATION_TYPES = {"group", "groupRect", "text"}
|
||||
|
||||
|
||||
def _frontend_node_types() -> set[str]:
|
||||
src = FRONTEND_TYPES_FILE.read_text()
|
||||
match = re.search(r"export type NodeType =\n((?:\s*\|\s*'[^']+'\n)+)", src)
|
||||
assert match, (
|
||||
"Could not locate the NodeType union in frontend/src/types/index.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_node_types_in_sync_with_frontend():
|
||||
frontend_types = _frontend_node_types() - ANNOTATION_TYPES
|
||||
mcp_types = set(NODE_TYPES)
|
||||
assert mcp_types == frontend_types, (
|
||||
"mcp/app/tools.py NODE_TYPES is out of sync with frontend/src/types/index.ts NodeType.\n"
|
||||
f"Missing from NODE_TYPES: {sorted(frontend_types - mcp_types)}\n"
|
||||
f"Extra in NODE_TYPES (no longer a device type?): {sorted(mcp_types - frontend_types)}"
|
||||
)
|
||||
@@ -65,6 +65,16 @@ async def test_update_node_properties(mock_backend):
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("node_type", ["firewall", "docker_container", "ups", "camera", "zigbee_router"])
|
||||
async def test_create_node_accepts_previously_rejected_types(mock_backend, node_type):
|
||||
# These types were missing from the old, stale NODE_TYPES enum and were
|
||||
# rejected by the MCP schema even though the backend always accepted them.
|
||||
args = {"type": node_type, "label": "Device"}
|
||||
await _dispatch("create_node", dict(args))
|
||||
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_node(mock_backend):
|
||||
await _dispatch("delete_node", {"id": "42"})
|
||||
@@ -77,6 +87,16 @@ async def test_create_edge(mock_backend):
|
||||
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("edge_type", ["cluster", "fibre", "electrical"])
|
||||
async def test_create_edge_accepts_previously_rejected_types(mock_backend, edge_type):
|
||||
# These types were missing from the old, stale edge type enum and were
|
||||
# rejected by the MCP schema even though the backend always accepted them.
|
||||
args = {"source": "1", "target": "2", "type": edge_type}
|
||||
await _dispatch("create_edge", dict(args))
|
||||
mock_backend.post.assert_called_once_with("/api/v1/edges", args)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_node_with_design_id(mock_backend):
|
||||
# design_id is forwarded to the backend, which attaches the node to that canvas.
|
||||
@@ -205,6 +225,23 @@ def test_create_node_schema_exposes_full_node_fields():
|
||||
assert "enum" in props["type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ["create_node", "update_node", "approve_device"])
|
||||
def test_node_type_enum_excludes_canvas_annotations(tool_name):
|
||||
# group/groupRect/text are canvas annotations created via dedicated UI actions
|
||||
# (grouping a selection, "Add Zone", "Add Text") — not real devices, so they
|
||||
# must never be creatable/settable through the device-oriented node tools.
|
||||
type_enum = set(_tool_schema(tool_name)["type"]["enum"])
|
||||
assert type_enum.isdisjoint({"group", "groupRect", "text"})
|
||||
assert "firewall" in type_enum
|
||||
|
||||
|
||||
def test_create_edge_schema_exposes_full_edge_type_enum():
|
||||
props = _tool_schema("create_edge")
|
||||
type_enum = set(props["type"]["enum"])
|
||||
assert {"cluster", "fibre", "electrical"} <= type_enum
|
||||
assert props["type"]["default"] == "ethernet"
|
||||
|
||||
|
||||
def test_update_node_schema_exposes_full_node_fields():
|
||||
props = _tool_schema("update_node")
|
||||
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||
@@ -233,6 +270,55 @@ async def test_list_nodes(mock_backend):
|
||||
assert result == [{"id": "1", "label": "Freebox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_node_summaries(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[
|
||||
{
|
||||
"id": "1", "label": "Freebox", "type": "router", "status": "online",
|
||||
"ip": "192.168.1.1", "notes": "ISP box", "properties": [{"name": "rack", "value": "A1"}],
|
||||
},
|
||||
])
|
||||
result = await _dispatch("list_node_summaries", {})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes")
|
||||
assert result == [{"id": "1", "label": "Freebox", "type": "router", "status": "online"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_id(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={"id": "42", "label": "pve1", "type": "proxmox"})
|
||||
result = await _dispatch("get_node", {"id": "42"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
|
||||
assert result == {"id": "42", "label": "pve1", "type": "proxmox"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_label(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}])
|
||||
result = await _dispatch("get_node", {"label": "pve"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=pve")
|
||||
assert result == [{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_label_url_encodes(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[])
|
||||
await _dispatch("get_node", {"label": "living room ap"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=living%20room%20ap")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_id_takes_precedence_over_label(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={"id": "42"})
|
||||
await _dispatch("get_node", {"id": "42", "label": "pve"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_requires_id_or_label():
|
||||
with pytest.raises(ValueError, match="requires either"):
|
||||
await _dispatch("get_node", {})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_pending_devices(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])
|
||||
|
||||
Reference in New Issue
Block a user