Compare commits

...
17 Commits
Author SHA1 Message Date
Pouzor f07f43686e chore: bump version to 3.1.2
ha-relevant: no
2026-08-01 01:26:23 +02:00
Pouzor 3bde741fd2 chore(deps): clear the seven open dependabot alerts
Lockfile only — every advisory is transitive and no direct dependency range
had to move:

- postcss 8.5.14 -> 8.5.25 (high, GHSA-r28c-9q8g-f849, path traversal in
  source-map auto-loading), pulled in by vite and shadcn
- fast-uri 3.1.2 -> 3.1.5 (two high advisories), via shadcn's ajv
- hono 4.12.25 -> 4.12.33 and @hono/node-server 1.19.14 -> 2.0.12 (three
  moderate advisories), via shadcn's @modelcontextprotocol/sdk, itself bumped
  1.29.0 -> 1.30.0

`npm audit` now reports 0 vulnerabilities. Build, lint and the full frontend
suite (1542 tests) pass unchanged.

ha-relevant: no
2026-08-01 01:14:21 +02:00
NebeszandPouzor - Rémy Jardient e8ea6857bc feat(mcp): add lightweight node lookup tools
Add a label substring filter to GET /api/v1/nodes, plus MCP tools
list_node_summaries (id/label/type/status only) and get_node
(by id -> single object, by label -> array) so an LLM client can look
up nodes without paying the token cost of the full node payload.
2026-07-30 01:57:34 +02:00
Pouzor - Rémy JardientandGitHub 42774fc138 Merge pull request #311 from Nebesz/fix/mcp-node-type-enum
fix: sync MCP node type enum with frontend NodeType
2026-07-30 01:37:33 +02:00
Pouzor 8f273e0201 fix: sync MCP edge type enum with frontend and gate both enums in CI
The node type fix left the same stale-enum bug in place for edges:
create_edge only advertised ethernet/wifi/iot/vlan/virtual, while the
frontend EdgeType union also defines cluster, fibre and electrical.
The backend's EdgeBase.type is a plain str, so those three were always
accepted server-side — only the MCP schema rejected them.

- Extract EDGE_TYPES in mcp/app/tools.py and use it for create_edge.
- Add mcp/tests/test_edge_types_sync.py, mirroring the NodeType guard:
  parses EdgeType out of frontend/src/types/index.ts and asserts set
  equality with EDGE_TYPES.
- Add create_edge cases for the three previously-rejected types plus a
  schema test pinning the enum contents and the ethernet default.
- Add an `mcp` job to quality.yml. Both sync guards were unreachable
  from CI before: quality.yml only ran frontend and backend, so the
  enums could drift again with nothing to catch it. The job runs from a
  full checkout because the guards read the frontend sources, which are
  not in the mcp Docker build context.
- Pin mcp[cli]>=1.26,<2. The bound is load-bearing: SDK 2.0 dropped the
  low-level Server.list_resources/list_tools decorator API that
  app/resources.py and app/tools.py are built on, so an unbounded spec
  breaks both the tests and the Docker image at import time.
- Move pytest/pytest-anyio/trio into mcp/requirements.txt, matching how
  backend/requirements.txt carries its own test deps. trio is required
  because pytest-anyio parametrises every async test over both backends.

Tested: 114 passed in mcp/, both in the existing venv and in a clean
Python 3.13 venv installed the way the new CI job does.

ha-relevant: no
2026-07-30 01:05:48 +02:00
Pouzor - Rémy JardientandGitHub 3c8e463dd9 Merge pull request #313 from Pouzor/feat/service-brand-icons
feat: brand icon picker for services, in a dedicated add/edit modal
2026-07-28 22:12:27 +02:00
Pouzor c4017c7634 feat(services): brand icon picker and dedicated add/edit modal
Services can now carry a brand logo (homarr dashboard-icons) or a generic
lucide icon, the same way nodes already could — closes the request in
discussion #299 where services stand in for docker images.

The add/edit form moves out of the right-hand DetailPanel into a proper
ServiceModal, which gives the icon picker room to breathe and matches how
nodes are edited.

- ServiceModal: name, port, protocol, path, icon; validates the name and
  clamps the port, and spreads over the original service on save so fields
  it doesn't own (category) survive an edit
- IconPickerPanel: Generic/Brand picker extracted from NodeModal so both
  surfaces share one implementation
- ServiceIcon: renders a lucide or brand:<slug> icon in the DetailPanel
  badge and on the canvas node, and nothing when the key can't be resolved
- Legacy scanner signature keys (bar-chart-2) still don't resolve to an
  icon, but the modal shows them verbatim and preserves them on save

