Skip to content

Commit 48e1e96

Browse files
jdrumgooleclaude
andcommitted
First-run setup wizard + solisdash add-user CLI subcommand
Two complementary paths for "I just did `pip install solisdash` and need a way to log in": 1. **Browser wizard at `/setup`.** Shown once, on the first visit to a fresh install. `/login` redirects to `/setup` when the `users` collection is empty; `/setup` redirects to `/login` once any account exists. Both `GET` and `POST` are gated, so a stray `POST /setup` after the first admin exists can never create a second account. The created account gets `role=admin`. 2. **`solisdash add-user --username NAME [--role admin|user]`.** The CLI is now a subcommand parser: - `solisdash` / `solisdash run` — open the dashboard (unchanged). - `solisdash add-user` — prompt for password (via getpass, never on the command line), seed an account. Reads `SOLIS_MONGODB_URI` from env / `.env`. Returns non-zero on bad role / missing config / password mismatch / duplicate username. Top-level run flags (`--port`, `--no-window`, `--debug`, etc.) still work without a subcommand, so the v0.4.0 invocation is preserved. Tests (+14): - 5 in `tests/test_cli.py` for the new subparser shape (defaults, `run` flags, `add-user` username/role parsing, role validation). - 9 in `tests/test_app_setup.py` covering: wizard renders when no users, `/login` redirects to `/setup` when no users, `/login` renders normally once a user exists, `/setup` POST creates an admin and redirects to `/login`, password mismatch / empty password / empty username re-render with errors and don't create a user, and the "lock after first user" invariant on both `GET /setup` and `POST /setup`. Browser smoke against the live setup confirmed: GET / on a fresh install → 303 /login → 303 /setup, wizard form renders; POST /setup creates the admin and redirects to /login; a second POST /setup redirects without creating a duplicate; /login now renders the sign-in form. Bump to 0.5.0 (new user-facing surface). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c65ad82 commit 48e1e96

8 files changed

Lines changed: 438 additions & 49 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "solisdash"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "Self-hosted dashboard for the SolisCloud (Ginlong Solis) platform"
55
readme = "README.md"
66
requires-python = ">=3.10"

‎src/solisdash/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.4.0"
1+
__version__ = "0.5.0"

