add auth
This commit is contained in:
22 files changed
+1028
-19
No files matched your search
@@ -27,6 +27,23 @@ SCHEDULER_ENABLED=true
|
|||||||
TELEGRAM_BOT_ENABLED=true
|
TELEGRAM_BOT_ENABLED=true
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ---- web login (closes the browser UI to everyone but you) -----------------
|
||||||
|
# Unset: the browser UI is as open as the API ("off by default" above). Set
|
||||||
|
# either of these and the login screen appears — the desktop app is untouched,
|
||||||
|
# it keeps using API_TOKEN.
|
||||||
|
#
|
||||||
|
# Password: set once, seeds a hash into the DB on first boot; from then on
|
||||||
|
# change it in Settings -> Access or with `make set-password` on the server.
|
||||||
|
AUTH_USERNAME=timka
|
||||||
|
AUTH_PASSWORD=
|
||||||
|
#
|
||||||
|
# Telegram login (the "Login Widget", not the bot): needs TELEGRAM_BOT_TOKEN
|
||||||
|
# above, this site's HTTPS domain registered with @BotFather via /setdomain,
|
||||||
|
# and your numeric Telegram user id (DM @userinfobot to get it — it is not
|
||||||
|
# the same as TELEGRAM_CHAT_ID when that is a group).
|
||||||
|
TELEGRAM_OWNER_ID=
|
||||||
|
TELEGRAM_BOT_USERNAME=
|
||||||
|
|
||||||
# ---- Jira (required for the Jira source) -----------------------------------
|
# ---- Jira (required for the Jira source) -----------------------------------
|
||||||
JIRA_BASE_URL=https://your-site.atlassian.net
|
JIRA_BASE_URL=https://your-site.atlassian.net
|
||||||
JIRA_EMAIL=you@example.com
|
JIRA_EMAIL=you@example.com
|
||||||
|
|||||||
@@ -39,4 +39,7 @@ helper-install: ## Install the privileged blocking helper (asks for sudo)
|
|||||||
report: ## Build and send today's report right now
|
report: ## Build and send today's report right now
|
||||||
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
|
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
|
||||||
|
|
||||||
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report
|
set-password: ## Set/rotate the web login password (asks for a confirm)
|
||||||
|
docker compose exec app python -m app.cli set-password
|
||||||
|
|
||||||
|
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report set-password
|
||||||
@@ -179,6 +179,8 @@ and you can fill everything in from **Settings**.
|
|||||||
| `JIRA_*`, `TODOIST_*`, `GOOGLE_*`, `LAUNDRY_*` | seeds for the sources |
|
| `JIRA_*`, `TODOIST_*`, `GOOGLE_*`, `LAUNDRY_*` | seeds for the sources |
|
||||||
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | the bot |
|
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | the bot |
|
||||||
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off |
|
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off |
|
||||||
|
| `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) |
|
||||||
|
| `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,7 +202,7 @@ and you can fill everything in from **Settings**.
|
|||||||
|
|
||||||
* `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
|
* `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
|
||||||
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module
|
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module
|
||||||
per integration; `app/api/` is a thin HTTP layer. 44 tests under `backend/tests`.
|
per integration; `app/api/` is a thin HTTP layer. 63 tests under `backend/tests`.
|
||||||
* `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
|
* `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
|
||||||
served from the same origin (so there is no CORS to configure).
|
served from the same origin (so there is no CORS to configure).
|
||||||
* **The look** — warm paper, ink type, hairline rules instead of boxes and
|
* **The look** — warm paper, ink type, hairline rules instead of boxes and
|
||||||
@@ -232,10 +234,42 @@ python -m app.cli refresh # pull every source once
|
|||||||
python -m app.cli briefing # send the morning briefing
|
python -m app.cli briefing # send the morning briefing
|
||||||
python -m app.cli report # build and send today's report
|
python -m app.cli report # build and send today's report
|
||||||
python -m app.cli stats # print the numbers as JSON
|
python -m app.cli stats # print the numbers as JSON
|
||||||
|
python -m app.cli set-password # set/rotate the web login password (prompts, never argv)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Going public
|
||||||
|
|
||||||
|
Kadence is still single-user: `AUTH_USERNAME`/`AUTH_PASSWORD` and
|
||||||
|
`TELEGRAM_OWNER_ID` all name *you*, not a user table. What they do is close the
|
||||||
|
**browser UI** to everyone else once the backend is reachable beyond your own
|
||||||
|
machine — `API_TOKEN` already protected the raw API; this is the same idea with
|
||||||
|
a login screen instead of a header the desktop app fills in for you.
|
||||||
|
|
||||||
|
1. **Password.** Set `AUTH_PASSWORD` (and optionally `AUTH_USERNAME`, default
|
||||||
|
`timka`) in `.env` and restart — the hash is seeded into the database on
|
||||||
|
first boot, the plaintext is never stored. A browser now has to sign in at
|
||||||
|
`/`; the desktop app is unaffected, it keeps sending `X-API-Token`. Rotate
|
||||||
|
the password later from **Settings → Access** (while signed in) or by
|
||||||
|
running `make set-password` on the server — the UI never accepts a new
|
||||||
|
password over an unauthenticated connection.
|
||||||
|
2. **Telegram login, in addition** (optional). You already have the bot token
|
||||||
|
(`TELEGRAM_BOT_TOKEN`) for notifications; login reuses it:
|
||||||
|
* DM [@userinfobot](https://t.me/userinfobot) for your numeric Telegram id
|
||||||
|
→ `TELEGRAM_OWNER_ID`.
|
||||||
|
* Tell [@BotFather](https://t.me/BotFather) `/setdomain` and give it this
|
||||||
|
site's **HTTPS** domain — the Login Widget refuses to render anywhere else.
|
||||||
|
* Set `TELEGRAM_BOT_USERNAME` (no `@`) in `.env`, or fill it in under
|
||||||
|
**Settings → Access** once the UI is reachable.
|
||||||
|
* Restart. The login screen grows a "Log in with Telegram" button under the
|
||||||
|
password form.
|
||||||
|
3. Either credential alone is enough to close the UI; set both and either one
|
||||||
|
signs you in. With neither set, the browser stays as open as the bare API
|
||||||
|
always was — nothing changes until you opt in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
* The desktop app never runs as root. The only privileged code is
|
* The desktop app never runs as root. The only privileged code is
|
||||||
@@ -245,6 +279,14 @@ python -m app.cli stats # print the numbers as JSON
|
|||||||
hostname. It is authorised by polkit (`auth_admin_keep` by default — one prompt
|
hostname. It is authorised by polkit (`auth_admin_keep` by default — one prompt
|
||||||
per session; `install.sh --passwordless` drops that for one user).
|
per session; `install.sh --passwordless` drops that for one user).
|
||||||
* Tokens live in the database and in `.env`, are masked in every API response,
|
* Tokens live in the database and in `.env`, are masked in every API response,
|
||||||
and are never sent to the browser in full.
|
and are never sent to the browser in full. The password is never stored at
|
||||||
* Set `API_TOKEN` before exposing the backend beyond localhost; put it behind TLS
|
all — only a salted PBKDF2 hash.
|
||||||
if it leaves your network.
|
* Set `API_TOKEN` before exposing the backend beyond localhost (protects the
|
||||||
|
raw API — desktop app, scripts, cron); see [Going public](#going-public) to
|
||||||
|
close the **browser UI** the same way, with a login instead of a header.
|
||||||
|
* The login cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever the
|
||||||
|
request arrives as HTTPS (directly or via `X-Forwarded-Proto` from a reverse
|
||||||
|
proxy) — put the backend behind TLS before relying on it over the open
|
||||||
|
internet, same as `API_TOKEN` always needed.
|
||||||
|
* Login attempts are rate-limited per source IP (in-process; fine for one
|
||||||
|
instance, one user).
|
||||||
+30
-9
@@ -1,32 +1,53 @@
|
|||||||
"""Shared dependencies: the database session and the optional shared secret."""
|
"""Shared dependencies: the database session and the two ways into the API."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, status
|
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import env_settings
|
from app.config import env_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.services import auth_service
|
||||||
from app.services.settings_service import load_settings
|
from app.services.settings_service import load_settings
|
||||||
from app.settings_schema import AppSettings
|
from app.settings_schema import AppSettings
|
||||||
|
|
||||||
DbSession = Annotated[Session, Depends(get_session)]
|
DbSession = Annotated[Session, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
|
def require_auth(
|
||||||
"""Guard every API call when `API_TOKEN` is set.
|
session: DbSession,
|
||||||
|
x_api_token: Annotated[str | None, Header()] = None,
|
||||||
|
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Guard every API call.
|
||||||
|
|
||||||
Off by default: on a laptop the backend is bound to localhost and a token is
|
Two independent gates, either of which lets a request through:
|
||||||
friction. Set it the moment the container is reachable from anywhere else.
|
|
||||||
|
* `X-API-Token` against `API_TOKEN` — the desktop app and scripts, off by
|
||||||
|
default (friction-free on a laptop; set the moment the backend leaves it).
|
||||||
|
* a `kadence_session` cookie from `/api/auth/login*` — the browser, closed
|
||||||
|
the moment a password or Telegram login is configured in Settings → Access.
|
||||||
|
|
||||||
|
With neither `API_TOKEN` nor a login configured, the API stays open, exactly
|
||||||
|
as before this existed.
|
||||||
"""
|
"""
|
||||||
expected = env_settings().api_token
|
expected = env_settings().api_token
|
||||||
if not expected:
|
if expected and x_api_token == expected:
|
||||||
|
return
|
||||||
|
if auth_service.valid_session(session, kadence_session):
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = load_settings(session)
|
||||||
|
login_required = bool(settings.auth.password_hash) or (
|
||||||
|
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||||
|
)
|
||||||
|
if not expected and not login_required:
|
||||||
return
|
return
|
||||||
if x_api_token != expected:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Sign in, or send a valid X-API-Token.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Signing in and out of the browser UI.
|
||||||
|
|
||||||
|
Mounted directly on the app, not through `api_router` — these endpoints have
|
||||||
|
to be reachable *before* a session exists, so they never carry the
|
||||||
|
`require_auth` dependency. `change_password` checks for a live session itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Cookie, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.api.deps import DbSession
|
||||||
|
from app.services import auth_service, settings_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_COOKIE = auth_service.SESSION_COOKIE
|
||||||
|
|
||||||
|
|
||||||
|
def _client_key(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https(request: Request) -> bool:
|
||||||
|
# TLS usually terminates at a reverse proxy in front of this container, so
|
||||||
|
# `request.url.scheme` alone would read "http" even on an HTTPS deployment.
|
||||||
|
return request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, request: Request, token: str, max_age: int) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
_COOKIE,
|
||||||
|
token,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_https(request),
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(
|
||||||
|
session: DbSession,
|
||||||
|
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> dict:
|
||||||
|
settings = settings_service.load_settings(session)
|
||||||
|
row = auth_service.valid_session(session, kadence_session)
|
||||||
|
login_required = bool(settings.auth.password_hash) or (
|
||||||
|
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"authenticated": row is not None or not login_required,
|
||||||
|
"login_required": login_required,
|
||||||
|
"username": settings.auth.username,
|
||||||
|
"password_login_enabled": bool(settings.auth.password_hash),
|
||||||
|
"telegram_login_enabled": settings.auth.telegram_login_enabled
|
||||||
|
and bool(settings.auth.telegram_owner_id),
|
||||||
|
"telegram_bot_username": settings.auth.telegram_bot_username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(
|
||||||
|
session: DbSession,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
body: dict[str, Any] = Body(...),
|
||||||
|
) -> dict:
|
||||||
|
key = f"password:{_client_key(request)}"
|
||||||
|
if auth_service.rate_limited(key):
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
|
||||||
|
|
||||||
|
settings = settings_service.load_settings(session)
|
||||||
|
username = str(body.get("username", ""))
|
||||||
|
password = str(body.get("password", ""))
|
||||||
|
ok = (
|
||||||
|
bool(settings.auth.password_hash)
|
||||||
|
and username == settings.auth.username
|
||||||
|
and auth_service.verify_password(password, settings.auth.password_hash)
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
auth_service.record_failure(key)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong username or password.")
|
||||||
|
|
||||||
|
auth_service.record_success(key)
|
||||||
|
row = auth_service.create_session(session, "password", settings.auth.session_days)
|
||||||
|
_set_cookie(response, request, row.token, settings.auth.session_days * 86400)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login/telegram")
|
||||||
|
def login_telegram(
|
||||||
|
session: DbSession,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
body: dict[str, Any] = Body(...),
|
||||||
|
) -> dict:
|
||||||
|
key = f"telegram:{_client_key(request)}"
|
||||||
|
if auth_service.rate_limited(key):
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Wait a few minutes.")
|
||||||
|
|
||||||
|
settings = settings_service.load_settings(session)
|
||||||
|
telegram = settings.notifications.telegram
|
||||||
|
auth = settings.auth
|
||||||
|
ok = (
|
||||||
|
auth.telegram_login_enabled
|
||||||
|
and bool(auth.telegram_owner_id)
|
||||||
|
and bool(telegram.bot_token)
|
||||||
|
and auth_service.verify_telegram_payload(body, telegram.bot_token)
|
||||||
|
and str(body.get("id")) == auth.telegram_owner_id
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
auth_service.record_failure(key)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Telegram sign-in did not check out.")
|
||||||
|
|
||||||
|
auth_service.record_success(key)
|
||||||
|
row = auth_service.create_session(session, "telegram", auth.session_days)
|
||||||
|
_set_cookie(response, request, row.token, auth.session_days * 86400)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
session: DbSession,
|
||||||
|
response: Response,
|
||||||
|
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> dict:
|
||||||
|
auth_service.delete_session(session, kadence_session)
|
||||||
|
response.delete_cookie(_COOKIE, path="/")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password")
|
||||||
|
def change_password(
|
||||||
|
session: DbSession,
|
||||||
|
body: dict[str, Any] = Body(...),
|
||||||
|
kadence_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Requires a live session — set the first password via `AUTH_PASSWORD` or
|
||||||
|
`make set-password` instead, since there is nothing to authenticate with
|
||||||
|
yet on a fresh install."""
|
||||||
|
if auth_service.valid_session(session, kadence_session) is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sign in first.")
|
||||||
|
|
||||||
|
settings = settings_service.load_settings(session)
|
||||||
|
current = str(body.get("current_password", ""))
|
||||||
|
new = str(body.get("new_password", ""))
|
||||||
|
if settings.auth.password_hash and not auth_service.verify_password(
|
||||||
|
current, settings.auth.password_hash
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Current password is wrong.")
|
||||||
|
if len(new) < 8:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Use at least 8 characters.")
|
||||||
|
|
||||||
|
settings.auth.password_hash = auth_service.hash_password(new)
|
||||||
|
settings_service.save_settings(session, settings)
|
||||||
|
return {"ok": True}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ def main() -> int:
|
|||||||
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
|
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
|
||||||
sub.add_parser("stats", help="print a summary as JSON")
|
sub.add_parser("stats", help="print a summary as JSON")
|
||||||
|
|
||||||
|
setpw = sub.add_parser("set-password", help="set or replace the web login password")
|
||||||
|
setpw.add_argument("--username", default=None, help="defaults to the current one")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "serve":
|
if args.command == "serve":
|
||||||
@@ -69,6 +73,21 @@ def main() -> int:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif args.command == "set-password":
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
password = getpass.getpass("New web login password: ")
|
||||||
|
if password != getpass.getpass("Confirm: "):
|
||||||
|
print("Passwords did not match.")
|
||||||
|
return 1
|
||||||
|
if len(password) < 8:
|
||||||
|
print("Use at least 8 characters.")
|
||||||
|
return 1
|
||||||
|
if args.username:
|
||||||
|
settings.auth.username = args.username
|
||||||
|
settings.auth.password_hash = auth_service.hash_password(password)
|
||||||
|
settings_service.save_settings(session, settings)
|
||||||
|
print(f"Password set for {settings.auth.username!r}.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ class EnvSettings(BaseSettings):
|
|||||||
telegram_chat_id: str = ""
|
telegram_chat_id: str = ""
|
||||||
slack_webhook_url: str = ""
|
slack_webhook_url: str = ""
|
||||||
|
|
||||||
|
# ---- web login (closes the browser UI; the desktop app keeps API_TOKEN) ---
|
||||||
|
auth_username: str = "timka"
|
||||||
|
auth_password: str = ""
|
||||||
|
"""Set once; the hash is seeded into settings on first boot, never stored here."""
|
||||||
|
|
||||||
|
telegram_owner_id: str = ""
|
||||||
|
"""Your numeric Telegram user id — https://t.me/userinfobot says it in a DM."""
|
||||||
|
|
||||||
|
telegram_bot_username: str = ""
|
||||||
|
"""Without the `@`. Needed to render the Telegram Login Widget."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origin_list(self) -> list[str]:
|
def cors_origin_list(self) -> list[str]:
|
||||||
if self.cors_origins.strip() == "*":
|
if self.cors_origins.strip() == "*":
|
||||||
|
|||||||
+10
-3
@@ -20,7 +20,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
from app.api import api_router
|
from app.api import api_router
|
||||||
from app.api.deps import require_token
|
from app.api.deps import require_auth
|
||||||
|
from app.api.routes_auth import router as auth_router
|
||||||
from app.config import env_settings
|
from app.config import env_settings
|
||||||
from app.db import init_db, session_scope
|
from app.db import init_db, session_scope
|
||||||
from app.services import scheduler, settings_service, telegram_bot
|
from app.services import scheduler, settings_service, telegram_bot
|
||||||
@@ -70,7 +71,8 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(api_router, dependencies=[Depends(require_token)])
|
app.include_router(auth_router)
|
||||||
|
app.include_router(api_router, dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health", tags=["meta"])
|
@app.get("/api/health", tags=["meta"])
|
||||||
@@ -82,9 +84,14 @@ def health() -> dict:
|
|||||||
def info() -> dict:
|
def info() -> dict:
|
||||||
"""What a client needs to know before it has a token: who we are, what is on."""
|
"""What a client needs to know before it has a token: who we are, what is on."""
|
||||||
env = env_settings()
|
env = env_settings()
|
||||||
|
with session_scope() as session:
|
||||||
|
settings = settings_service.load_settings(session)
|
||||||
|
login_required = bool(settings.auth.password_hash) or (
|
||||||
|
settings.auth.telegram_login_enabled and bool(settings.auth.telegram_owner_id)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"auth_required": bool(env.api_token),
|
"auth_required": bool(env.api_token) or login_required,
|
||||||
"scheduler": env.scheduler_enabled,
|
"scheduler": env.scheduler_enabled,
|
||||||
"jobs": scheduler.describe(),
|
"jobs": scheduler.describe(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,22 @@ class DailyReport(Base):
|
|||||||
score: Mapped[float] = mapped_column(Float, default=0.0)
|
score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSession(Base):
|
||||||
|
"""A browser login, created by a password or Telegram sign-in.
|
||||||
|
|
||||||
|
The token is the cookie value itself (a random opaque string) — no server
|
||||||
|
side id/secret split needed for a single-user app. Expired rows are pruned
|
||||||
|
lazily whenever a session is looked up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "auth_sessions"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
method: Mapped[str] = mapped_column(String(16)) # password | telegram
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|
||||||
class EventLog(Base):
|
class EventLog(Base):
|
||||||
"""Small append-only log used for "did we already send this?" questions.
|
"""Small append-only log used for "did we already send this?" questions.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Who is allowed into the browser UI.
|
||||||
|
|
||||||
|
Two ways in, both ending in the same thing — a row in `auth_sessions` and a
|
||||||
|
cookie that names it:
|
||||||
|
|
||||||
|
* a password, hashed with stdlib PBKDF2 (no new dependency for one check);
|
||||||
|
* the Telegram Login Widget, verified with the documented HMAC scheme against
|
||||||
|
the bot token already configured for notifications.
|
||||||
|
|
||||||
|
The desktop app and scripts are untouched — they keep using `X-API-Token`
|
||||||
|
(see `app.api.deps`). This module only ever runs for the browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import AuthSession
|
||||||
|
|
||||||
|
PBKDF2_ALGO = "pbkdf2_sha256"
|
||||||
|
PBKDF2_ITERATIONS = 260_000
|
||||||
|
SESSION_COOKIE = "kadence_session"
|
||||||
|
TELEGRAM_AUTH_MAX_AGE = 86400 # a login link older than a day is refused
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Passwords
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS)
|
||||||
|
return f"{PBKDF2_ALGO}${PBKDF2_ITERATIONS}${salt}${digest.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
algo, iterations, salt, expected = stored_hash.split("$")
|
||||||
|
if algo != PBKDF2_ALGO:
|
||||||
|
return False
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(digest.hex(), expected)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Telegram Login Widget
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def verify_telegram_payload(data: dict[str, Any], bot_token: str) -> bool:
|
||||||
|
"""https://core.telegram.org/widgets/login#checking-authorization"""
|
||||||
|
received_hash = data.get("hash")
|
||||||
|
if not received_hash or not bot_token:
|
||||||
|
return False
|
||||||
|
auth_date = data.get("auth_date")
|
||||||
|
try:
|
||||||
|
if time.time() - int(auth_date) > TELEGRAM_AUTH_MAX_AGE:
|
||||||
|
return False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
check_string = "\n".join(
|
||||||
|
f"{key}={data[key]}" for key in sorted(data) if key not in ("hash",)
|
||||||
|
)
|
||||||
|
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
||||||
|
computed = hmac.new(secret_key, check_string.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(computed, received_hash)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Sessions
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def create_session(session: Session, method: str, session_days: int) -> AuthSession:
|
||||||
|
_prune_expired(session)
|
||||||
|
row = AuthSession(
|
||||||
|
token=secrets.token_urlsafe(32),
|
||||||
|
method=method,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=session_days),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(dt: datetime) -> datetime:
|
||||||
|
"""SQLite drops the tzinfo it was stored with; Postgres does not."""
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_session(session: Session, token: str | None) -> AuthSession | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
row = session.get(AuthSession, token)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if _aware(row.expires_at) < datetime.now(timezone.utc):
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
return None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(session: Session, token: str | None) -> None:
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
session.execute(delete(AuthSession).where(AuthSession.token == token))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_expired(session: Session) -> None:
|
||||||
|
session.execute(delete(AuthSession).where(AuthSession.expires_at < datetime.now(timezone.utc)))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Login rate limiting (in-process; one instance, one user)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_MAX_ATTEMPTS = 8
|
||||||
|
_WINDOW_SECONDS = 300
|
||||||
|
_failures: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limited(key: str) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
recent = [t for t in _failures.get(key, []) if now - t < _WINDOW_SECONDS]
|
||||||
|
_failures[key] = recent
|
||||||
|
return len(recent) >= _MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def record_failure(key: str) -> None:
|
||||||
|
_failures.setdefault(key, []).append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def record_success(key: str) -> None:
|
||||||
|
_failures.pop(key, None)
|
||||||
@@ -83,6 +83,18 @@ def settings_from_env() -> AppSettings:
|
|||||||
settings.notifications.slack.enabled = True
|
settings.notifications.slack.enabled = True
|
||||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||||
|
|
||||||
|
auth = settings.auth
|
||||||
|
if env.auth_password:
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
auth.username = env.auth_username or auth.username
|
||||||
|
auth.password_hash = auth_service.hash_password(env.auth_password)
|
||||||
|
if env.telegram_owner_id:
|
||||||
|
auth.telegram_owner_id = env.telegram_owner_id
|
||||||
|
auth.telegram_login_enabled = True
|
||||||
|
if env.telegram_bot_username:
|
||||||
|
auth.telegram_bot_username = env.telegram_bot_username
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,32 @@ class NotificationsSettings(BaseModel):
|
|||||||
report: ReportSettings = Field(default_factory=ReportSettings)
|
report: ReportSettings = Field(default_factory=ReportSettings)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Web login
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class AuthSettings(BaseModel):
|
||||||
|
"""Gates the browser UI. The desktop app and scripts keep using `API_TOKEN`.
|
||||||
|
|
||||||
|
The web UI is closed the moment either credential is configured: a password
|
||||||
|
(`password_hash` set) or Telegram sign-in (`telegram_login_enabled` with an
|
||||||
|
`telegram_owner_id`). With neither set, the browser is open — the same
|
||||||
|
"friction-free on a laptop" default `API_TOKEN` already has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
username: str = "timka"
|
||||||
|
password_hash: str = ""
|
||||||
|
"""`pbkdf2_sha256$<iterations>$<salt>$<hash>`, never the plaintext password."""
|
||||||
|
|
||||||
|
telegram_login_enabled: bool = False
|
||||||
|
telegram_owner_id: str = ""
|
||||||
|
"""The numeric Telegram user id allowed to sign in with the Login Widget."""
|
||||||
|
|
||||||
|
telegram_bot_username: str = ""
|
||||||
|
"""Without the `@`, e.g. `kadence_bot` — the widget needs it to render."""
|
||||||
|
|
||||||
|
session_days: int = Field(default=30, ge=1, le=365)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Root
|
# Root
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -298,6 +324,7 @@ class AppSettings(BaseModel):
|
|||||||
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
|
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
|
||||||
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
|
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
|
||||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||||
|
auth: AuthSettings = Field(default_factory=AuthSettings)
|
||||||
|
|
||||||
@field_validator("timezone")
|
@field_validator("timezone")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -321,6 +348,7 @@ SECRET_FIELDS = frozenset(
|
|||||||
"session_cookie",
|
"session_cookie",
|
||||||
"bot_token",
|
"bot_token",
|
||||||
"webhook_url",
|
"webhook_url",
|
||||||
|
"password_hash",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ for name in (
|
|||||||
"TELEGRAM_BOT_TOKEN",
|
"TELEGRAM_BOT_TOKEN",
|
||||||
"TELEGRAM_CHAT_ID",
|
"TELEGRAM_CHAT_ID",
|
||||||
"SLACK_WEBHOOK_URL",
|
"SLACK_WEBHOOK_URL",
|
||||||
|
"AUTH_PASSWORD",
|
||||||
|
"TELEGRAM_OWNER_ID",
|
||||||
|
"TELEGRAM_BOT_USERNAME",
|
||||||
|
"API_TOKEN",
|
||||||
):
|
):
|
||||||
os.environ.pop(name, None)
|
os.environ.pop(name, None)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Login gate: closed only once a password or Telegram login is configured."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_by_default(client):
|
||||||
|
assert client.get("/api/settings").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_hash_round_trips():
|
||||||
|
hashed = auth_service.hash_password("correct horse battery staple")
|
||||||
|
assert auth_service.verify_password("correct horse battery staple", hashed)
|
||||||
|
assert not auth_service.verify_password("wrong", hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_flow(client):
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.services import settings_service
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
settings = settings_service.load_settings(db)
|
||||||
|
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
|
||||||
|
settings_service.save_settings(db, settings)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Locked out without a session.
|
||||||
|
assert client.get("/api/settings").status_code == 401
|
||||||
|
assert client.get("/api/auth/me").json()["login_required"] is True
|
||||||
|
|
||||||
|
bad = client.post("/api/auth/login", json={"username": "timka", "password": "nope"})
|
||||||
|
assert bad.status_code == 401
|
||||||
|
|
||||||
|
ok = client.post("/api/auth/login", json={"username": "timka", "password": "s3cret!!"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert "kadence_session" in client.cookies
|
||||||
|
|
||||||
|
assert client.get("/api/settings").status_code == 200
|
||||||
|
assert client.get("/api/auth/me").json()["authenticated"] is True
|
||||||
|
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
assert client.get("/api/settings").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_token_still_works_once_login_is_configured(client, monkeypatch):
|
||||||
|
from app.config import env_settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.services import settings_service
|
||||||
|
|
||||||
|
env_settings.cache_clear()
|
||||||
|
monkeypatch.setenv("API_TOKEN", "desktop-secret")
|
||||||
|
env_settings.cache_clear()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
settings = settings_service.load_settings(db)
|
||||||
|
settings.auth.password_hash = auth_service.hash_password("s3cret!!")
|
||||||
|
settings_service.save_settings(db, settings)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
assert client.get("/api/settings").status_code == 401
|
||||||
|
assert client.get("/api/settings", headers={"X-API-Token": "desktop-secret"}).status_code == 200
|
||||||
|
env_settings.cache_clear()
|
||||||
@@ -42,6 +42,12 @@ services:
|
|||||||
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
|
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# Web login — see .env.example for what each does.
|
||||||
|
AUTH_USERNAME: ${AUTH_USERNAME:-timka}
|
||||||
|
AUTH_PASSWORD: ${AUTH_PASSWORD:-}
|
||||||
|
TELEGRAM_OWNER_ID: ${TELEGRAM_OWNER_ID:-}
|
||||||
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||||
|
|
||||||
# Seeds for the editable settings — the same variable names jira-helper
|
# Seeds for the editable settings — the same variable names jira-helper
|
||||||
# uses, so an existing .env can be copied across unchanged.
|
# uses, so an existing .env can be copied across unchanged.
|
||||||
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
||||||
|
|||||||
+36
-1
@@ -3,9 +3,11 @@ import { Alert, Box, CssBaseline, LinearProgress, Snackbar, ThemeProvider, useMe
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import AppShell from "./components/AppShell";
|
import AppShell from "./components/AppShell";
|
||||||
|
import { AuthProvider, useAuth } from "./lib/auth";
|
||||||
import { SettingsProvider, useSettings } from "./lib/settings";
|
import { SettingsProvider, useSettings } from "./lib/settings";
|
||||||
import { buildTheme, type ThemeMode } from "./theme";
|
import { buildTheme, type ThemeMode } from "./theme";
|
||||||
import BlockerPage from "./pages/BlockerPage";
|
import BlockerPage from "./pages/BlockerPage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
import ReportPage from "./pages/ReportPage";
|
import ReportPage from "./pages/ReportPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import TasksPage from "./pages/TasksPage";
|
import TasksPage from "./pages/TasksPage";
|
||||||
@@ -66,10 +68,43 @@ function Shell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function Gate() {
|
||||||
|
const { me, loading } = useAuth();
|
||||||
|
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
if (loading || !me) {
|
||||||
|
// The paper theme, so the blank moment before `/api/auth/me` answers
|
||||||
|
// doesn't flash a default MUI white.
|
||||||
|
const theme = buildTheme(prefersDark ? "dark" : "light", false);
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<LinearProgress />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me.authenticated) {
|
||||||
|
const theme = buildTheme(prefersDark ? "dark" : "light", false);
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<LoginPage />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Gate />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,11 +22,13 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined";
|
import AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined";
|
||||||
import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
|
import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
|
||||||
|
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
|
||||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||||
import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
|
import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
import { useResource } from "../lib/hooks";
|
import { useResource } from "../lib/hooks";
|
||||||
import type { BlockerPolicy, TimerState } from "../lib/types";
|
import type { BlockerPolicy, TimerState } from "../lib/types";
|
||||||
@@ -55,6 +57,7 @@ export default function AppShell({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const palette = vizPalette(dark);
|
const palette = vizPalette(dark);
|
||||||
|
const { me, logout } = useAuth();
|
||||||
|
|
||||||
const timer = useResource<TimerState>("/api/timer/state", 5000);
|
const timer = useResource<TimerState>("/api/timer/state", 5000);
|
||||||
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
|
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
|
||||||
@@ -187,6 +190,23 @@ export default function AppShell({
|
|||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
{status}
|
{status}
|
||||||
|
{me?.login_required && (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
onClick={() => void logout()}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "text.secondary",
|
||||||
|
"&:hover": { color: "text.primary" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogoutOutlinedIcon fontSize="small" />
|
||||||
|
<Typography variant="caption">Sign out</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -208,6 +228,14 @@ export default function AppShell({
|
|||||||
Kadence
|
Kadence
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ width: 120 }}>{status}</Box>
|
<Box sx={{ width: 120 }}>{status}</Box>
|
||||||
|
{me?.login_required && (
|
||||||
|
<Box
|
||||||
|
onClick={() => void logout()}
|
||||||
|
sx={{ display: "flex", alignItems: "center", color: "text.secondary", ml: 1 }}
|
||||||
|
>
|
||||||
|
<LogoutOutlinedIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+13
-1
@@ -26,6 +26,15 @@ export function setToken(token: string): void {
|
|||||||
else localStorage.removeItem(TOKEN_KEY);
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The login screen listens here, so a session that expires mid-use drops the
|
||||||
|
// app back to it instead of showing a generic "backend unreachable" error.
|
||||||
|
const unauthorizedListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function onUnauthorized(listener: () => void): () => void {
|
||||||
|
unauthorizedListeners.add(listener);
|
||||||
|
return () => unauthorizedListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
@@ -33,7 +42,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.set("X-API-Token", token);
|
if (token) headers.set("X-API-Token", token);
|
||||||
|
|
||||||
const response = await fetch(path, { ...init, headers });
|
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
|
||||||
|
if (response.status === 401 && !path.startsWith("/api/auth/")) {
|
||||||
|
unauthorizedListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = `${response.status} ${response.statusText}`;
|
let detail = `${response.status} ${response.statusText}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* The login gate.
|
||||||
|
*
|
||||||
|
* One fetch at start-up decides whether the app behind it is reachable yet.
|
||||||
|
* When no password or Telegram login is configured, `authenticated` comes
|
||||||
|
* back `true` immediately — the same friction-free default the API token has
|
||||||
|
* always had — so nothing changes until Settings → Access is actually set up.
|
||||||
|
*/
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { api, onUnauthorized } from "./api";
|
||||||
|
import type { AuthMe } from "./types";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
me: AuthMe | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
loginWithPassword: (username: string, password: string) => Promise<void>;
|
||||||
|
loginWithTelegram: (payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [me, setMe] = useState<AuthMe | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const body = await api.get<AuthMe>("/api/auth/me");
|
||||||
|
setMe(body);
|
||||||
|
setError(null);
|
||||||
|
} catch (exc) {
|
||||||
|
setError(exc instanceof Error ? exc.message : String(exc));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => onUnauthorized(() => void refresh()), [refresh]);
|
||||||
|
|
||||||
|
const loginWithPassword = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
await api.post("/api/auth/login", { username, password });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginWithTelegram = useCallback(
|
||||||
|
async (payload: Record<string, unknown>) => {
|
||||||
|
await api.post("/api/auth/login/telegram", payload);
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.post("/api/auth/logout");
|
||||||
|
await refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh }),
|
||||||
|
[me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh],
|
||||||
|
);
|
||||||
|
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const value = useContext(Context);
|
||||||
|
if (!value) throw new Error("useAuth outside AuthProvider");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -236,9 +236,26 @@ export interface AppSettings {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean };
|
appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean };
|
||||||
|
auth: {
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
telegram_login_enabled: boolean;
|
||||||
|
telegram_owner_id: string;
|
||||||
|
telegram_bot_username: string;
|
||||||
|
session_days: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
defaults: { blocked_domains: string[]; blocked_apps: string[] };
|
defaults: { blocked_domains: string[]; blocked_apps: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthMe {
|
||||||
|
authenticated: boolean;
|
||||||
|
login_required: boolean;
|
||||||
|
username: string;
|
||||||
|
password_login_enabled: boolean;
|
||||||
|
telegram_login_enabled: boolean;
|
||||||
|
telegram_bot_username: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* The gate in front of everything else.
|
||||||
|
*
|
||||||
|
* Same editorial language as the rest of the app — warm paper, a serif
|
||||||
|
* wordmark, a hairline, one filled button — just with nowhere else to go
|
||||||
|
* until one of the two credentials checks out.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Box, Button, Divider, Stack, TextField, Typography, useTheme } from "@mui/material";
|
||||||
|
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
onKadenceTelegramAuth?: (user: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { me, loginWithPassword, loginWithTelegram } = useAuth();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const widgetHost = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await loginWithPassword(username, password);
|
||||||
|
} catch (exc) {
|
||||||
|
setError(exc instanceof Error ? exc.message : String(exc));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me?.telegram_login_enabled || !me.telegram_bot_username || !widgetHost.current) return;
|
||||||
|
|
||||||
|
window.onKadenceTelegramAuth = (user) => {
|
||||||
|
setError(null);
|
||||||
|
void loginWithTelegram(user).catch((exc) =>
|
||||||
|
setError(exc instanceof Error ? exc.message : String(exc)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||||
|
script.async = true;
|
||||||
|
script.dataset.telegramLogin = me.telegram_bot_username;
|
||||||
|
script.dataset.size = "large";
|
||||||
|
script.dataset.radius = "10";
|
||||||
|
script.dataset.onauth = "onKadenceTelegramAuth(user)";
|
||||||
|
script.dataset.requestAccess = "write";
|
||||||
|
widgetHost.current.innerHTML = "";
|
||||||
|
widgetHost.current.appendChild(script);
|
||||||
|
}, [me?.telegram_login_enabled, me?.telegram_bot_username, loginWithTelegram]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
px: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: "100%", maxWidth: 360 }}>
|
||||||
|
<Stack spacing={0.5} sx={{ mb: 4, textAlign: "center" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: theme.typography.h1.fontFamily,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kadence
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
find your rhythm
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="overline" color="text.secondary" sx={{ display: "block", mb: 1 }}>
|
||||||
|
Sign in
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{me?.password_login_enabled && (
|
||||||
|
<Box component="form" onSubmit={submit}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
<Button type="submit" variant="contained" disabled={busy} size="large">
|
||||||
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{me?.password_login_enabled && me?.telegram_login_enabled && (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ my: 3 }}>
|
||||||
|
<Divider sx={{ flexGrow: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
or
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flexGrow: 1 }} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{me?.telegram_login_enabled && (
|
||||||
|
<Stack alignItems="center" spacing={2}>
|
||||||
|
<Box ref={widgetHost} />
|
||||||
|
{!me?.password_login_enabled && error && <Alert severity="error">{error}</Alert>}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!me?.password_login_enabled && !me?.telegram_login_enabled && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
No login method is configured yet. Set <code>AUTH_PASSWORD</code> (or a Telegram owner id) in{" "}
|
||||||
|
<code>.env</code>, or run <code>make set-password</code>.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
|
|
||||||
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
|
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
|
||||||
import { api, getToken, setToken } from "../lib/api";
|
import { api, getToken, setToken } from "../lib/api";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
import { useSettings } from "../lib/settings";
|
import { useSettings } from "../lib/settings";
|
||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
@@ -453,11 +454,142 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
|||||||
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
|
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
|
||||||
</Alert>
|
</Alert>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<AccessSection patch={patch} onToast={onToast} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AccessSection({
|
||||||
|
patch,
|
||||||
|
onToast,
|
||||||
|
}: {
|
||||||
|
patch: (change: Patch, message?: string) => Promise<void>;
|
||||||
|
onToast: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { me, logout } = useAuth();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
const auth = settings.auth;
|
||||||
|
|
||||||
|
const changePassword = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post("/api/auth/password", {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
onToast("Password changed");
|
||||||
|
} catch (exc) {
|
||||||
|
onToast(exc instanceof Error ? exc.message : String(exc));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title="Access"
|
||||||
|
subtitle="Closes the browser UI behind a login. The desktop app keeps using its own API token."
|
||||||
|
>
|
||||||
|
<Panel>
|
||||||
|
<Typography sx={{ fontWeight: 600, mb: 1.5 }}>Password</Typography>
|
||||||
|
{auth.password_hash ? (
|
||||||
|
<>
|
||||||
|
{me?.login_required ? (
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Current password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={saving || newPassword.length < 8}
|
||||||
|
onClick={changePassword}
|
||||||
|
>
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
A password is set, but this browser isn't signed in. Sign out and back in to rotate it
|
||||||
|
here, or run <code>make set-password</code> on the server.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mt: 1.5 }} alignItems="center">
|
||||||
|
<Chip size="small" color="success" variant="outlined" label={`on · ${auth.username}`} />
|
||||||
|
<Button size="small" onClick={() => void logout()}>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Not set — the browser UI is open to anyone who can reach this address. Set{" "}
|
||||||
|
<code>AUTH_PASSWORD</code> in <code>.env</code> (and restart), or run{" "}
|
||||||
|
<code>make set-password</code> on the server, then reload this page.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
value={auth.username}
|
||||||
|
onCommit={(value) => void patch({ auth: { username: value } })}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel sx={{ mt: 2 }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: auth.telegram_login_enabled ? 1.5 : 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>Telegram login</Typography>
|
||||||
|
{auth.telegram_login_enabled && auth.telegram_owner_id && (
|
||||||
|
<Chip size="small" color="success" variant="outlined" label="on" />
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={auth.telegram_login_enabled}
|
||||||
|
onChange={(event) => void patch({ auth: { telegram_login_enabled: event.target.checked } })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{auth.telegram_login_enabled && (
|
||||||
|
<Stack spacing={1.5} direction="row" sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
|
||||||
|
<Field
|
||||||
|
label="Your Telegram user id"
|
||||||
|
value={auth.telegram_owner_id}
|
||||||
|
placeholder="e.g. from @userinfobot"
|
||||||
|
onCommit={(value) => void patch({ auth: { telegram_owner_id: value } })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Bot username (no @)"
|
||||||
|
value={auth.telegram_bot_username}
|
||||||
|
placeholder={settings.notifications.telegram.bot_token ? "" : "needs a bot token above"}
|
||||||
|
onCommit={(value) => void patch({ auth: { telegram_bot_username: value } })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
|
||||||
|
Needs the bot token from the Telegram section above, plus `/setdomain` registered with
|
||||||
|
@BotFather for this site's HTTPS domain.
|
||||||
|
</Typography>
|
||||||
|
</Panel>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|||||||
Reference in new issue
Block a user