Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07f43686e | ||
|
|
3bde741fd2 | ||
|
|
e8ea6857bc | ||
|
|
42774fc138 | ||
|
|
8f273e0201 | ||
|
|
3c8e463dd9 | ||
|
|
c4017c7634 | ||
|
|
a88688170a | ||
|
|
aaccb50b00 | ||
|
|
34758c9850 | ||
|
|
4d2284dc37 | ||
|
|
9f974f2fcf | ||
|
|
37ad9c6933 | ||
|
|
99e7b9a343 | ||
|
|
f061db645b | ||
|
|
fd96b7d6ae | ||
|
|
0e95d99868 | ||
|
|
d9f3b4c18a | ||
|
|
80ebbca804 | ||
|
|
959cd4a3ae | ||
|
|
084a8e23e7 | ||
|
|
f5dd46a729 | ||
|
|
121873f41c | ||
|
|
16d6348cec | ||
|
|
28fc4d8a7f | ||
|
|
5726289b18 | ||
|
|
e85dab8cfc | ||
|
|
deccff15cf | ||
|
|
a1bf54c417 | ||
|
|
8c56921375 | ||
|
|
bf8551015c | ||
|
|
3f030959be |
+20
-2
@@ -1,4 +1,5 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
SQLITE_PATH=./data/homelab.db
|
||||
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
|
||||
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
# Auth — local mode is the backward-compatible default.
|
||||
AUTH_MODE=local
|
||||
# Local credentials: admin / admin by default.
|
||||
# ⚠️ Change before exposing on a network.
|
||||
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
# OpenID Connect (optional alternative to local auth).
|
||||
# Set AUTH_MODE=oidc and configure every value below. The callback must be
|
||||
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
|
||||
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
|
||||
# also requires SECRET_KEY to contain at least 32 bytes.
|
||||
# AUTH_MODE=oidc
|
||||
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=homelable
|
||||
# OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
# OIDC_COOKIE_SECURE=true
|
||||
# OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
|
||||
# Scanner — JSON array of CIDR ranges to scan
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
|
||||
@@ -60,3 +60,20 @@ jobs:
|
||||
- run: ruff check .
|
||||
- run: mypy app/
|
||||
- run: pytest
|
||||
|
||||
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
|
||||
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
|
||||
# test_edge_types_sync.py) — those tests need the frontend sources, so this
|
||||
# job runs from a full checkout rather than the mcp Docker context.
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest
|
||||
|
||||
@@ -31,11 +31,12 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: NPM Audit
|
||||
run: cd frontend && npm audit --audit-level=high
|
||||
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
|
||||
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
|
||||
# bundled or served, so its advisories must not fail release CI.
|
||||
run: cd frontend && npm audit --omit=dev --audit-level=high
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Pip audit
|
||||
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
|
||||
# python-jose forces ecdsa as a dependency with no fixed version available.
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt
|
||||
|
||||
@@ -5,6 +5,41 @@ 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
|
||||
|
||||
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
|
||||
- Getting Started walkthrough tour with first-run canvas detection. (#300)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Round-trip Show Port Numbers on YAML export/import. (#301)
|
||||
- Render error toasts on a red surface. (#300)
|
||||
|
||||
### Docs
|
||||
|
||||
- Rework README header with logo, badges, and nav links. (#300)
|
||||
|
||||
## [3.1.0] - 2026-07-18
|
||||
|
||||
### Features
|
||||
|
||||
+18
@@ -29,6 +29,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
15. [Gethomepage Widget](#15-gethomepage-widget-)
|
||||
16. [MCP Server (AI integration)](#16-mcp-server-)
|
||||
17. [Settings & Shortcuts](#17-settings--shortcuts)
|
||||
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
|
||||
|
||||
---
|
||||
|
||||
@@ -252,4 +253,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 18. Authentication (Local / OpenID Connect) 🔒
|
||||
|
||||
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
|
||||
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
|
||||
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
|
||||
|
||||
**Use (OIDC):**
|
||||
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
|
||||
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
|
||||
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
|
||||
|
||||
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
|
||||
|
||||
> OIDC is Full-mode only — the no-backend standalone build has no login.
|
||||
|
||||
---
|
||||
|
||||
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
|
||||
|
||||
+26
-2
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||
>
|
||||
Generate a new hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
|
||||
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
||||
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
|
||||
```
|
||||
|
||||
## Quick Start — Frontend only
|
||||
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
All configuration is done via `.env` (copied from `.env.example`):
|
||||
|
||||
```env
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
|
||||
# Auth — default: admin / admin
|
||||
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
```
|
||||
|
||||
### OpenID Connect (optional)
|
||||
|
||||
OIDC is an exclusive alternative to the local password. Existing installs stay
|
||||
in `AUTH_MODE=local` unless explicitly changed.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
```
|
||||
|
||||
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
|
||||
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
|
||||
backend uses Authorization Code with PKCE and keeps provider tokens out of the
|
||||
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
|
||||
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
|
||||
|
||||
All settings are also editable in-app via the **Scan Network** button.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
# Homelable
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&label=build" alt="Build status" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
<a href="#network-scanner">Network Scanner</a> ·
|
||||
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
|
||||
<a href="#proxmox-ve-import">Proxmox</a> ·
|
||||
<a href="#live-view-read-only-public-canvas">Live View</a> ·
|
||||
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
|
||||
</p>
|
||||
|
||||
## About
|
||||
|
||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||
|
||||
@@ -10,7 +40,6 @@ If you just like the design, you can only run the frontend and export your desig
|
||||
|
||||
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||
|
||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="250" height="55"/></a>
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
+45
-10
@@ -1,31 +1,66 @@
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
from app.core.security import decode_token, get_oidc_session
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
) -> AuthContext:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
return AuthContext(
|
||||
subject="__mcp_service__",
|
||||
display_name="MCP service",
|
||||
auth_method="mcp",
|
||||
)
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return username
|
||||
if credentials is not None:
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return AuthContext(
|
||||
subject=username,
|
||||
display_name=username,
|
||||
auth_method="local",
|
||||
)
|
||||
|
||||
# 3. OIDC session cookie (only active in exclusive OIDC mode)
|
||||
if settings.auth_mode == "oidc":
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is not None:
|
||||
return AuthContext(
|
||||
subject=oidc_session.subject,
|
||||
display_name=oidc_session.display_name,
|
||||
auth_method="oidc",
|
||||
issuer=oidc_session.issuer,
|
||||
csrf_token=oidc_session.csrf_token,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
|
||||
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
|
||||
return context.subject
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.api.deps import AuthContext, get_auth_context
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.core.security import (
|
||||
clear_oidc_session_cookie,
|
||||
create_access_token,
|
||||
create_oidc_session_token,
|
||||
get_oidc_client,
|
||||
set_oidc_session_cookie,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
mode: str
|
||||
oidc_login_url: str | None = None
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def _require_oidc_mode() -> None:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
|
||||
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config() -> AuthConfigResponse:
|
||||
return AuthConfigResponse(
|
||||
mode=settings.auth_mode,
|
||||
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest) -> TokenResponse:
|
||||
if settings.auth_mode != "local":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
|
||||
# Always run both checks to prevent timing-based username enumeration.
|
||||
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
||||
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
||||
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except (OAuthError, JoseError) as exc:
|
||||
logger.warning("OIDC callback rejected: %s", exc.error)
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not isinstance(userinfo, Mapping):
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
|
||||
try:
|
||||
session_token = create_oidc_session_token(userinfo)
|
||||
except ValueError:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
request.session.clear()
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_oidc_session_cookie(response, session_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
|
||||
return CurrentUserResponse(
|
||||
subject=context.subject,
|
||||
display_name=context.display_name,
|
||||
auth_method=context.auth_method,
|
||||
issuer=context.issuer,
|
||||
csrf_token=context.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
clear_oidc_session_cookie(response)
|
||||
return response
|
||||
|
||||
@@ -37,6 +37,9 @@ async def load_canvas(
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
# A CanvasState row exists only after a save (or explicit design create),
|
||||
# so its presence marks an intentional canvas vs. a never-touched one.
|
||||
initialized=state is not None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -55,9 +55,15 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
|
||||
|
||||
|
||||
@router.get("", response_model=list[NodeResponse])
|
||||
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
||||
result = await db.execute(select(Node))
|
||||
return list(result.scalars().all())
|
||||
async def list_nodes(
|
||||
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[Node]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
return list((await db.execute(query)).scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -3,7 +3,13 @@ import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
decode_oidc_session_token,
|
||||
decode_token,
|
||||
oidc_session_cookie_name,
|
||||
origin_is_allowed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
await websocket.accept()
|
||||
try:
|
||||
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
if not origin_is_allowed(websocket.headers.get("origin")):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
|
||||
if settings.auth_mode == "oidc":
|
||||
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
|
||||
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
else:
|
||||
# Local mode keeps the existing first-message bearer protocol.
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
_connections.append(websocket)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
|
||||
access_token_expire_minutes: int = 1440 # 24h
|
||||
|
||||
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
||||
auth_mode: Literal["local", "oidc"] = "local"
|
||||
auth_username: str = "admin"
|
||||
auth_password_hash: str = ""
|
||||
|
||||
# OpenID Connect — required only when AUTH_MODE=oidc.
|
||||
oidc_discovery_url: str = ""
|
||||
oidc_client_id: str = ""
|
||||
oidc_client_secret: str = ""
|
||||
oidc_redirect_uri: str = ""
|
||||
oidc_scopes: str = "openid profile email"
|
||||
oidc_cookie_secure: bool = True
|
||||
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
|
||||
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_password_hash(self) -> "Settings":
|
||||
def validate_auth_settings(self) -> "Settings":
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
|
||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||
)
|
||||
if self.auth_mode == "oidc":
|
||||
required = {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_CLIENT_ID": self.oidc_client_id,
|
||||
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}
|
||||
missing = [name for name, value in required.items() if not value]
|
||||
if missing:
|
||||
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
|
||||
if len(self.secret_key.encode("utf-8")) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
|
||||
if "openid" not in self.oidc_scopes.split():
|
||||
raise ValueError("OIDC_SCOPES must contain 'openid'")
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith(("https://", "http://")):
|
||||
raise ValueError(f"{name} must be an HTTP(S) URL")
|
||||
if self.oidc_cookie_secure:
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import hmac
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
||||
from fastapi import Request, Response, status
|
||||
from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OIDCSession:
|
||||
subject: str
|
||||
issuer: str
|
||||
display_name: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return str(sub) if sub is not None else None
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
|
||||
subject = userinfo.get("sub")
|
||||
issuer = userinfo.get("iss")
|
||||
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
|
||||
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
|
||||
|
||||
display_name = next(
|
||||
(
|
||||
value
|
||||
for value in (
|
||||
userinfo.get("preferred_username"),
|
||||
userinfo.get("name"),
|
||||
userinfo.get("email"),
|
||||
subject,
|
||||
)
|
||||
if isinstance(value, str) and value
|
||||
),
|
||||
subject,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"oidc_issuer": issuer,
|
||||
"name": display_name,
|
||||
"csrf": secrets.token_urlsafe(32),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
|
||||
"token_use": OIDC_SESSION_TOKEN_USE,
|
||||
}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_oidc_session_token(token: str) -> OIDCSession | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
issuer = payload.get("oidc_issuer")
|
||||
display_name = payload.get("name")
|
||||
csrf_token = payload.get("csrf")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
return None
|
||||
if not isinstance(issuer, str) or not issuer:
|
||||
return None
|
||||
if not isinstance(display_name, str) or not display_name:
|
||||
return None
|
||||
if not isinstance(csrf_token, str) or not csrf_token:
|
||||
return None
|
||||
return OIDCSession(
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
display_name=display_name,
|
||||
csrf_token=csrf_token,
|
||||
)
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def oidc_session_cookie_name() -> str:
|
||||
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
|
||||
|
||||
|
||||
def set_oidc_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
value=token,
|
||||
max_age=settings.oidc_session_expire_minutes * 60,
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_oidc_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def get_oidc_session(request: Request) -> OIDCSession | None:
|
||||
token = request.cookies.get(oidc_session_cookie_name())
|
||||
return decode_oidc_session_token(token) if token else None
|
||||
|
||||
|
||||
def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
# Explicit non-cookie credentials take precedence in get_current_user,
|
||||
# so their requests keep the existing CSRF-free API behavior.
|
||||
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
|
||||
return await call_next(request)
|
||||
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is None:
|
||||
return await call_next(request)
|
||||
|
||||
csrf_token = request.headers.get("X-Homelable-CSRF", "")
|
||||
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
|
||||
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF validation failed"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _build_oidc_client(
|
||||
discovery_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> StarletteOAuth2App:
|
||||
oauth = OAuth()
|
||||
client = oauth.register(
|
||||
name="oidc",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": scopes,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
if client is None:
|
||||
raise RuntimeError("Failed to register OIDC client")
|
||||
return client
|
||||
|
||||
|
||||
def get_oidc_client() -> StarletteOAuth2App:
|
||||
return _build_oidc_client(
|
||||
settings.oidc_discovery_url,
|
||||
settings.oidc_client_id,
|
||||
settings.oidc_client_secret,
|
||||
settings.oidc_scopes,
|
||||
)
|
||||
|
||||
+12
-1
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.routes import (
|
||||
auth,
|
||||
@@ -25,6 +26,7 @@ from app.api.routes import (
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
from app.core.security import OIDCCSRFMiddleware
|
||||
from app.db.database import init_db
|
||||
|
||||
|
||||
@@ -53,12 +55,21 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(OIDCCSRFMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
|
||||
max_age=settings.oidc_transaction_expire_seconds,
|
||||
same_site="lax",
|
||||
https_only=settings.oidc_cookie_secure,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
@@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel):
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
# True once this design's canvas has ever been persisted (a CanvasState row
|
||||
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
|
||||
# who intentionally cleared their canvas (keep it empty). False also for a
|
||||
# missing/uninitialized design.
|
||||
initialized: bool = False
|
||||
|
||||
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
|
||||
alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
PyJWT==2.13.0
|
||||
bcrypt==4.2.1
|
||||
authlib==1.7.2
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
|
||||
+288
-3
@@ -1,5 +1,56 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import ExpiredTokenError, JoseError
|
||||
from fastapi import WebSocketDisconnect
|
||||
from httpx import AsyncClient
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
class FakeOIDCClient:
|
||||
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
|
||||
self.token = token or {}
|
||||
self.error = error
|
||||
self.redirect_uri: str | None = None
|
||||
|
||||
async def authorize_redirect(self, _request, redirect_uri: str):
|
||||
self.redirect_uri = redirect_uri
|
||||
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
|
||||
|
||||
async def authorize_access_token(self, _request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_settings():
|
||||
from app.core.config import settings
|
||||
from app.core.security import _build_oidc_client
|
||||
|
||||
values = {
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "test-client-secret",
|
||||
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
|
||||
"oidc_scopes": "openid profile email",
|
||||
"oidc_cookie_secure": False,
|
||||
"oidc_session_expire_minutes": 60,
|
||||
"cors_origins": ["http://test"],
|
||||
}
|
||||
original = {name: getattr(settings, name) for name in values}
|
||||
for name, value in values.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
yield settings
|
||||
for name, value in original.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient):
|
||||
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_auth_config_defaults_to_local(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"mode": "local", "oidc_login_url": None}
|
||||
|
||||
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
|
||||
"""A JWT whose `exp` is in the past must be refused."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||
"""A token signed with a different key must not be accepted."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
|
||||
# --- OpenID Connect ---
|
||||
|
||||
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"mode": "oidc",
|
||||
"oidc_login_url": "/api/v1/auth/oidc/login",
|
||||
}
|
||||
|
||||
|
||||
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient()
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"].startswith("https://idp.example/")
|
||||
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
|
||||
|
||||
|
||||
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
|
||||
from app.core.security import get_oidc_client
|
||||
|
||||
oidc_client = get_oidc_client()
|
||||
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
|
||||
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
|
||||
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
|
||||
|
||||
query = parse_qs(urlparse(authorization["url"]).query)
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert "code_challenge" in query
|
||||
assert "code_verifier" not in query
|
||||
assert query["nonce"] == [authorization["nonce"]]
|
||||
assert authorization["code_verifier"]
|
||||
|
||||
|
||||
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
callback = await client.get("/api/v1/auth/oidc/callback")
|
||||
|
||||
assert callback.status_code == 303
|
||||
assert callback.headers["location"] == "/"
|
||||
cookie = callback.headers["set-cookie"]
|
||||
assert "homelable-session=" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
|
||||
me = await client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["subject"] == "user-123"
|
||||
assert me.json()["display_name"] == "alice"
|
||||
assert me.json()["auth_method"] == "oidc"
|
||||
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
|
||||
assert me.json()["csrf_token"]
|
||||
|
||||
protected = await client.get("/api/v1/nodes")
|
||||
assert protected.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
|
||||
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
|
||||
token = {} if userinfo is None else {"userinfo": userinfo}
|
||||
fake_client = FakeOIDCClient(token=token)
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "sensitive detail" not in res.text
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=ExpiredTokenError())
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "expired" not in res.text.lower()
|
||||
|
||||
|
||||
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
|
||||
from app.core.security import create_oidc_session_token
|
||||
|
||||
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
missing = await client.post("/api/v1/auth/logout")
|
||||
assert missing.status_code == 403
|
||||
wrong_origin = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
valid = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert valid.status_code == 204
|
||||
assert "Max-Age=0" in valid.headers["set-cookie"]
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
|
||||
# --- WebSocket authentication ---
|
||||
|
||||
def _wait_for_connection_count(expected: int) -> None:
|
||||
from app.api.routes import status as status_routes
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while len(status_routes._connections) != expected and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(status_routes._connections) == expected
|
||||
|
||||
|
||||
def test_local_websocket_keeps_first_message_bearer_flow():
|
||||
from app.core.security import create_access_token
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://localhost:3000"},
|
||||
) as websocket:
|
||||
websocket.send_json({"token": create_access_token("admin")})
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_websocket_rejects_untrusted_origin_before_auth():
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "https://evil.example"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
|
||||
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
|
||||
from app.main import app
|
||||
|
||||
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
test_client = TestClient(app)
|
||||
test_client.cookies.set(oidc_session_cookie_name(), session_token)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
):
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
@@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
||||
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||
|
||||
|
||||
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
||||
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
||||
# uses this to show the demo canvas only to genuinely new users.
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["initialized"] is False
|
||||
|
||||
|
||||
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
||||
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
||||
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
||||
# and must not get the demo re-seeded.
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["nodes"] == []
|
||||
assert data["initialized"] is True
|
||||
|
||||
|
||||
async def test_load_canvas_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/canvas")
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -363,3 +363,43 @@ async def test_create_node_explicit_zero_position_preserved(client: AsyncClient,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
|
||||
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
|
||||
for label in ("A", "B", "C"):
|
||||
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 3
|
||||
|
||||
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
|
||||
assert written["zigbee_sync_enabled"] is True
|
||||
assert written["zigbee_sync_interval"] == 900
|
||||
assert written["zwave_sync_interval"] == 1200
|
||||
|
||||
|
||||
def _valid_oidc_settings(**overrides):
|
||||
values = {
|
||||
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "client-secret",
|
||||
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
|
||||
"cors_origins": ["https://homelable.example"],
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_complete_client_configuration():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
|
||||
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_openid_scope():
|
||||
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
|
||||
_valid_oidc_settings(oidc_scopes="profile email")
|
||||
|
||||
|
||||
def test_oidc_mode_requires_strong_session_secret():
|
||||
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
||||
_valid_oidc_settings(secret_key="too-short")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_callback():
|
||||
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_discovery():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
|
||||
_valid_oidc_settings(
|
||||
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
|
||||
def test_oidc_insecure_dev_mode_allows_http_urls():
|
||||
configured = _valid_oidc_settings(
|
||||
oidc_cookie_secure=False,
|
||||
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
|
||||
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
|
||||
)
|
||||
assert configured.oidc_cookie_secure is False
|
||||
|
||||
|
||||
def test_oidc_mode_rejects_wildcard_cors():
|
||||
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
|
||||
_valid_oidc_settings(cors_origins=["*"])
|
||||
|
||||
|
||||
def test_valid_oidc_configuration_is_accepted():
|
||||
configured = _valid_oidc_settings()
|
||||
assert configured.auth_mode == "oidc"
|
||||
assert configured.oidc_cookie_secure is True
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 82" width="64" height="82" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,192 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
Homelable can delegate login to your own identity provider (IdP) instead of the
|
||||
built-in username/password. Sign-in uses the standard **Authorization Code flow
|
||||
with PKCE**: the browser is redirected to your IdP, and on return Homelable
|
||||
issues its own short-lived session cookie. Provider access/ID tokens are
|
||||
exchanged server-side and **never** reach the browser.
|
||||
|
||||
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
|
||||
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
|
||||
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
|
||||
|
||||
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
|
||||
> in the no-backend standalone/demo build, which has no login at all.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
|
||||
login screen shows **Sign in with OpenID Connect**.
|
||||
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
|
||||
and a PKCE `code_challenge` (S256).
|
||||
3. The IdP authenticates the user and redirects back to
|
||||
`GET /api/v1/auth/oidc/callback`.
|
||||
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
|
||||
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
|
||||
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
|
||||
cookie. The browser never sees the provider tokens.
|
||||
|
||||
The display name shown in the app is the first present of
|
||||
`preferred_username` → `name` → `email` → `sub`.
|
||||
|
||||
Local Bearer authentication and the MCP service key keep working unchanged, so
|
||||
the API and the MCP server are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A reachable **OpenID Connect** provider with a discovery document
|
||||
(`.well-known/openid-configuration`).
|
||||
2. Homelable served over **HTTPS** in production (required for the secure
|
||||
session cookie).
|
||||
3. A **confidential** OIDC client (client id **and** secret) registered at the
|
||||
IdP, with Homelable's callback URL allow-listed as a redirect URI.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `.env` (see `.env.example`). Switching to OIDC is
|
||||
exclusive — it replaces local login. Existing installs stay on
|
||||
`AUTH_MODE=local` until you change this.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
|
||||
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
|
||||
# From your IdP:
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
|
||||
# Must EXACTLY match a redirect URI registered at the IdP.
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
```
|
||||
|
||||
Then restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Reference
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
|
||||
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
|
||||
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
|
||||
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
|
||||
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
|
||||
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
|
||||
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
|
||||
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
|
||||
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
|
||||
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
|
||||
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
|
||||
|
||||
### Fail-closed validation
|
||||
|
||||
The backend refuses to start in OIDC mode unless the configuration is complete
|
||||
and safe. It errors if any of the following is true:
|
||||
|
||||
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
|
||||
`OIDC_REDIRECT_URI` is missing;
|
||||
- `SECRET_KEY` is shorter than 32 bytes;
|
||||
- `OIDC_SCOPES` does not contain `openid`;
|
||||
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
|
||||
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
|
||||
- `CORS_ORIGINS` contains `*`.
|
||||
|
||||
---
|
||||
|
||||
## Provider setup
|
||||
|
||||
The callback URL to register everywhere is:
|
||||
|
||||
```
|
||||
https://<your-host>/api/v1/auth/oidc/callback
|
||||
```
|
||||
|
||||
### Authentik
|
||||
|
||||
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
|
||||
2. Client type **Confidential**; add the redirect URI above.
|
||||
3. Note the client id/secret and the discovery URL:
|
||||
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
|
||||
4. Create an **Application** bound to that provider.
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
|
||||
authentication ON** (confidential).
|
||||
2. Set **Valid redirect URIs** to the callback URL.
|
||||
3. Discovery URL:
|
||||
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
|
||||
4. Copy the secret from the **Credentials** tab.
|
||||
|
||||
### Authelia
|
||||
|
||||
1. Add Homelable to the `identity_providers.oidc.clients` list with a
|
||||
`client_secret`, the redirect URI, and scopes `openid profile email`.
|
||||
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
|
||||
|
||||
### Google
|
||||
|
||||
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
|
||||
(type *Web application*).
|
||||
2. Add the callback URL under **Authorized redirect URIs** (Google requires
|
||||
HTTPS).
|
||||
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
|
||||
|
||||
Any other spec-compliant provider works the same way — point
|
||||
`OIDC_DISCOVERY_URL` at its discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
|
||||
**public** HTTPS URL your users reach — not the internal container address. Make
|
||||
sure the proxy:
|
||||
|
||||
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
|
||||
- passes the original `Host` and `X-Forwarded-Proto` headers;
|
||||
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
|
||||
|
||||
---
|
||||
|
||||
## Logout & sessions
|
||||
|
||||
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
|
||||
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
|
||||
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
|
||||
elapses. Keep the lifetime modest for sensitive deployments.
|
||||
- Logout is local to Homelable; it does not sign the user out at the IdP.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---|---|
|
||||
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
|
||||
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
|
||||
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
|
||||
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
|
||||
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
|
||||
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
|
||||
|
||||
---
|
||||
|
||||
## Reverting to local auth
|
||||
|
||||
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
|
||||
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
|
||||
ignored while in local mode.
|
||||
Generated
+515
-121
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.1.0",
|
||||
"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",
|
||||
|
||||
+174
-34
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
@@ -45,7 +45,13 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
|
||||
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
|
||||
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'
|
||||
@@ -57,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), [])
|
||||
|
||||
@@ -74,6 +87,18 @@ export default function App() {
|
||||
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
|
||||
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
|
||||
|
||||
// True while the last canvas load failed (backend down/error). Drives the error
|
||||
// banner and keeps us from masking a real failure with the demo canvas.
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
// True when the loaded canvas is the demo (a brand-new user). Reserved as the
|
||||
// entry signal for the upcoming Getting Started walkthrough.
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
|
||||
// Getting Started tour: when true, the corresponding modal renders injected demo
|
||||
// data instead of hitting the backend.
|
||||
const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
|
||||
const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
@@ -151,55 +176,83 @@ export default function App() {
|
||||
})
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
let res
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||
// across canvases when switching designs.
|
||||
setFloorMap(savedFloorMap ?? null)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
res = await canvasApi.load(designId)
|
||||
} catch {
|
||||
// Backend down / errored. Surface it and STOP — never fall back to the demo
|
||||
// canvas, which would hide the real error and look like a fresh account.
|
||||
// Leave the on-screen canvas and provenance untouched so an autosave can't
|
||||
// clobber real data with an empty canvas.
|
||||
setLoadError(true)
|
||||
toast.error('Could not load canvas — backend not responding')
|
||||
return
|
||||
}
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true)
|
||||
if (mode === 'real') {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||
// across canvases when switching designs.
|
||||
setFloorMap(savedFloorMap ?? null)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else if (mode === 'empty') {
|
||||
// Initialized but no nodes: the user cleared this canvas on purpose — respect
|
||||
// it and keep it empty instead of re-seeding the demo.
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
setFloorMap(null)
|
||||
loadCanvas([], [])
|
||||
} else {
|
||||
// Brand-new, never-saved canvas: seed the demo.
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
} finally {
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId ?? null)
|
||||
}
|
||||
setLoadError(false)
|
||||
setIsNewUser(isNewUserCanvas(mode))
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId ?? null)
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
||||
// localStorage, falling back to the demo canvas when it has never been saved.
|
||||
const loadStandaloneCanvas = useCallback((designId: string) => {
|
||||
const saved = standaloneStorage.loadCanvas(designId)
|
||||
if (saved && saved.nodes.length > 0) {
|
||||
// A stored entry (even with zero nodes) means the user has saved this canvas,
|
||||
// so treat it as initialized and don't re-seed the demo on top of a canvas
|
||||
// they deliberately cleared.
|
||||
const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null)
|
||||
if (mode === 'real' && saved) {
|
||||
if (saved.theme_id) setTheme(saved.theme_id)
|
||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||
// Floor plans are backend-only; keep the store clear in standalone mode.
|
||||
setFloorMap(null)
|
||||
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
||||
loadCanvas(migrated.nodes, migrated.edges)
|
||||
} else if (mode === 'empty' && saved) {
|
||||
if (saved.theme_id) setTheme(saved.theme_id)
|
||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||
setFloorMap(null)
|
||||
loadCanvas([], [])
|
||||
} else {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
setLoadError(false)
|
||||
setIsNewUser(isNewUserCanvas(mode))
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId)
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
@@ -231,16 +284,73 @@ export default function App() {
|
||||
await loadCanvasFromApi(targetId)
|
||||
}
|
||||
} catch {
|
||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
|
||||
// hide a real outage behind a fake "new account" canvas.
|
||||
setLoadError(true)
|
||||
toast.error('Could not reach backend — check the server and retry')
|
||||
}
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
|
||||
|
||||
// Keep a ref so the auth effect can call the latest loader without listing it
|
||||
// as a dependency (which would re-fire on every design switch).
|
||||
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
|
||||
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
|
||||
|
||||
// Retry a failed load from the error banner.
|
||||
const handleRetryLoad = useCallback(() => {
|
||||
setLoadError(false)
|
||||
void loadDesignsAndCanvasRef.current()
|
||||
}, [])
|
||||
|
||||
// Bridge the Getting Started tour steps to the App's modal controls. The overlay
|
||||
// calls closeAll() before each step, then the step's action to open the target.
|
||||
const walkthroughActions = useMemo<WalkthroughActionApi>(() => ({
|
||||
closeAll: () => {
|
||||
setScanConfigOpen(false)
|
||||
setScanHistoryOpen(false)
|
||||
setTourScanHistoryDemo(false)
|
||||
setPendingModalOpen(false)
|
||||
setTourInventoryDemo(false)
|
||||
setEditNodeId(null)
|
||||
setThemeModalOpen(false)
|
||||
setZigbeeImportOpen(false)
|
||||
// Clear any tour-driven multi-selection so the DetailPanel closes.
|
||||
useCanvasStore.setState((s) => ({
|
||||
nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
|
||||
selectedNodeIds: [],
|
||||
selectedNodeId: null,
|
||||
}))
|
||||
},
|
||||
openScanConfig: () => setScanConfigOpen(true),
|
||||
openScanHistoryDemo: () => {
|
||||
setTourScanHistoryDemo(true)
|
||||
setScanHistoryOpen(true)
|
||||
},
|
||||
openInventoryDemo: () => {
|
||||
setTourInventoryDemo(true)
|
||||
openPendingModal(undefined, 'pending')
|
||||
},
|
||||
editFirstNode: () => {
|
||||
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
if (first) setEditNodeId(first.id)
|
||||
},
|
||||
selectTwoNodes: () => {
|
||||
const ids = useCanvasStore.getState().nodes
|
||||
.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
.slice(0, 2)
|
||||
.map((n) => n.id)
|
||||
if (ids.length < 2) return
|
||||
const set = new Set(ids)
|
||||
useCanvasStore.setState((s) => ({
|
||||
nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
|
||||
selectedNodeIds: ids,
|
||||
selectedNodeId: null,
|
||||
}))
|
||||
},
|
||||
openStyle: () => setThemeModalOpen(true),
|
||||
openZigbeeImport: () => setZigbeeImportOpen(true),
|
||||
}), [openPendingModal])
|
||||
|
||||
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
||||
// no auth gate).
|
||||
useEffect(() => {
|
||||
@@ -820,12 +930,22 @@ 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 (
|
||||
<WalkthroughActionsProvider value={walkthroughActions}>
|
||||
<TooltipProvider>
|
||||
<ReactFlowProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
||||
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
|
||||
Getting Started walkthrough keys off. */}
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||
@@ -840,6 +960,21 @@ export default function App() {
|
||||
onOpenPending={openPendingModal}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{loadError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
|
||||
>
|
||||
<span>Backend not responding — the canvas could not be loaded.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetryLoad}
|
||||
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Toolbar
|
||||
onSave={handleSave}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
@@ -975,6 +1110,7 @@ export default function App() {
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
onClose={() => setScanHistoryOpen(false)}
|
||||
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1099,6 +1235,7 @@ export default function App() {
|
||||
onClose={() => setPendingModalOpen(false)}
|
||||
highlightId={pendingHighlightId}
|
||||
initialStatus={pendingModalStatus}
|
||||
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
@@ -1108,7 +1245,10 @@ export default function App() {
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
<WalkthroughInvite />
|
||||
<WalkthroughOverlay />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
</WalkthroughActionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ type Interceptor<T> = {
|
||||
}
|
||||
|
||||
interface MockInstance {
|
||||
defaults: { baseURL?: string }
|
||||
defaults: { baseURL?: string; withCredentials?: boolean }
|
||||
interceptors: {
|
||||
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: (cfg: { baseURL?: string }) => {
|
||||
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
|
||||
const inst: MockInstance = {
|
||||
defaults: { baseURL: cfg?.baseURL },
|
||||
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
|
||||
interceptors: {
|
||||
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||
@@ -54,7 +54,7 @@ describe('api/client', () => {
|
||||
const [api, publicApi] = instances
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
|
||||
api.get.mockClear()
|
||||
api.post.mockClear()
|
||||
api.patch.mockClear()
|
||||
@@ -66,6 +66,7 @@ describe('api/client', () => {
|
||||
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||
expect(api.defaults.withCredentials).toBe(true)
|
||||
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||
})
|
||||
|
||||
@@ -86,6 +87,18 @@ describe('api/client', () => {
|
||||
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
|
||||
useAuthStore.setState({ csrfToken: 'csrf-123' })
|
||||
const post = { method: 'post', headers: {} as Record<string, string> }
|
||||
const get = { method: 'get', headers: {} as Record<string, string> }
|
||||
|
||||
api.__req.fulfilled!(post)
|
||||
api.__req.fulfilled!(get)
|
||||
|
||||
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
|
||||
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('response interceptor passes through 2xx responses', () => {
|
||||
const r = { status: 200, data: { ok: true } }
|
||||
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||
@@ -125,6 +138,15 @@ describe('api/client', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||
})
|
||||
|
||||
it('authApi discovers auth publicly and manages the current session', () => {
|
||||
mod.authApi.config()
|
||||
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
|
||||
mod.authApi.me()
|
||||
expect(api.get).toHaveBeenCalledWith('/auth/me')
|
||||
mod.authApi.logout()
|
||||
expect(api.post).toHaveBeenCalledWith('/auth/logout')
|
||||
})
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthMode, AuthUser } from '@/stores/authStore'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
|
||||
const publicApi = axios.create({ baseURL: '/api/v1' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
const { token, csrfToken } = useAuthStore.getState()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
const method = config.method?.toUpperCase() ?? 'GET'
|
||||
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
config.headers['X-Homelable-CSRF'] = csrfToken
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -23,8 +29,11 @@ api.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ access_token: string }>('/auth/login', { username, password }),
|
||||
me: () => api.get<AuthUser>('/auth/me'),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
}
|
||||
|
||||
export const canvasApi = {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
config: vi.fn(),
|
||||
me: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { authApi } from '@/api/client'
|
||||
import { bootstrapAuth } from '../bootstrap'
|
||||
|
||||
describe('bootstrapAuth', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
})
|
||||
vi.mocked(authApi.config).mockReset()
|
||||
vi.mocked(authApi.me).mockReset()
|
||||
})
|
||||
|
||||
it('restores an OIDC cookie session without storing an IdP token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'user-1',
|
||||
display_name: 'Alice',
|
||||
auth_method: 'oidc',
|
||||
issuer: 'https://id.example/application/o/homelable/',
|
||||
csrf_token: 'csrf-123',
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(authApi.me).toHaveBeenCalledOnce()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.authMethod).toBe('oidc')
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.csrfToken).toBe('csrf-123')
|
||||
})
|
||||
|
||||
it('does not probe /auth/me for local mode without a persisted token', async () => {
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(authApi.me).not.toHaveBeenCalled()
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: 'local',
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates and retains a persisted local bearer token', async () => {
|
||||
useAuthStore.setState({ token: 'local-token' })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockResolvedValue({
|
||||
data: {
|
||||
subject: 'admin',
|
||||
display_name: 'admin',
|
||||
auth_method: 'local',
|
||||
issuer: null,
|
||||
csrf_token: null,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: 'local-token',
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the backend rejects the session', async () => {
|
||||
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
|
||||
vi.mocked(authApi.config).mockResolvedValue({
|
||||
data: { mode: 'local', oidc_login_url: null },
|
||||
} as never)
|
||||
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the auth mode unknown when discovery is unavailable', async () => {
|
||||
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
|
||||
|
||||
await bootstrapAuth()
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
authMode: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/**
|
||||
* Discover the configured authentication mode and restore a valid session.
|
||||
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
|
||||
* sessions retain their existing bearer token until /auth/me validates it.
|
||||
*/
|
||||
export async function bootstrapAuth(): Promise<void> {
|
||||
const store = useAuthStore.getState()
|
||||
|
||||
try {
|
||||
const { data: config } = await authApi.config()
|
||||
store.setAuthConfig(config.mode, config.oidc_login_url)
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (config.mode === 'oidc' || token) {
|
||||
try {
|
||||
const { data: user } = await authApi.me()
|
||||
useAuthStore.getState().authenticate(user)
|
||||
} catch {
|
||||
// The response interceptor clears invalid credentials on 401. Also
|
||||
// clear them for malformed/unreachable session checks, failing closed.
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep authMode unknown when discovery is unreachable. The login screen
|
||||
// reports that state without guessing which authentication mode is active.
|
||||
} finally {
|
||||
useAuthStore.getState().finishInitialization()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Network, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const { authMode, oidcLoginUrl, login } = useAuthStore()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -42,62 +44,92 @@ export function LoginPage() {
|
||||
<div className="relative w-full max-w-sm px-4">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
|
||||
<Network size={28} className="text-[#00d4ff]" />
|
||||
</div>
|
||||
<Logo size={64} showText={false} />
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
<span className="text-[#e6edf3]">Home</span>
|
||||
<span className="text-[#00d4ff]">lable</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
{authMode === 'local' && (
|
||||
<>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authMode === 'oidc' && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue through the configured OpenID Connect provider.
|
||||
</p>
|
||||
<a
|
||||
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
|
||||
)}
|
||||
>
|
||||
Sign in with OpenID Connect
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
|
||||
required
|
||||
/>
|
||||
{authMode === null && (
|
||||
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
|
||||
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[#f85149] text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LoginPage } from '../LoginPage'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
authMode: 'local',
|
||||
oidcLoginUrl: null,
|
||||
})
|
||||
vi.mocked(authApi.login).mockReset()
|
||||
})
|
||||
|
||||
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
|
||||
render(<LoginPage />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
expect(svg.classList.contains('lucide')).toBe(false)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
|
||||
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders only the provider redirect in OIDC mode', () => {
|
||||
useAuthStore.setState({
|
||||
authMode: 'oidc',
|
||||
oidcLoginUrl: '/api/v1/auth/oidc/login',
|
||||
})
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.queryByLabelText('Password')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
|
||||
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
|
||||
})
|
||||
|
||||
it('does not guess an auth mode when discovery failed', () => {
|
||||
useAuthStore.setState({ authMode: null })
|
||||
|
||||
render(<LoginPage />)
|
||||
|
||||
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
|
||||
expect(screen.queryByLabelText('Username')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Security checks ──────────────────────────────────────────────────────
|
||||
|
||||
it('password field type is "password" — not rendered as plain text', () => {
|
||||
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
await act(async () => {
|
||||
resolve({ data: { access_token: 'tok' } })
|
||||
})
|
||||
})
|
||||
|
||||
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
|
||||
|
||||
@@ -44,9 +44,11 @@ vi.mock('@/utils/nodeColors', () => ({
|
||||
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeIcons', () => ({
|
||||
// Partial mock: node-type icon resolution is stubbed, but the brand/lucide
|
||||
// resolver stays real so service icons render as they do in the app.
|
||||
vi.mock('@/utils/nodeIcons', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/utils/nodeIcons')>()),
|
||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/maskIp', () => ({
|
||||
@@ -252,6 +254,27 @@ describe('BaseNode — services visibility toggle', () => {
|
||||
|
||||
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the brand icon of a service that has one', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'plex', port: 32400, protocol: 'tcp', icon: 'brand:plex' }],
|
||||
})
|
||||
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no service icon when the service has none', () => {
|
||||
renderBaseNode({
|
||||
ip: '192.168.1.10',
|
||||
custom_colors: { show_services: true },
|
||||
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — legacy hardware fallback', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { ServiceIcon } from '@/components/ui/ServiceIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
@@ -172,13 +173,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
{/* LEFT: service name */}
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
{/* LEFT: icon + service name */}
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<ServiceIcon iconKey={svc.icon} size={11} />
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* RIGHT: path + port */}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, isBrandIconKey } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
|
||||
interface IconPickerPanelProps {
|
||||
/** Current icon key — a lucide registry key or `brand:<slug>`. */
|
||||
value?: string
|
||||
/** Called with the new key, or `undefined` when the current generic icon is toggled off. */
|
||||
onSelect: (key: string | undefined) => void
|
||||
/** Focus the generic-icon search field on mount. */
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Generic/Brand icon picker body. Used by NodeModal and ServiceModal so
|
||||
* both surfaces offer the same lucide registry + dashboard-icons brand catalog.
|
||||
*/
|
||||
export function IconPickerPanel({ value, onSelect, autoFocus }: IconPickerPanelProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<'generic' | 'brand'>(isBrandIconKey(value) ? 'brand' : 'generic')
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
active ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button type="button" role="tab" aria-selected={tab === 'generic'} onClick={() => setTab('generic')} className={tabClass(tab === 'generic')}>
|
||||
Generic
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'brand'} onClick={() => setTab('brand')} className={tabClass(tab === 'brand')}>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'brand' ? (
|
||||
<BrandIconPicker value={value} onSelect={onSelect} />
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const q = search.toLowerCase()
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat && (search === '' || e.label.toLowerCase().includes(q) || e.key.includes(q))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = value === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => onSelect(isSelected ? undefined : entry.key)}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
@@ -142,9 +142,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const merged = { ...DEFAULT_DATA, ...initial }
|
||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
@@ -298,85 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'generic'}
|
||||
onClick={() => setIconTab('generic')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Generic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={iconTab === 'brand'}
|
||||
onClick={() => setIconTab('brand')}
|
||||
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
{iconTab === 'brand' ? (
|
||||
<BrandIconPicker
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
<div className="col-span-2">
|
||||
<IconPickerPanel
|
||||
value={form.custom_icon}
|
||||
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
{ICON_CATEGORIES.map((cat) => {
|
||||
const entries = ICON_REGISTRY.filter(
|
||||
(e) => e.category === cat &&
|
||||
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div key={cat}>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = form.custom_icon === entry.key
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
aria-label={`Select icon ${entry.label}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
{createElement(entry.icon, { size: 13 })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
|
||||
onClose: () => void
|
||||
highlightId?: string
|
||||
initialStatus?: 'pending' | 'hidden'
|
||||
/** Getting Started tour: render these canned devices and skip the backend fetch. */
|
||||
demoDevices?: PendingDevice[]
|
||||
}
|
||||
|
||||
const PORT_COLORS: Record<number, string> = {
|
||||
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected devices, never touch the backend.
|
||||
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
}, [statusFilter, demoDevices])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ScanRun {
|
||||
interface ScanHistoryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Getting Started tour: render these canned runs and skip the backend fetch. */
|
||||
demoRuns?: ScanRun[]
|
||||
}
|
||||
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
const prevRunsRef = useRef<ScanRun[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected runs, never touch the backend.
|
||||
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.runs()
|
||||
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [demoRuns])
|
||||
|
||||
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||
useEffect(() => {
|
||||
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
}, [open, runs])
|
||||
|
||||
const handleStop = async (runId: string) => {
|
||||
if (demoRuns) return // tour mode — no backend
|
||||
setStopping(runId)
|
||||
try {
|
||||
await scanApi.stop(runId)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ZwaveConfigData,
|
||||
} from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
@@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-foreground">Getting started tour</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
|
||||
>
|
||||
Restart walkthrough
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,9 @@ describe('SettingsModal', () => {
|
||||
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
// Wait for the async proxmox getConfig to hydrate the toggle before saving —
|
||||
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||
await waitFor(() => expect(screen.getByLabelText('Toggle Proxmox auto-sync')).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
|
||||
@@ -174,6 +177,9 @@ describe('SettingsModal', () => {
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
await screen.findByText('Zigbee auto-sync')
|
||||
// Wait for the async zigbee getConfig to hydrate the toggle before saving —
|
||||
// otherwise Save can fire with the default sync_enabled=false under CI load.
|
||||
await waitFor(() => expect(screen.getByLabelText('Toggle Zigbee auto-sync')).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,6 +39,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
|
||||
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
|
||||
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
|
||||
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
|
||||
|
||||
// Multi-select panel
|
||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||
@@ -87,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]
|
||||
@@ -102,60 +103,55 @@ 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)
|
||||
setSvcModal({ nodeId: node.id, index, form: serviceToForm(svc) })
|
||||
}
|
||||
|
||||
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
|
||||
const handleReorderService = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) 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)
|
||||
const reordered = [...services]
|
||||
const [moved] = reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, moved)
|
||||
updateNode(node.id, { services: reordered })
|
||||
}
|
||||
|
||||
// --- Property handlers ---
|
||||
@@ -340,25 +336,50 @@ 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 />
|
||||
) : (
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
{services.map((svc, i) => (
|
||||
<ServiceBadge
|
||||
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
|
||||
draggable={services.length > 1}
|
||||
isDragging={dragSvcIndex === i}
|
||||
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
|
||||
onDragStart={() => setDragSvcIndex(i)}
|
||||
onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }}
|
||||
onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }}
|
||||
onDrop={() => {
|
||||
if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i)
|
||||
setDragSvcIndex(null)
|
||||
setDragOverSvcIndex(null)
|
||||
}}
|
||||
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>
|
||||
@@ -516,6 +537,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
data-tour="create-group"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||||
variant="ghost"
|
||||
@@ -667,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 }: {
|
||||
@@ -852,7 +825,20 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
||||
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
|
||||
svc: ServiceInfo
|
||||
host?: string
|
||||
status?: ServiceStatus
|
||||
draggable: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
onDragStart: () => void
|
||||
onDragEnter: () => void
|
||||
onDragEnd: () => void
|
||||
onDrop: () => void
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
// Manually-added services carry no category, so they fell back to grey even
|
||||
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
||||
@@ -863,11 +849,27 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop() }}
|
||||
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||
style={{
|
||||
background: '#21262d',
|
||||
borderColor: isDragOver ? '#00d4ff' : '#30363d',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{draggable && (
|
||||
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||
{createElement(GripVertical, { size: 11 })}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<ServiceIcon iconKey={svc.icon} size={12} className="shrink-0" color={color} />
|
||||
|
||||
{url ? (
|
||||
<a
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { designsApi, mediaApi } from '@/api/client'
|
||||
import { authApi, designsApi, mediaApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||
@@ -47,6 +47,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
|
||||
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
logout()
|
||||
} catch {
|
||||
toast.error('Could not end the current session')
|
||||
}
|
||||
}, [logout])
|
||||
|
||||
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
|
||||
setOpenSeq((s) => s + 1)
|
||||
setDesignModal(m)
|
||||
@@ -268,13 +277,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
|
||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
@@ -294,7 +303,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
icon={LogOut}
|
||||
label="Logout"
|
||||
collapsed={collapsed}
|
||||
onClick={logout}
|
||||
onClick={() => void handleLogout()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -360,12 +369,15 @@ interface SidebarItemProps {
|
||||
badge?: boolean
|
||||
accent?: boolean
|
||||
onClick?: () => void
|
||||
/** Stable anchor for the Getting Started walkthrough spotlight. */
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
|
||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
|
||||
const btn = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-tour={dataTour}
|
||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -430,17 +473,56 @@ describe('DetailPanel', () => {
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('reorders services on drag and drop', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [
|
||||
{ port: 80, protocol: 'tcp', service_name: 'A' },
|
||||
{ port: 81, protocol: 'tcp', service_name: 'B' },
|
||||
{ port: 82, protocol: 'tcp', service_name: 'C' },
|
||||
] })],
|
||||
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 rows = screen.getAllByTitle('Drag to reorder').map(
|
||||
(g) => g.closest('[draggable="true"]') as HTMLElement,
|
||||
)
|
||||
// Drag last (C) onto first (A) position
|
||||
fireEvent.dragStart(rows[2])
|
||||
fireEvent.dragEnter(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
const [, payload] = updateNode.mock.calls[0]
|
||||
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('is not draggable with a single service', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
@@ -474,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({
|
||||
@@ -548,6 +670,25 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders a brand icon image when the service carries one', () => {
|
||||
setupStore({ services: [{ port: 32400, protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const img = screen.getByAltText('plex') as HTMLImageElement
|
||||
expect(img.src).toContain('dashboard-icons/svg/plex.svg')
|
||||
})
|
||||
|
||||
it('renders no icon image when the service has no icon', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no icon image for an unresolvable legacy icon key', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'nginx', icon: 'bar-chart-2' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders path label when path is set', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
logout: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
@@ -272,10 +275,14 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when Logout is clicked', () => {
|
||||
it('ends the backend session before clearing local auth state', async () => {
|
||||
const { authApi } = await import('@/api/client')
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Logout'))
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(authApi.logout).toHaveBeenCalledOnce()
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
|
||||
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
|
||||
*/
|
||||
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
|
||||
// Colons are legal in a URL fragment but confuse some tooling — strip them.
|
||||
const uid = useId().replace(/:/g, '');
|
||||
const bgGlow = `homelable-bg-glow-${uid}`;
|
||||
const nodeGlow = `homelable-node-glow-${uid}`;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<svg
|
||||
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
data-testid="logo-mark"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
|
||||
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<filter id={nodeGlow}>
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Background */}
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117" />
|
||||
<circle cx="32" cy="32" r="32" fill={`url(#${bgGlow})`} />
|
||||
|
||||
{/* House body */}
|
||||
<path
|
||||
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
|
||||
<circle cx="22" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="42" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="24" r="2" fill="#a855f7" />
|
||||
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
|
||||
|
||||
{/* Floor line (subtle) */}
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
|
||||
|
||||
{/* Door */}
|
||||
<rect
|
||||
x="27"
|
||||
y="40"
|
||||
width="10"
|
||||
height="13"
|
||||
rx="1.5"
|
||||
fill="#0d1117"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1"
|
||||
opacity="0.9"
|
||||
/>
|
||||
|
||||
{/* Network lines (drawn under nodes) */}
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
|
||||
|
||||
{/* Center hub */}
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
|
||||
|
||||
{/* Peripheral nodes */}
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="font-bold tracking-tight"
|
||||
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<span style={{ color: '#00d4ff' }}>Home</span>
|
||||
<span style={{ color: '#ffffff' }}>lable</span>
|
||||
<span style={{ color: '#e6edf3' }}>Home</span>
|
||||
<span style={{ color: '#00d4ff' }}>lable</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createElement } from 'react'
|
||||
import { resolveCustomIcon } from '@/utils/nodeIcons'
|
||||
|
||||
interface ServiceIconProps {
|
||||
/** Icon key on the service — a lucide registry key or `brand:<slug>`. */
|
||||
iconKey?: string
|
||||
size?: number
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a service's icon, or nothing when the service has no icon (or carries
|
||||
* a key we can't resolve — e.g. a legacy scanner signature name).
|
||||
*/
|
||||
export function ServiceIcon({ iconKey, size = 12, className, color }: ServiceIconProps) {
|
||||
const resolved = resolveCustomIcon(iconKey)
|
||||
if (!resolved) return null
|
||||
if (resolved.kind === 'brand') {
|
||||
return (
|
||||
<img
|
||||
src={resolved.url}
|
||||
alt={resolved.slug}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
style={{ width: size, height: size, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return createElement(resolved.icon, { size, className, color })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Logo } from '../Logo'
|
||||
|
||||
describe('Logo', () => {
|
||||
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
|
||||
render(<Logo />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
// Canonical house outline from docs/logo/icon.svg
|
||||
expect(svg.querySelector('path')?.getAttribute('d'))
|
||||
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
|
||||
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
|
||||
expect(svg.querySelectorAll('circle').length).toBe(7)
|
||||
})
|
||||
|
||||
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
|
||||
render(<Logo showText />)
|
||||
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
|
||||
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
|
||||
})
|
||||
|
||||
it('hides the wordmark when showText is false', () => {
|
||||
render(<Logo showText={false} />)
|
||||
expect(screen.queryByText('Home')).toBeNull()
|
||||
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scales the svg to the requested size', () => {
|
||||
render(<Logo size={64} />)
|
||||
const svg = screen.getByTestId('logo-mark')
|
||||
expect(svg.getAttribute('width')).toBe('64')
|
||||
expect(svg.getAttribute('height')).toBe('64')
|
||||
})
|
||||
|
||||
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
|
||||
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
|
||||
// defs ids would make one instance reference the other's gradient.
|
||||
render(<><Logo /><Logo /></>)
|
||||
const [first, second] = screen.getAllByTestId('logo-mark')
|
||||
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
|
||||
expect(idOf(first)).not.toBe(idOf(second))
|
||||
|
||||
// Each instance must point at its own gradient
|
||||
const gradientRef = (svg: HTMLElement) =>
|
||||
svg.querySelectorAll('circle')[1].getAttribute('fill')
|
||||
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
|
||||
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
|
||||
})
|
||||
|
||||
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
|
||||
render(<Logo />)
|
||||
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('forwards className to the wrapper', () => {
|
||||
const { container } = render(<Logo className="my-class" />)
|
||||
expect(container.firstElementChild).toHaveClass('my-class')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'
|
||||
import { render, cleanup, waitFor } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '../sonner'
|
||||
|
||||
beforeAll(() => {
|
||||
// sonner reads matchMedia for theme detection; jsdom doesn't provide it.
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('Toaster', () => {
|
||||
it('renders error toasts on a red surface so failures are visible', async () => {
|
||||
render(<Toaster />)
|
||||
toast.error('Save failed')
|
||||
// The style (with the CSS vars) lands on the toast list, mounted on first toast.
|
||||
const list = await waitFor(() => {
|
||||
const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null
|
||||
expect(el).not.toBeNull()
|
||||
return el!
|
||||
})
|
||||
const style = list.getAttribute('style') ?? ''
|
||||
expect(style).toContain('--error-bg: #f85149')
|
||||
expect(style).toContain('--error-text: #ffffff')
|
||||
|
||||
// richColors must be on for sonner to apply the per-type surface, and the
|
||||
// toast itself must be tagged as an error so it picks up --error-bg.
|
||||
const item = await waitFor(() => {
|
||||
const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null
|
||||
expect(el).not.toBeNull()
|
||||
return el!
|
||||
})
|
||||
expect(item.getAttribute('data-rich-colors')).toBe('true')
|
||||
expect(item.getAttribute('data-type')).toBe('error')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
richColors
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
@@ -34,6 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
// richColors (below) is required for sonner to honor per-type surfaces.
|
||||
// Errors get a red surface so failures (e.g. a save while the backend
|
||||
// is down) stand out instead of reading like a normal toast...
|
||||
"--error-bg": "#f85149",
|
||||
"--error-text": "#ffffff",
|
||||
"--error-border": "#da3633",
|
||||
// ...while success/info/warning stay on the neutral popover surface so
|
||||
// the rest of the toast UX is unchanged.
|
||||
"--success-bg": "var(--popover)",
|
||||
"--success-text": "var(--popover-foreground)",
|
||||
"--success-border": "var(--border)",
|
||||
"--info-bg": "var(--popover)",
|
||||
"--info-text": "var(--popover-foreground)",
|
||||
"--info-border": "var(--border)",
|
||||
"--warning-bg": "var(--popover)",
|
||||
"--warning-text": "var(--popover-foreground)",
|
||||
"--warning-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('useStatusPolling', () => {
|
||||
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
token: 'test-token',
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
|
||||
@@ -58,6 +59,7 @@ describe('useStatusPolling', () => {
|
||||
it('does not open WebSocket when not authenticated', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
authMethod: null,
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
renderHook(() => useStatusPolling())
|
||||
@@ -67,12 +69,28 @@ describe('useStatusPolling', () => {
|
||||
it('does not open WebSocket when token is missing', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
renderHook(() => useStatusPolling())
|
||||
expect(MockWebSocket.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the HttpOnly cookie and sends no credential in OIDC mode', () => {
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
authMethod: 'oidc',
|
||||
token: null,
|
||||
} as ReturnType<typeof useAuthStore>)
|
||||
|
||||
renderHook(() => useStatusPolling())
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1)
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onopen?.()
|
||||
expect(ws.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connects to correct ws:// URL', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
expect(MockWebSocket.instances).toHaveLength(1)
|
||||
|
||||
@@ -25,10 +25,10 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
const { isAuthenticated, authMethod, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (STANDALONE || !isAuthenticated || !token) return
|
||||
if (STANDALONE || !isAuthenticated || (authMethod !== 'oidc' && !token)) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host // includes port when non-standard
|
||||
@@ -37,9 +37,13 @@ export function useStatusPolling() {
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
// Send token as first message (not in URL to avoid log/history exposure)
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token }))
|
||||
// Local mode sends the bearer token as the first message (not in the URL
|
||||
// where proxies may log it). OIDC mode is authenticated during the upgrade
|
||||
// with the backend's HttpOnly session cookie, so React sends no credential.
|
||||
if (token) {
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -71,5 +75,5 @@ export function useStatusPolling() {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
|
||||
}, [isAuthenticated, authMethod, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
|
||||
}
|
||||
|
||||
@@ -4,7 +4,17 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
sessionStorage.clear()
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
authMode: null,
|
||||
authMethod: null,
|
||||
oidcLoginUrl: null,
|
||||
displayName: null,
|
||||
csrfToken: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts unauthenticated', () => {
|
||||
@@ -18,6 +28,8 @@ describe('authStore', () => {
|
||||
const { token, isAuthenticated } = useAuthStore.getState()
|
||||
expect(token).toBe('my-jwt-token')
|
||||
expect(isAuthenticated).toBe(true)
|
||||
expect(useAuthStore.getState().authMethod).toBe('local')
|
||||
expect(useAuthStore.getState().isInitialized).toBe(true)
|
||||
})
|
||||
|
||||
it('logout clears token and isAuthenticated', () => {
|
||||
@@ -27,4 +39,36 @@ describe('authStore', () => {
|
||||
expect(token).toBeNull()
|
||||
expect(isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('stores OIDC identity and CSRF state without a browser-readable credential', () => {
|
||||
useAuthStore.getState().setAuthConfig('oidc', '/api/v1/auth/oidc/login')
|
||||
useAuthStore.getState().authenticate({
|
||||
subject: 'user-1',
|
||||
display_name: 'Alice',
|
||||
auth_method: 'oidc',
|
||||
issuer: 'https://id.example/application/o/homelable/',
|
||||
csrf_token: 'csrf-123',
|
||||
})
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.authMethod).toBe('oidc')
|
||||
expect(state.displayName).toBe('Alice')
|
||||
expect(state.csrfToken).toBe('csrf-123')
|
||||
expect(sessionStorage.getItem('homelable-auth') ?? '').not.toContain('csrf-123')
|
||||
})
|
||||
|
||||
it('keeps a validated local token when applying /auth/me identity', () => {
|
||||
useAuthStore.getState().login('local-token')
|
||||
useAuthStore.getState().authenticate({
|
||||
subject: 'admin',
|
||||
display_name: 'admin',
|
||||
auth_method: 'local',
|
||||
issuer: null,
|
||||
csrf_token: null,
|
||||
})
|
||||
|
||||
expect(useAuthStore.getState().token).toBe('local-token')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useWalkthroughStore } from '../walkthroughStore'
|
||||
import { readWalkthrough, WALKTHROUGH_VERSION } from '@/utils/walkthrough'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||
})
|
||||
|
||||
describe('walkthroughStore', () => {
|
||||
it('start activates at step 0', () => {
|
||||
useWalkthroughStore.getState().start()
|
||||
const s = useWalkthroughStore.getState()
|
||||
expect(s.isActive).toBe(true)
|
||||
expect(s.stepIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('next advances and finishes past the last step', () => {
|
||||
const store = useWalkthroughStore.getState()
|
||||
store.start()
|
||||
store.setTotal(3)
|
||||
store.next()
|
||||
expect(useWalkthroughStore.getState().stepIndex).toBe(1)
|
||||
store.next()
|
||||
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||
// At the last step, next() finishes the tour and stamps it completed.
|
||||
store.next()
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||
expect(readWalkthrough()).toEqual({ seenVersion: WALKTHROUGH_VERSION, status: 'completed' })
|
||||
})
|
||||
|
||||
it('prev clamps at 0', () => {
|
||||
const store = useWalkthroughStore.getState()
|
||||
store.start()
|
||||
store.setTotal(3)
|
||||
store.prev()
|
||||
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('goTo clamps within [0, total-1]', () => {
|
||||
const store = useWalkthroughStore.getState()
|
||||
store.start()
|
||||
store.setTotal(3)
|
||||
store.goTo(9)
|
||||
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||
store.goTo(-1)
|
||||
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('skip deactivates and stamps skipped', () => {
|
||||
const store = useWalkthroughStore.getState()
|
||||
store.start()
|
||||
store.skip()
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||
expect(readWalkthrough().status).toBe('skipped')
|
||||
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||
})
|
||||
})
|
||||
@@ -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,48 @@
|
||||
import { create } from 'zustand'
|
||||
import { markWalkthroughSeen } from '@/utils/walkthrough'
|
||||
|
||||
/**
|
||||
* Runtime state of an active Getting Started tour. Persistence of the "already
|
||||
* seen" flag lives in utils/walkthrough.ts; this store only tracks the live run.
|
||||
*
|
||||
* `total` is set by the overlay from the mode-filtered step list, so next()/goTo
|
||||
* can clamp and finish() fires when the user advances past the last step.
|
||||
*/
|
||||
interface WalkthroughStore {
|
||||
isActive: boolean
|
||||
stepIndex: number
|
||||
total: number
|
||||
setTotal: (n: number) => void
|
||||
start: () => void
|
||||
next: () => void
|
||||
prev: () => void
|
||||
goTo: (n: number) => void
|
||||
skip: () => void
|
||||
finish: () => void
|
||||
}
|
||||
|
||||
export const useWalkthroughStore = create<WalkthroughStore>((set, get) => ({
|
||||
isActive: false,
|
||||
stepIndex: 0,
|
||||
total: 0,
|
||||
setTotal: (n) => set({ total: n }),
|
||||
start: () => set({ isActive: true, stepIndex: 0 }),
|
||||
next: () => {
|
||||
const { stepIndex, total } = get()
|
||||
if (stepIndex >= total - 1) {
|
||||
get().finish()
|
||||
return
|
||||
}
|
||||
set({ stepIndex: stepIndex + 1 })
|
||||
},
|
||||
prev: () => set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
|
||||
goTo: (n) => set((s) => ({ stepIndex: Math.max(0, Math.min(n, Math.max(0, s.total - 1))) })),
|
||||
skip: () => {
|
||||
markWalkthroughSeen('skipped')
|
||||
set({ isActive: false })
|
||||
},
|
||||
finish: () => {
|
||||
markWalkthroughSeen('completed')
|
||||
set({ isActive: false })
|
||||
},
|
||||
}))
|
||||
@@ -34,4 +34,7 @@ export interface YamlNode {
|
||||
bottomHandles?: number
|
||||
leftHandles?: number
|
||||
rightHandles?: number
|
||||
// Whether port-number labels are shown next to connection points. Only written
|
||||
// when enabled so the toggle round-trips through export/import (issue #272).
|
||||
showPortNumbers?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { decideCanvasLoad, isNewUserCanvas } from '../canvasLoadDecision'
|
||||
|
||||
describe('decideCanvasLoad', () => {
|
||||
it('renders real nodes when the canvas has any', () => {
|
||||
expect(decideCanvasLoad(true, true)).toBe('real')
|
||||
// hasNodes wins even if the initialized flag is somehow false
|
||||
expect(decideCanvasLoad(true, false)).toBe('real')
|
||||
})
|
||||
|
||||
it('keeps an initialized-but-empty canvas empty (user cleared it)', () => {
|
||||
expect(decideCanvasLoad(false, true)).toBe('empty')
|
||||
})
|
||||
|
||||
it('shows the demo only for a brand-new, uninitialized canvas', () => {
|
||||
expect(decideCanvasLoad(false, false)).toBe('demo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNewUserCanvas', () => {
|
||||
it('is true only for the demo mode', () => {
|
||||
expect(isNewUserCanvas('demo')).toBe(true)
|
||||
expect(isNewUserCanvas('empty')).toBe(false)
|
||||
expect(isNewUserCanvas('real')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -208,4 +208,12 @@ describe('exportCanvasToYaml', () => {
|
||||
expect(entry).not.toHaveProperty('topHandles')
|
||||
expect(entry).not.toHaveProperty('leftHandles')
|
||||
})
|
||||
|
||||
it('exports showPortNumbers only when enabled (issue #272)', () => {
|
||||
const on = (yaml.load(exportCanvasToYaml([makeNode({ label: 'On', type: 'server', show_port_numbers: true })], [])) as Record<string, unknown>[])[0]
|
||||
expect(on.showPortNumbers).toBe(true)
|
||||
|
||||
const off = (yaml.load(exportCanvasToYaml([makeNode({ label: 'Off', type: 'server', show_port_numbers: false })], [])) as Record<string, unknown>[])[0]
|
||||
expect(off).not.toHaveProperty('showPortNumbers')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,4 +368,30 @@ describe('parseYamlToCanvas', () => {
|
||||
expect(edges[0].targetHandle).toBe('top-t')
|
||||
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
|
||||
})
|
||||
|
||||
// Regression for issue #272.
|
||||
it('restores the show-port-numbers toggle from YAML', () => {
|
||||
const yaml = `
|
||||
- nodeType: server
|
||||
label: "Server"
|
||||
showPortNumbers: true
|
||||
`
|
||||
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||
expect(nodes.find((n) => n.data.label === 'Server')!.data.show_port_numbers).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips show-port-numbers through export → import (issue #272)', () => {
|
||||
const on: Node<NodeData> = {
|
||||
id: 'on', type: 'server', position: { x: 0, y: 0 },
|
||||
data: { label: 'On', type: 'server', status: 'online', services: [], show_port_numbers: true },
|
||||
}
|
||||
const off: Node<NodeData> = {
|
||||
id: 'off', type: 'server', position: { x: 0, y: 0 },
|
||||
data: { label: 'Off', type: 'server', status: 'online', services: [] },
|
||||
}
|
||||
const yamlStr = exportCanvasToYaml([on, off], [])
|
||||
const { nodes } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
|
||||
expect(nodes.find((n) => n.data.label === 'On')!.data.show_port_numbers).toBe(true)
|
||||
expect(nodes.find((n) => n.data.label === 'Off')!.data.show_port_numbers).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm'
|
||||
|
||||
describe('serviceToForm', () => {
|
||||
it('maps a service onto string form fields', () => {
|
||||
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '443',
|
||||
protocol: 'udp',
|
||||
path: '/x',
|
||||
icon: 'database',
|
||||
})
|
||||
})
|
||||
|
||||
it('blanks out absent port and path', () => {
|
||||
expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({
|
||||
service_name: 'svc',
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
path: '',
|
||||
icon: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a brand icon key intact', () => {
|
||||
expect(serviceToForm({ protocol: 'tcp', service_name: 'plex', icon: 'brand:plex' }).icon).toBe('brand:plex')
|
||||
})
|
||||
|
||||
it('starts from a blank tcp form', () => {
|
||||
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
WALKTHROUGH_VERSION,
|
||||
readWalkthrough,
|
||||
writeWalkthrough,
|
||||
subscribeWalkthrough,
|
||||
shouldOfferWalkthrough,
|
||||
markWalkthroughSeen,
|
||||
resetWalkthrough,
|
||||
} from '../walkthrough'
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('readWalkthrough', () => {
|
||||
it('defaults to pending / unseen when nothing is stored', () => {
|
||||
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||
})
|
||||
|
||||
it('round-trips written state', () => {
|
||||
writeWalkthrough({ seenVersion: 3, status: 'completed' })
|
||||
expect(readWalkthrough()).toEqual({ seenVersion: 3, status: 'completed' })
|
||||
})
|
||||
|
||||
it('falls back to defaults on malformed json', () => {
|
||||
localStorage.setItem('homelable.walkthrough', '{not json')
|
||||
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldOfferWalkthrough', () => {
|
||||
it('offers a brand-new user (seenVersion null)', () => {
|
||||
expect(shouldOfferWalkthrough()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers an upgrader whose seen version is behind', () => {
|
||||
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION - 1, status: 'completed' })
|
||||
expect(shouldOfferWalkthrough()).toBe(true)
|
||||
})
|
||||
|
||||
it('stops offering once the current version is stamped', () => {
|
||||
markWalkthroughSeen('completed')
|
||||
expect(shouldOfferWalkthrough()).toBe(false)
|
||||
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetWalkthrough', () => {
|
||||
it('re-offers after a reset', () => {
|
||||
markWalkthroughSeen('skipped')
|
||||
expect(shouldOfferWalkthrough()).toBe(false)
|
||||
resetWalkthrough()
|
||||
expect(shouldOfferWalkthrough()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribeWalkthrough', () => {
|
||||
it('notifies listeners on write and unsubscribes cleanly', () => {
|
||||
const spy = vi.fn()
|
||||
const unsub = subscribeWalkthrough(spy)
|
||||
writeWalkthrough({ seenVersion: 1, status: 'completed' })
|
||||
expect(spy).toHaveBeenCalledWith({ seenVersion: 1, status: 'completed' })
|
||||
unsub()
|
||||
writeWalkthrough({ seenVersion: 2, status: 'skipped' })
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Decides what to render when a design's canvas loads.
|
||||
*
|
||||
* Three cases must stay distinct (issue: clearing a canvas re-showed the demo):
|
||||
* - `real` → the canvas has saved nodes; render them.
|
||||
* - `empty` → the canvas is initialized (ever saved / explicitly created) but
|
||||
* has no nodes; the user intentionally cleared it — keep it empty.
|
||||
* - `demo` → brand-new, never-initialized canvas; seed the demo so first-time
|
||||
* users see an example (and, later, the Getting Started walkthrough).
|
||||
*
|
||||
* Backend errors are handled by the caller, NOT here — a failed load must show an
|
||||
* error and must never fall back to `demo` (that would hide the real failure).
|
||||
*/
|
||||
export type CanvasLoadMode = 'real' | 'empty' | 'demo'
|
||||
|
||||
export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode {
|
||||
if (hasNodes) return 'real'
|
||||
if (initialized) return 'empty'
|
||||
return 'demo'
|
||||
}
|
||||
|
||||
/** A new user is one who lands on the demo canvas — the Getting Started hook. */
|
||||
export function isNewUserCanvas(mode: CanvasLoadMode): boolean {
|
||||
return mode === 'demo'
|
||||
}
|
||||
@@ -92,6 +92,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
||||
// Custom connection-point counts, so imported edges land on real slots.
|
||||
attachHandleCounts(entry, d)
|
||||
|
||||
// Preserve the port-number toggle so it survives a round-trip (issue #272).
|
||||
if (d.show_port_numbers) entry.showPortNumbers = true
|
||||
|
||||
// Parent relationship: if this node has a parentId in React Flow,
|
||||
// encode it as a 'parent' connection using any virtual edge between them.
|
||||
if (node.parentId) {
|
||||
|
||||
@@ -79,6 +79,8 @@ export function parseYamlToCanvas(
|
||||
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
|
||||
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
|
||||
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
|
||||
// Restore the port-number toggle (issue #272).
|
||||
...(yn.showPortNumbers ? { show_port_numbers: true } : {}),
|
||||
}
|
||||
|
||||
newNodes.push({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Getting Started walkthrough — persisted invite state (localStorage).
|
||||
*
|
||||
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
|
||||
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
|
||||
* without a global store.
|
||||
*
|
||||
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
|
||||
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
|
||||
* existing users are re-offered the tour exactly once (they may have missed the
|
||||
* new features). A brand-new user starts at seenVersion=null and is offered too.
|
||||
*/
|
||||
export const WALKTHROUGH_VERSION = 1
|
||||
|
||||
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
|
||||
|
||||
export interface WalkthroughState {
|
||||
seenVersion: number | null
|
||||
status: WalkthroughStatus
|
||||
}
|
||||
|
||||
const KEY = 'homelable.walkthrough'
|
||||
const EVENT = 'homelable:walkthrough-changed'
|
||||
|
||||
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
|
||||
|
||||
function isStatus(v: unknown): v is WalkthroughStatus {
|
||||
return v === 'pending' || v === 'skipped' || v === 'completed'
|
||||
}
|
||||
|
||||
export function readWalkthrough(): WalkthroughState {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
|
||||
return {
|
||||
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
|
||||
status: isStatus(parsed.status) ? parsed.status : 'pending',
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWalkthrough(state: WalkthroughState): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(state))
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
|
||||
} catch {
|
||||
// ignore quota / SSR
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
|
||||
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
|
||||
window.addEventListener(EVENT, handler)
|
||||
return () => window.removeEventListener(EVENT, handler)
|
||||
}
|
||||
|
||||
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
|
||||
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
|
||||
return state.seenVersion !== WALKTHROUGH_VERSION
|
||||
}
|
||||
|
||||
/** Stamp the current version so the invite stops auto-showing until the next bump. */
|
||||
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
|
||||
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
|
||||
}
|
||||
|
||||
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
|
||||
export function resetWalkthrough(): void {
|
||||
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Compass, X } from 'lucide-react'
|
||||
import './walkthrough.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import {
|
||||
readWalkthrough,
|
||||
subscribeWalkthrough,
|
||||
shouldOfferWalkthrough,
|
||||
markWalkthroughSeen,
|
||||
type WalkthroughState,
|
||||
} from '@/utils/walkthrough'
|
||||
|
||||
/**
|
||||
* First-run invite bubble (bottom-right, above the toaster).
|
||||
*
|
||||
* - Not now → hide for this session only (no write), reappears next load
|
||||
* - Don't show again → persist as skipped, stops offering until the next version
|
||||
* - Getting started → launch the tour
|
||||
*/
|
||||
export function WalkthroughInvite() {
|
||||
const [state, setState] = useState<WalkthroughState>(readWalkthrough)
|
||||
useEffect(() => subscribeWalkthrough(setState), [])
|
||||
|
||||
// "Not now" dismissal — session-local, not persisted.
|
||||
const [dismissedThisSession, setDismissedThisSession] = useState(false)
|
||||
|
||||
const isActive = useWalkthroughStore((s) => s.isActive)
|
||||
const start = useWalkthroughStore((s) => s.start)
|
||||
|
||||
if (isActive || dismissedThisSession || !shouldOfferWalkthrough(state)) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 right-4 z-[90] w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl walkthrough-invite-enter">
|
||||
<button
|
||||
onClick={() => setDismissedThisSession(true)}
|
||||
aria-label="Not now"
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground p-1 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#00d4ff]/10 text-[#00d4ff]">
|
||||
<Compass size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">New here? Take the tour</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
A 2-minute walkthrough of scanning, devices, and building your canvas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => markWalkthroughSeen('skipped')}
|
||||
>
|
||||
Don't show again
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={start}
|
||||
>
|
||||
Getting started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import { useWalkthroughActions } from './actions'
|
||||
import { getSteps, type StepPlacement } from './steps'
|
||||
import './walkthrough.css'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
const CARD_W = 320
|
||||
const CARD_H_EST = 210
|
||||
const GAP = 16
|
||||
const PAD = 6 // spotlight padding around the target
|
||||
|
||||
/** Coach-card position from the target rect + requested placement. */
|
||||
function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8))
|
||||
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
|
||||
if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||
|
||||
let side = placement
|
||||
if (side === 'auto') {
|
||||
side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom'
|
||||
}
|
||||
switch (side) {
|
||||
case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) }
|
||||
case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) }
|
||||
case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) }
|
||||
case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) }
|
||||
case 'center':
|
||||
default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the card relative to an OPEN MODAL (the step's real subject).
|
||||
* Large modals (inventory, scan history at ~90vw) leave no room beside them, so
|
||||
* the card drops into the empty bottom-right corner instead of covering content.
|
||||
* Small modals get the card beside them, on whichever side has room.
|
||||
*/
|
||||
function dialogCardPosition(rect: DOMRect): { left: number; top: number } {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
|
||||
const isLarge = rect.width > vw * 0.6 || rect.height > vh * 0.7
|
||||
if (isLarge) return { left: vw - CARD_W - 16, top: vh - CARD_H_EST - 16 }
|
||||
if (rect.left >= CARD_W + GAP * 2) return { left: rect.left - GAP - CARD_W, top: clampT(rect.top) }
|
||||
if (vw - rect.right >= CARD_W + GAP * 2) return { left: rect.right + GAP, top: clampT(rect.top) }
|
||||
return { left: Math.max(8, Math.min(rect.left, vw - CARD_W - 8)), top: clampT(rect.bottom + GAP) }
|
||||
}
|
||||
|
||||
export function WalkthroughOverlay() {
|
||||
const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore()
|
||||
const actions = useWalkthroughActions()
|
||||
const steps = useMemo(() => getSteps(STANDALONE), [])
|
||||
const step = steps[stepIndex]
|
||||
|
||||
// anchorRect: the element the step rings (sidebar/toolbar button). dialogRect:
|
||||
// any open app modal, always kept lit so it stays readable during modal steps.
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
|
||||
const [dialogRect, setDialogRect] = useState<DOMRect | null>(null)
|
||||
|
||||
// Keep the store's total in sync with the mode-filtered step list.
|
||||
useEffect(() => {
|
||||
if (isActive) setTotal(steps.length)
|
||||
}, [isActive, steps.length, setTotal])
|
||||
|
||||
// Run the step's action on enter (close leftovers, open the right modal / inject
|
||||
// demo data). Close everything when the tour ends.
|
||||
useEffect(() => {
|
||||
if (!actions) return
|
||||
if (!isActive) { actions.closeAll(); return }
|
||||
actions.closeAll()
|
||||
const key = step?.action as keyof typeof actions | undefined
|
||||
if (key && typeof actions[key] === 'function') (actions[key] as () => void)()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isActive, stepIndex, actions])
|
||||
|
||||
// Track target rects. Polls each frame because modals mount asynchronously after
|
||||
// the step action fires, and layout shifts as they animate in.
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
let raf = 0
|
||||
const update = () => {
|
||||
const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null
|
||||
setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null)
|
||||
// Any open app modal, excluding our own coach card (inside the overlay).
|
||||
const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||
.find((el) => !el.closest('[data-walkthrough-overlay]'))
|
||||
setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null)
|
||||
raf = window.requestAnimationFrame(update)
|
||||
}
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, true)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update, true)
|
||||
}
|
||||
}, [isActive, stepIndex, step?.anchor])
|
||||
|
||||
const handleKey = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); skip() }
|
||||
}, [next, prev, skip])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [isActive, handleKey])
|
||||
|
||||
// Flag the tour so app-modal backdrops drop their own dim/blur (see
|
||||
// walkthrough.css) — the spotlight then dims consistently everywhere.
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
document.body.classList.add('walkthrough-running')
|
||||
return () => document.body.classList.remove('walkthrough-running')
|
||||
}, [isActive])
|
||||
|
||||
if (!isActive || !step) return null
|
||||
|
||||
const isLast = stepIndex >= total - 1
|
||||
// Ring the step's anchor (sidebar/toolbar button) if it has one, else the modal.
|
||||
const ringRect = anchorRect ?? dialogRect
|
||||
// The card follows the OPEN MODAL when there is one (that's what the user reads),
|
||||
// otherwise the step's anchor. Keeps the card off the modal's content.
|
||||
const pos = dialogRect
|
||||
? dialogCardPosition(dialogRect)
|
||||
: cardPosition(anchorRect, step.placement ?? 'auto')
|
||||
// Cut-outs keep both the ringed control and any open modal lit.
|
||||
const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null)
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]" data-walkthrough-overlay>
|
||||
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so
|
||||
a ringed button AND an open modal can both stay readable). */}
|
||||
<svg className="pointer-events-none absolute inset-0 h-full w-full">
|
||||
<defs>
|
||||
<mask id="wt-spotlight-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
{holes.map((r, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={r.left - PAD}
|
||||
y={r.top - PAD}
|
||||
width={r.width + PAD * 2}
|
||||
height={r.height + PAD * 2}
|
||||
rx="10"
|
||||
fill="black"
|
||||
/>
|
||||
))}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="#010409" fillOpacity="0.72" mask="url(#wt-spotlight-mask)" />
|
||||
</svg>
|
||||
|
||||
{/* Pulsing ring on the primary target */}
|
||||
{ringRect && (
|
||||
<div
|
||||
className="walkthrough-spotlight--pulse pointer-events-none absolute rounded-lg border-2 border-[#00d4ff]"
|
||||
style={{
|
||||
left: ringRect.left - PAD,
|
||||
top: ringRect.top - PAD,
|
||||
width: ringRect.width + PAD * 2,
|
||||
height: ringRect.height + PAD * 2,
|
||||
transition: 'left 0.25s, top 0.25s, width 0.25s, height 0.25s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Coach card */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Getting started walkthrough"
|
||||
className="walkthrough-card-enter absolute pointer-events-auto rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl"
|
||||
style={{ left: pos.left, top: pos.top, width: CARD_W, transition: 'left 0.25s, top 0.25s' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{step.title}</h3>
|
||||
<button
|
||||
onClick={skip}
|
||||
aria-label="Skip walkthrough"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{step.body}</p>
|
||||
|
||||
{step.link && (
|
||||
<a
|
||||
href={step.link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-[#00d4ff] hover:underline"
|
||||
>
|
||||
{step.link.label} <ArrowRight size={12} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{steps.map((s, i) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
|
||||
{stepIndex + 1}/{steps.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Previous step"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
onClick={prev}
|
||||
disabled={stepIndex === 0}
|
||||
>
|
||||
<ArrowLeft size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={next}
|
||||
>
|
||||
{isLast ? 'Finish' : <>Next <ArrowRight size={13} className="ml-1" /></>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent } from '@/test/render'
|
||||
import { WalkthroughInvite } from '../WalkthroughInvite'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
import { readWalkthrough } from '@/utils/walkthrough'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||
})
|
||||
|
||||
describe('WalkthroughInvite', () => {
|
||||
it('offers the tour to a new user', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden once already seen', () => {
|
||||
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Getting started" launches the tour', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByText('Getting started'))
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(true)
|
||||
// Invite hides while the tour is active.
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Don\'t show again" persists and hides', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByText("Don't show again"))
|
||||
expect(readWalkthrough().status).toBe('skipped')
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Not now" hides without persisting', () => {
|
||||
renderWithProviders(<WalkthroughInvite />)
|
||||
fireEvent.click(screen.getByLabelText('Not now'))
|
||||
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||
// Not persisted → would be offered again on next load.
|
||||
expect(readWalkthrough().seenVersion).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
|
||||
import { WalkthroughOverlay } from '../WalkthroughOverlay'
|
||||
import { getSteps } from '../steps'
|
||||
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||
|
||||
const TOTAL = getSteps(false).length
|
||||
|
||||
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('WalkthroughOverlay', () => {
|
||||
it('renders nothing while inactive', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the first step and progress when active', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('advances and rewinds between steps', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(screen.getByText('Scan your network')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Previous step'))
|
||||
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skip closes the overlay', () => {
|
||||
renderWithProviders(<WalkthroughOverlay />)
|
||||
startTour()
|
||||
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
|
||||
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getSteps, STEPS } from '../steps'
|
||||
|
||||
describe('getSteps', () => {
|
||||
it('returns every step in full mode', () => {
|
||||
expect(getSteps(false)).toHaveLength(STEPS.length)
|
||||
})
|
||||
|
||||
it('drops backend-only (mode:full) steps in standalone', () => {
|
||||
const standalone = getSteps(true)
|
||||
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
|
||||
expect(standalone.length).toBeLessThan(STEPS.length)
|
||||
const ids = standalone.map((s) => s.id)
|
||||
// Canvas-only steps survive; backend-only steps are filtered out.
|
||||
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
|
||||
expect(ids).not.toContain('scan')
|
||||
expect(ids).not.toContain('scan-history')
|
||||
expect(ids).not.toContain('inventory')
|
||||
expect(ids).not.toContain('imports')
|
||||
})
|
||||
|
||||
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
|
||||
const last = getSteps(true).at(-1)
|
||||
expect(last?.id).toBe('end')
|
||||
expect(last?.link?.href).toContain('github.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Bridge between tour steps and the App's modal/canvas controls. App builds the
|
||||
* concrete implementation from its useState setters and provides it here; the
|
||||
* overlay resolves each step's `action` string to a function on this object.
|
||||
*
|
||||
* `closeAll` is called before every step's action so leftover modals from the
|
||||
* previous step are dismissed.
|
||||
*
|
||||
* Grows as steps land (openInventoryDemo, editDemoNode, openStyle, …).
|
||||
*/
|
||||
export interface WalkthroughActionApi {
|
||||
closeAll: () => void
|
||||
openScanConfig: () => void
|
||||
openScanHistoryDemo: () => void
|
||||
openInventoryDemo: () => void
|
||||
editFirstNode: () => void
|
||||
selectTwoNodes: () => void
|
||||
openStyle: () => void
|
||||
openZigbeeImport: () => void
|
||||
}
|
||||
|
||||
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
|
||||
|
||||
export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider
|
||||
|
||||
export function useWalkthroughActions(): WalkthroughActionApi | null {
|
||||
return useContext(WalkthroughActionsContext)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ScanRun } from '@/components/modals/ScanHistoryModal'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
/**
|
||||
* Canned data injected into the real modals during the tour, so the demo steps
|
||||
* look alive on a fresh install without hitting the backend. Never persisted.
|
||||
*/
|
||||
export const DEMO_SCAN_RUNS: ScanRun[] = [
|
||||
{
|
||||
id: 'tour-scan-running',
|
||||
status: 'running',
|
||||
kind: 'ip',
|
||||
ranges: ['192.168.1.0/24'],
|
||||
devices_found: 12,
|
||||
// 8s ago so the live "Duration" reads a realistic elapsed time.
|
||||
started_at: new Date(Date.now() - 8000).toISOString(),
|
||||
finished_at: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 'tour-scan-done',
|
||||
status: 'done',
|
||||
kind: 'ip',
|
||||
ranges: ['192.168.1.0/24'],
|
||||
devices_found: 9,
|
||||
started_at: new Date(Date.now() - 3600_000).toISOString(),
|
||||
finished_at: new Date(Date.now() - 3560_000).toISOString(),
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEMO_PENDING_DEVICES: PendingDevice[] = [
|
||||
{
|
||||
id: 'tour-dev-1',
|
||||
ip: '192.168.1.20',
|
||||
mac: 'AA:BB:CC:DD:EE:20',
|
||||
hostname: 'synology-nas',
|
||||
os: 'DSM 7.2',
|
||||
services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }],
|
||||
suggested_type: 'nas',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
friendly_name: 'Synology NAS',
|
||||
vendor: 'Synology',
|
||||
model: 'DS920+',
|
||||
discovered_at: new Date(Date.now() - 120_000).toISOString(),
|
||||
canvas_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'tour-dev-2',
|
||||
ip: '192.168.1.30',
|
||||
mac: 'AA:BB:CC:DD:EE:30',
|
||||
hostname: 'pi-hole',
|
||||
os: null,
|
||||
services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }],
|
||||
suggested_type: 'generic',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
friendly_name: 'Pi-hole',
|
||||
vendor: 'Raspberry Pi',
|
||||
model: null,
|
||||
discovered_at: new Date(Date.now() - 90_000).toISOString(),
|
||||
canvas_count: 0,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Declarative Getting Started steps.
|
||||
*
|
||||
* `anchor` is a CSS selector for the element to ring/spotlight (undefined → the
|
||||
* overlay lights any open modal, or centers the card). `action` is a key resolved
|
||||
* against the walkthrough actions context on step enter (opens a modal, injects
|
||||
* demo data, selects nodes, …). `mode: 'full'` marks a backend-only step that is
|
||||
* filtered out of the standalone/public build. `link` renders a link in the card
|
||||
* (used for the closing "questions?" step).
|
||||
*/
|
||||
export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
||||
|
||||
export interface TourStep {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
anchor?: string
|
||||
placement?: StepPlacement
|
||||
action?: string
|
||||
mode?: 'full' | 'all'
|
||||
link?: { label: string; href: string }
|
||||
}
|
||||
|
||||
export const STEPS: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome to Homelable',
|
||||
body: 'Take a quick tour of the main features : scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.',
|
||||
placement: 'center',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'scan',
|
||||
title: 'Scan your network',
|
||||
body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch, discovered devices land in your inventory for review.',
|
||||
anchor: '[data-tour="scan-network"]',
|
||||
placement: 'right',
|
||||
action: 'openScanConfig',
|
||||
mode: 'full',
|
||||
},
|
||||
{
|
||||
id: 'scan-history',
|
||||
title: 'Follow scan progress',
|
||||
body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count. You can stop it here too. (This is example data.)',
|
||||
placement: 'left',
|
||||
action: 'openScanHistoryDemo',
|
||||
mode: 'full',
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Review discovered devices',
|
||||
body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it, with detected services and vendor shown. (Example devices.)',
|
||||
placement: 'left',
|
||||
action: 'openInventoryDemo',
|
||||
mode: 'full',
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
title: 'Your devices on the canvas',
|
||||
body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.',
|
||||
anchor: '.react-flow__node',
|
||||
placement: 'auto',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'node-edit',
|
||||
title: 'Edit a device',
|
||||
body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.',
|
||||
placement: 'left',
|
||||
action: 'editFirstNode',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'text-zone',
|
||||
title: 'Annotate with text & zones',
|
||||
body: 'Add free Text labels and Zones (colored rectangles) to document your layout to group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.',
|
||||
anchor: '[data-tour="add-zone"]',
|
||||
placement: 'right',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'grouping',
|
||||
title: 'Group related devices',
|
||||
body: 'Select multiple nodes, then Create Group to box them together and drag them as one. Perfect for a rack, a site, or a subnet.',
|
||||
anchor: '[data-tour="create-group"]',
|
||||
placement: 'left',
|
||||
action: 'selectTwoNodes',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'style',
|
||||
title: 'Make it yours',
|
||||
body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.',
|
||||
anchor: '[data-tour="style"]',
|
||||
placement: 'bottom',
|
||||
action: 'openStyle',
|
||||
mode: 'all',
|
||||
},
|
||||
{
|
||||
id: 'imports',
|
||||
title: 'Import from your stack',
|
||||
body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning. One click brings the whole topology in.',
|
||||
anchor: '[data-tour="zigbee-import"]',
|
||||
placement: 'right',
|
||||
action: 'openZigbeeImport',
|
||||
mode: 'full',
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
title: "You're all set",
|
||||
body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?",
|
||||
placement: 'center',
|
||||
mode: 'all',
|
||||
link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' },
|
||||
},
|
||||
]
|
||||
|
||||
/** Steps for the current build — backend-only steps are dropped in standalone. */
|
||||
export function getSteps(standalone: boolean): TourStep[] {
|
||||
return STEPS.filter((s) => s.mode !== 'full' || !standalone)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported
|
||||
by both the invite bubble and the overlay) so keyframes exist regardless of
|
||||
which component is mounted. */
|
||||
|
||||
@keyframes wt-invite-enter {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; }
|
||||
|
||||
@keyframes wt-card-enter {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; }
|
||||
|
||||
/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing
|
||||
cyan ring around the primary target (outline so it sits outside the border). */
|
||||
.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; }
|
||||
@keyframes wt-pulse {
|
||||
0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); }
|
||||
50% { outline: 7px solid rgba(0, 212, 255, 0); }
|
||||
}
|
||||
|
||||
/* While the tour runs, app modals must not add their own dim/blur — the overlay's
|
||||
SVG spotlight is the single source of dimming, so a ringed sidebar/toolbar
|
||||
control behind an open modal stays crisp instead of being double-dimmed and
|
||||
blurred by the modal's own backdrop. */
|
||||
body.walkthrough-running [data-slot="dialog-overlay"] {
|
||||
background: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.walkthrough-invite-enter,
|
||||
.walkthrough-card-enter,
|
||||
.walkthrough-spotlight--pulse { animation: none; }
|
||||
}
|
||||
+48
-2
@@ -1,10 +1,27 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from .backend_client import backend
|
||||
|
||||
|
||||
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
|
||||
# Kept in sync manually with frontend/src/types/index.ts NodeType (device types only —
|
||||
# group/groupRect/text are canvas annotations created via dedicated UI actions, not create_node).
|
||||
# Sync is enforced automatically by test_node_types_in_sync_with_frontend (mcp/tests/test_node_types_sync.py).
|
||||
NODE_TYPES = [
|
||||
"isp", "router", "firewall", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap",
|
||||
"camera", "printer", "computer", "laptop", "mobile", "cpl", "docker_host", "docker_container",
|
||||
"generic", "zigbee_coordinator", "zigbee_router", "zigbee_enddevice",
|
||||
"zwave_coordinator", "zwave_router", "zwave_enddevice", "grid", "ups", "battery", "generator",
|
||||
"solar_panel", "inverter", "circuit_breaker", "contactor", "electrical_switch", "socket",
|
||||
"light", "meter", "transformer", "load",
|
||||
]
|
||||
|
||||
# Kept in sync manually with frontend/src/types/index.ts EdgeType.
|
||||
# Sync is enforced automatically by test_edge_types_in_sync_with_frontend (mcp/tests/test_edge_types_sync.py).
|
||||
EDGE_TYPES = [
|
||||
"ethernet", "wifi", "iot", "vlan", "virtual", "cluster", "fibre", "electrical",
|
||||
]
|
||||
|
||||
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
|
||||
# create_node and update_node both expose these so the MCP is symmetric with what the
|
||||
@@ -92,7 +109,7 @@ def _build_tools() -> list[Tool]:
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
|
||||
"type": {"type": "string", "enum": EDGE_TYPES, "default": "ethernet"},
|
||||
"label": {"type": "string"},
|
||||
**_DESIGN_ID_FIELD,
|
||||
},
|
||||
@@ -131,6 +148,17 @@ def _build_tools() -> list[Tool]:
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_node_summaries", description="List all nodes with only id/label/type/status — a lighter, lower-token alternative to list_nodes or get_canvas for when full node detail (ip, services, notes, hardware, properties, ...) isn't needed.", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="get_node", description="Get node(s) by id or by label. Provide exactly one of the two. Lookup by 'id' returns a single full node object. Lookup by 'label' is a case-insensitive substring match and returns a list of full node objects, since labels are not unique.", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Node id. Returns a single node object."},
|
||||
"label": {"type": "string", "description": "Case-insensitive substring match on label. Returns a list of matching node objects."},
|
||||
},
|
||||
}),
|
||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -213,6 +241,11 @@ def _slim_canvas(raw: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _slim_node_summary(n: dict) -> dict:
|
||||
"""Only the fields needed to identify a node and reference it in later calls."""
|
||||
return {"id": n.get("id"), "label": n.get("label"), "type": n.get("type"), "status": n.get("status")}
|
||||
|
||||
|
||||
async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "create_node":
|
||||
return await backend.post("/api/v1/nodes", args)
|
||||
@@ -250,6 +283,19 @@ async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "list_nodes":
|
||||
return await backend.get("/api/v1/nodes")
|
||||
|
||||
if name == "list_node_summaries":
|
||||
nodes = await backend.get("/api/v1/nodes")
|
||||
return [_slim_node_summary(n) for n in nodes]
|
||||
|
||||
if name == "get_node":
|
||||
node_id = args.get("id")
|
||||
label = args.get("label")
|
||||
if node_id:
|
||||
return await backend.get(f"/api/v1/nodes/{node_id}")
|
||||
if label:
|
||||
return await backend.get(f"/api/v1/nodes?label={quote(label)}")
|
||||
raise ValueError("get_node requires either 'id' or 'label'")
|
||||
|
||||
if name == "list_pending_devices":
|
||||
# Backend /scan/pending returns the whole inventory: approved rows stay
|
||||
# listed so the frontend can show a canvas-presence badge. This tool
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
mcp[cli]>=1.0
|
||||
# Upper bound is load-bearing: mcp 2.0 dropped the low-level `Server.list_resources`
|
||||
# / `Server.list_tools` decorator API that app/resources.py and app/tools.py use.
|
||||
# Removing the bound breaks both the tests and the Docker image at import time.
|
||||
mcp[cli]>=1.26,<2
|
||||
httpx>=0.27
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.0
|
||||
|
||||
# Test-only, kept here for the same reason backend/requirements.txt carries its
|
||||
# own: CI installs a single requirements file then runs pytest.
|
||||
# pytest-anyio provides the `anyio` marker and parametrises asyncio + trio.
|
||||
pytest>=9.0
|
||||
pytest-anyio>=0.0.0
|
||||
trio>=0.27
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.tools import EDGE_TYPES
|
||||
|
||||
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
|
||||
|
||||
|
||||
def _frontend_edge_types() -> set[str]:
|
||||
src = FRONTEND_TYPES_FILE.read_text()
|
||||
match = re.search(r"export type EdgeType =([^\n]+)", src)
|
||||
assert match, (
|
||||
"Could not locate the EdgeType union in frontend/src/types/index.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_edge_types_in_sync_with_frontend():
|
||||
frontend_types = _frontend_edge_types()
|
||||
mcp_types = set(EDGE_TYPES)
|
||||
assert mcp_types == frontend_types, (
|
||||
"mcp/app/tools.py EDGE_TYPES is out of sync with frontend/src/types/index.ts EdgeType.\n"
|
||||
f"Missing from EDGE_TYPES: {sorted(frontend_types - mcp_types)}\n"
|
||||
f"Extra in EDGE_TYPES (no longer an edge type?): {sorted(mcp_types - frontend_types)}"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.tools import NODE_TYPES
|
||||
|
||||
FRONTEND_TYPES_FILE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "types" / "index.ts"
|
||||
|
||||
# Canvas annotations, not devices — created via dedicated UI actions (grouping a
|
||||
# selection, "Add Zone", "Add Text"), never through create_node/approve_device.
|
||||
ANNOTATION_TYPES = {"group", "groupRect", "text"}
|
||||
|
||||
|
||||
def _frontend_node_types() -> set[str]:
|
||||
src = FRONTEND_TYPES_FILE.read_text()
|
||||
match = re.search(r"export type NodeType =\n((?:\s*\|\s*'[^']+'\n)+)", src)
|
||||
assert match, (
|
||||
"Could not locate the NodeType union in frontend/src/types/index.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_node_types_in_sync_with_frontend():
|
||||
frontend_types = _frontend_node_types() - ANNOTATION_TYPES
|
||||
mcp_types = set(NODE_TYPES)
|
||||
assert mcp_types == frontend_types, (
|
||||
"mcp/app/tools.py NODE_TYPES is out of sync with frontend/src/types/index.ts NodeType.\n"
|
||||
f"Missing from NODE_TYPES: {sorted(frontend_types - mcp_types)}\n"
|
||||
f"Extra in NODE_TYPES (no longer a device type?): {sorted(mcp_types - frontend_types)}"
|
||||
)
|
||||
@@ -65,6 +65,16 @@ async def test_update_node_properties(mock_backend):
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("node_type", ["firewall", "docker_container", "ups", "camera", "zigbee_router"])
|
||||
async def test_create_node_accepts_previously_rejected_types(mock_backend, node_type):
|
||||
# These types were missing from the old, stale NODE_TYPES enum and were
|
||||
# rejected by the MCP schema even though the backend always accepted them.
|
||||
args = {"type": node_type, "label": "Device"}
|
||||
await _dispatch("create_node", dict(args))
|
||||
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_node(mock_backend):
|
||||
await _dispatch("delete_node", {"id": "42"})
|
||||
@@ -77,6 +87,16 @@ async def test_create_edge(mock_backend):
|
||||
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("edge_type", ["cluster", "fibre", "electrical"])
|
||||
async def test_create_edge_accepts_previously_rejected_types(mock_backend, edge_type):
|
||||
# These types were missing from the old, stale edge type enum and were
|
||||
# rejected by the MCP schema even though the backend always accepted them.
|
||||
args = {"source": "1", "target": "2", "type": edge_type}
|
||||
await _dispatch("create_edge", dict(args))
|
||||
mock_backend.post.assert_called_once_with("/api/v1/edges", args)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_node_with_design_id(mock_backend):
|
||||
# design_id is forwarded to the backend, which attaches the node to that canvas.
|
||||
@@ -205,6 +225,23 @@ def test_create_node_schema_exposes_full_node_fields():
|
||||
assert "enum" in props["type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ["create_node", "update_node", "approve_device"])
|
||||
def test_node_type_enum_excludes_canvas_annotations(tool_name):
|
||||
# group/groupRect/text are canvas annotations created via dedicated UI actions
|
||||
# (grouping a selection, "Add Zone", "Add Text") — not real devices, so they
|
||||
# must never be creatable/settable through the device-oriented node tools.
|
||||
type_enum = set(_tool_schema(tool_name)["type"]["enum"])
|
||||
assert type_enum.isdisjoint({"group", "groupRect", "text"})
|
||||
assert "firewall" in type_enum
|
||||
|
||||
|
||||
def test_create_edge_schema_exposes_full_edge_type_enum():
|
||||
props = _tool_schema("create_edge")
|
||||
type_enum = set(props["type"]["enum"])
|
||||
assert {"cluster", "fibre", "electrical"} <= type_enum
|
||||
assert props["type"]["default"] == "ethernet"
|
||||
|
||||
|
||||
def test_update_node_schema_exposes_full_node_fields():
|
||||
props = _tool_schema("update_node")
|
||||
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||
@@ -233,6 +270,55 @@ async def test_list_nodes(mock_backend):
|
||||
assert result == [{"id": "1", "label": "Freebox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_node_summaries(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[
|
||||
{
|
||||
"id": "1", "label": "Freebox", "type": "router", "status": "online",
|
||||
"ip": "192.168.1.1", "notes": "ISP box", "properties": [{"name": "rack", "value": "A1"}],
|
||||
},
|
||||
])
|
||||
result = await _dispatch("list_node_summaries", {})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes")
|
||||
assert result == [{"id": "1", "label": "Freebox", "type": "router", "status": "online"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_id(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={"id": "42", "label": "pve1", "type": "proxmox"})
|
||||
result = await _dispatch("get_node", {"id": "42"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
|
||||
assert result == {"id": "42", "label": "pve1", "type": "proxmox"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_label(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}])
|
||||
result = await _dispatch("get_node", {"label": "pve"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=pve")
|
||||
assert result == [{"id": "1", "label": "pve1"}, {"id": "2", "label": "pve2"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_by_label_url_encodes(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[])
|
||||
await _dispatch("get_node", {"label": "living room ap"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes?label=living%20room%20ap")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_id_takes_precedence_over_label(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={"id": "42"})
|
||||
await _dispatch("get_node", {"id": "42", "label": "pve"})
|
||||
mock_backend.get.assert_called_once_with("/api/v1/nodes/42")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_node_requires_id_or_label():
|
||||
with pytest.raises(ValueError, match="requires either"):
|
||||
await _dispatch("get_node", {})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_pending_devices(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])
|
||||
|
||||
Reference in New Issue
Block a user