‎src/solisdash/app.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from solisdash.alarms import ALARM_STATE_LABELS, AlarmService
2424
from solisdash.auth import (
2525
authenticate,
26+
create_user,
2627
get_current_user,
2728
redirect_to,
2829
require_user,
@@ -334,16 +335,74 @@ async def tiles_fragment(
334335
)
335336

336337

338+
async def _users_exist(db: AsyncDatabase[dict[str, Any]]) -> bool:
339+
"""True once any user has been created. The setup wizard is locked
340+
behind this — once a user exists, `/setup` returns to `/login` forever."""
341+
return await db["users"].count_documents({}, limit=1) > 0
342+
343+
337344
@app.get("/login", response_class=HTMLResponse, response_model=None)
338345
async def login_form(
339346
request: Request,
340347
user: dict[str, Any] | None = Depends(get_current_user),
348+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
341349
) -> HTMLResponse | RedirectResponse:
342350
if user is not None:
343351
return redirect_to("/")
352+
if not await _users_exist(db):
353+
return redirect_to("/setup")
344354
return templates.TemplateResponse(request, "login.html", {"error": None})
345355

346356

357+
@app.get("/setup", response_class=HTMLResponse, response_model=None)
358+
async def setup_form(
359+
request: Request,
360+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
361+
) -> HTMLResponse | RedirectResponse:
362+
"""First-run admin-creation wizard. Locked once any account exists."""
363+
if await _users_exist(db):
364+
return redirect_to("/login")
365+
return templates.TemplateResponse(
366+
request, "setup.html", {"error": None, "username": ""}
367+
)
368+
369+
370+
@app.post("/setup", response_class=HTMLResponse, response_model=None)
371+
async def setup_submit(
372+
request: Request,
373+
username: str = Form(...),
374+
password: str = Form(...),
375+
confirm: str = Form(...),
376+
db: AsyncDatabase[dict[str, Any]] = Depends(get_db),
377+
) -> HTMLResponse | RedirectResponse:
378+
if await _users_exist(db):
379+
# Race or replay — someone else already created the first user.
380+
return redirect_to("/login")
381+
if not username.strip():
382+
return templates.TemplateResponse(
383+
request,
384+
"setup.html",
385+
{"error": "Username must not be empty.", "username": username},
386+
status_code=400,
387+
)
388+
if password != confirm:
389+
return templates.TemplateResponse(
390+
request,
391+
"setup.html",
392+
{"error": "Passwords do not match.", "username": username},
393+
status_code=400,
394+
)
395+
if not password:
396+
return templates.TemplateResponse(
397+
request,
398+
"setup.html",
399+
{"error": "Password must not be empty.", "username": username},
400+
status_code=400,
401+
)
402+
await create_user(db, username=username.strip(), password=password, role="admin")
403+
return redirect_to("/login")
404+
405+
347406
@app.post("/login", response_class=HTMLResponse, response_model=None)
348407
async def login_submit(
349408
request: Request,

‎src/solisdash/cli.py‎

Lines changed: 169 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
1-
"""`solisdash` CLI: run the FastAPI app inside a native pywebview window.
1+
"""`solisdash` CLI: run the dashboard, or manage users.
22
3-
The CLI is for "desktop app" runs on the developer's machine. For a
4-
real deployment use `uv run python -m invoke start`; for tests use
5-
`pytest`. This entry point keeps the same FastAPI app, just hides
6-
the browser shell behind a Cocoa/GTK/EdgeWebView2 window with a
7-
native menu and the sun icon.
3+
Subcommands:
4+
- (no subcommand) / `run` — open the dashboard inside a pywebview window
5+
- `add-user` — seed an account (the only path before login works)
6+
7+
Both end up calling the same `solisdash.app` and `solisdash.auth` code that
8+
`uv run python -m invoke …` uses; they're just here so anyone who installed
9+
the package from PyPI doesn't need a dev checkout to bootstrap.
810
"""
911

1012
from __future__ import annotations
1113

1214
import argparse
15+
import asyncio
16+
import getpass
1317
import logging
1418
import socket
1519
import sys
1620
import threading
1721
import time
1822
import webbrowser
23+
from collections.abc import Callable
1924
from importlib.resources import files
2025
from pathlib import Path
2126

@@ -173,48 +178,10 @@ def launch_webview(base_url: str, *, debug: bool = False) -> None:
173178
webview.start(**start_kwargs) # type: ignore[arg-type]
174179

175180

176-
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
177-
parser = argparse.ArgumentParser(
178-
prog="solisdash",
179-
description="Run Solisdash inside a native window (pywebview).",
180-
)
181-
parser.add_argument(
182-
"--host",
183-
default="127.0.0.1",
184-
help="Address uvicorn binds to. Default: 127.0.0.1 (loopback only).",
185-
)
186-
parser.add_argument(
187-
"--port",
188-
type=int,
189-
default=0,
190-
help="Port to bind. 0 (default) picks a free one.",
191-
)
192-
parser.add_argument(
193-
"--no-window",
194-
action="store_true",
195-
help="Run uvicorn without opening pywebview (useful for ssh / headless smoke).",
196-
)
197-
parser.add_argument(
198-
"--debug",
199-
action="store_true",
200-
help="Open pywebview's dev tools and use uvicorn debug logging.",
201-
)
202-
parser.add_argument(
203-
"--version",
204-
action="version",
205-
version=f"solisdash {__version__}",
206-
)
207-
parser.add_argument(
208-
"--timeout",
209-
type=float,
210-
default=15.0,
211-
help="Seconds to wait for the app to become healthy before giving up.",
212-
)
213-
return parser.parse_args(argv)
181+
# --- subcommand: run -------------------------------------------------------
214182

215183

216-
def main(argv: list[str] | None = None) -> int:
217-
args = parse_args(argv)
184+
def cmd_run(args: argparse.Namespace) -> int:
218185
logging.basicConfig(
219186
level=logging.DEBUG if args.debug else logging.INFO,
220187
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
@@ -248,5 +215,161 @@ def main(argv: list[str] | None = None) -> int:
248215
return 0
249216

250217

218+
# --- subcommand: add-user --------------------------------------------------
219+
220+
221+
def _prompt_password(getpass_fn: Callable[[str], str] = getpass.getpass) -> str | None:
222+
"""Prompt for a password twice; return the value or `None` on mismatch/empty.
223+
224+
Indirection on `getpass.getpass` is so tests can inject a stub.
225+
"""
226+
pw1 = getpass_fn("Password: ")
227+
pw2 = getpass_fn("Confirm: ")
228+
if pw1 != pw2:
229+
print("passwords do not match", file=sys.stderr)
230+
return None
231+
if not pw1:
232+
print("password must not be empty", file=sys.stderr)
233+
return None
234+
return pw1
235+
236+
237+
def cmd_add_user(args: argparse.Namespace) -> int:
238+
"""Create a user in the configured Mongo. Prompts for the password."""
239+
from pymongo import AsyncMongoClient
240+
from pymongo.errors import DuplicateKeyError
241+
242+
from solisdash.auth import ROLES, create_user
243+
from solisdash.config import get_settings
244+
from solisdash.db import ensure_indexes
245+
246+
if args.role not in ROLES:
247+
print(f"role must be one of {ROLES}, got {args.role!r}", file=sys.stderr)
248+
return 2
249+
250+
settings = get_settings()
251+
if not settings.SOLIS_MONGODB_URI:
252+
print("SOLIS_MONGODB_URI is not set", file=sys.stderr)
253+
return 2
254+
255+
password = _prompt_password()
256+
if password is None:
257+
return 2
258+
259+
async def _go() -> int:
260+
client: AsyncMongoClient[dict[str, object]] = AsyncMongoClient(
261+
settings.SOLIS_MONGODB_URI
262+
)
263+
try:
264+
db = client[settings.SOLIS_MONGODB_DB]
265+
await ensure_indexes(db)
266+
try:
267+
await create_user(
268+
db, username=args.username, password=password, role=args.role
269+
)
270+
except DuplicateKeyError:
271+
print(f"user {args.username!r} already exists", file=sys.stderr)
272+
return 1
273+
print(f"created {args.role} {args.username!r}")
274+
return 0
275+
finally:
276+
await client.close()
277+
278+
return asyncio.run(_go())
279+
280+
281+
# --- argparse plumbing -----------------------------------------------------
282+
283+
284+
def _add_run_args(p: argparse.ArgumentParser) -> None:
285+
p.add_argument(
286+
"--host",
287+
default="127.0.0.1",
288+
help="Address uvicorn binds to. Default: 127.0.0.1 (loopback only).",
289+
)
290+
p.add_argument(
291+
"--port",
292+
type=int,
293+
default=0,
294+
help="Port to bind. 0 (default) picks a free one.",
295+
)
296+
p.add_argument(
297+
"--no-window",
298+
action="store_true",
299+
help="Run uvicorn without opening pywebview (useful for ssh / headless smoke).",
300+
)
301+
p.add_argument(
302+
"--debug",
303+
action="store_true",
304+
help="Open pywebview's dev tools and use uvicorn debug logging.",
305+
)
306+
p.add_argument(
307+
"--timeout",
308+
type=float,
309+
default=15.0,
310+
help="Seconds to wait for the app to become healthy before giving up.",
311+
)
312+
313+
314+
def _make_parser() -> argparse.ArgumentParser:
315+
parser = argparse.ArgumentParser(
316+
prog="solisdash",
317+
description=(
318+
"Solisdash CLI. With no subcommand, runs the dashboard in a "
319+
"native pywebview window."
320+
),
321+
)
322+
parser.add_argument(
323+
"--version",
324+
action="version",
325+
version=f"solisdash {__version__}",
326+
)
327+
# The run-mode flags are also accepted at the top level so
328+
# `solisdash --port 9000` keeps working without an explicit subcommand.
329+
_add_run_args(parser)
330+
331+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
332+
333+
run_p = subparsers.add_parser(
334+
"run",
335+
help="Run the dashboard in a pywebview window (default).",
336+
description="Run the dashboard. Equivalent to `solisdash` with no subcommand.",
337+
)
338+
_add_run_args(run_p)
339+
340+
add_p = subparsers.add_parser(
341+
"add-user",
342+
help="Create a Solisdash account (prompts for password).",
343+
description=(
344+
"Create a Solisdash account in the configured MongoDB. Reads "
345+
"SOLIS_MONGODB_URI from environment / .env. Prompts for the "
346+
"password — it's never accepted on the command line."
347+
),
348+
)
349+
add_p.add_argument(
350+
"--username", required=True, help="Username for the new account."
351+
)
352+
add_p.add_argument(
353+
"--role",
354+
default="user",
355+
choices=("admin", "user"),
356+
help="Account role (default: user).",
357+
)
358+
359+
return parser
360+
361+
362+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
363+
return _make_parser().parse_args(argv)
364+
365+
366+
def main(argv: list[str] | None = None) -> int:
367+
args = parse_args(argv)
368+
if args.command == "add-user":
369+
return cmd_add_user(args)
370+
# Both `solisdash` and `solisdash run` end up here.
371+
return cmd_run(args)
372+
373+
251374
if __name__ == "__main__":
252375
sys.exit(main())

‎src/solisdash/templates/setup.html‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{% extends "base.html" %}
2+
{% block title %}Welcome · Solisdash{% endblock %}
3+
{% block content %}
4+
<article class="login-card">
5+
<hgroup>
6+
<h2>Welcome to Solisdash</h2>
7+
<p>
8+
No accounts exist yet. Create the first administrator below.
9+
This page is shown only on a fresh install — once any account
10+
exists, it stops working.
11+
</p>
12+
</hgroup>
13+
14+
{% if error %}
15+
<p role="alert" class="error">{{ error }}</p>
16+
{% endif %}
17+
18+
<form method="post" action="/setup">
19+
<label for="username">Username</label>
20+
<input
21+
id="username"
22+
name="username"
23+
type="text"
24+
autocomplete="username"
25+
value="{{ username|default('') }}"
26+
required
27+
autofocus
28+
/>
29+
30+
<label for="password">Password</label>
31+
<input
32+
id="password"
33+
name="password"
34+
type="password"
35+
autocomplete="new-password"
36+
required
37+
/>
38+
39+
<label for="confirm">Confirm password</label>
40+
<input
41+
id="confirm"
42+
name="confirm"
43+
type="password"
44+
autocomplete="new-password"
45+
required
46+
/>
47+
48+
<button type="submit">Create administrator</button>
49+
</form>
50+
</article>
51+
{% endblock %}

0 commit comments

Comments
 (0)