ServiceInfo.icon already existed and the backend stores services as
list[Any], so no schema or migration change.

ha-relevant: yes
2026-07-28 22:00:45 +02:00
Nebesz a88688170a fix: sync MCP node type enum with frontend NodeType
The hardcoded NODE_TYPES list in mcp/app/tools.py only had 11 entries,
causing create_node/update_node/approve_device to reject valid device
types (firewall, docker_container, ups, etc.) that the backend and
frontend already support. Expanded the list to match
frontend/src/types/index.ts's NodeType union, excluding group/groupRect/text
which are canvas annotations, not devices. Added a test that parses the
frontend union and fails if the two lists drift apart again.
2026-07-28 18:17:52 +02:00
Pouzor - Rémy JardientandGitHub aaccb50b00 Merge pull request #306 from autogpt-elendir/feat/oidc-auth
feat(auth): add generic OIDC browser sessions
2026-07-28 00:29:41 +02:00
Pouzor 34758c9850 fix(ui): use official Homelable logo on login page
The login page rendered lucide's generic Network glyph instead of the
brand mark. Use the Logo component there, and realign Logo with the
canonical assets in docs/logo:

- match icon.svg geometry exactly (bg glow gradient, node glow filter)
- fix inverted wordmark colors (Home #e6edf3 / lable #00d4ff)
- scope gradient/filter ids per instance so the Sidebar and Toolbar
  logos don't share defs ids

ha-relevant: maybe
2026-07-25 00:02:39 +02:00
ypso 4d2284dc37 fix(auth): harden OIDC callback validation 2026-07-24 11:12:57 +02:00
Pouzor 9f974f2fcf docs(auth): document OIDC setup and configuration
- FEATURES.md: add "Authentication (Local / OpenID Connect)" section + TOC
- docs/oidc-auth.md: flow overview, env var reference, fail-closed validation
  rules, provider setup (Authentik/Keycloak/Authelia/Google), reverse-proxy
  notes, session/logout caveats, troubleshooting, revert-to-local

ha-relevant: no
2026-07-21 23:35:50 +02:00
Pouzor 37ad9c6933 fix(ci): scope dependency-audit npm audit to runtime deps
The dependency-audit job failed on vulnerabilities from `shadcn` and its
transitive tree (hono, fast-uri, body-parser, @modelcontextprotocol/sdk).
`shadcn` is a build-time scaffolding CLI plus a CSS `@import`, never shipped
in the browser bundle, so it belongs in devDependencies.

- move `shadcn` to devDependencies (regenerated lockfile: dev flags only,
  no version changes)
- run `npm audit` with `--omit=dev`, matching the runtime-only intent of the
  pip-audit step

npm audit --omit=dev --audit-level=high now reports 0 vulnerabilities; the
production build still resolves the shadcn CSS import.

ha-relevant: no
2026-07-21 23:34:34 +02:00
ypso 99e7b9a343 ci(security): enforce unsuppressed Python audit 2026-07-21 00:37:32 +02:00
ypso f061db645b chore(deps): refresh vulnerable frontend packages 2026-07-21 00:36:06 +02:00
ypso fd96b7d6ae feat(auth): add OIDC browser session flow 2026-07-21 00:36:02 +02:00
ypso 0e95d99868 feat(auth): add generic OIDC backend sessions 2026-07-21 00:35:57 +02:00
53 changed files with 3257 additions and 459 deletions
+20 -2
View File
@@ -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"]
+17
View File
@@ -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
+5 -4
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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.
---
+1 -1
View File
@@ -1 +1 @@
3.1.1
3.1.2
+45 -10
View File
@@ -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
+97 -2
View File
@@ -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
+10 -4
View File
@@ -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)
+28 -12
View File
@@ -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:
+43 -2
View File
@@ -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
+181 -3
View File
@@ -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
View File
@@ -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"])
+3 -1
View File
@@ -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
View File
@@ -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()
+40
View File
@@ -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
+61
View File
@@ -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
+192
View File
@@ -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.
+515 -121
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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 (
+26 -4
View File
@@ -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({}))
+10 -1
View File
@@ -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,
})
})
})
+33
View File
@@ -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()
}
}
+83 -51
View File
@@ -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>
)
}
+6 -81
View File
@@ -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()
})
})
})
+51 -107
View File
@@ -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
+11 -2
View File
@@ -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()
})
})
})
+64 -12
View File
@@ -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)
+10 -6
View File
@@ -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')
})
})
+64 -2
View File
@@ -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: '' })
})
})
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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)}"
)
+30
View File
@@ -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)}"
)
+86
View File
@@ -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"}])