Compare commits

..

8 Commits

Author SHA1 Message Date
0d956adce5 chore: ignore local venv directory 2026-05-28 08:47:13 +00:00
1edb89884b ci: verify Docker image build on pull requests
All checks were successful
docker / test (pull_request) Successful in 37s
docker / build-and-push (pull_request) Has been skipped
docker / build-verify-pr (pull_request) Successful in 40s
2026-05-22 13:52:40 +00:00
0f106e3544 Add public read-only mode toggle
All checks were successful
docker / test (push) Successful in 15s
docker / build-and-push (push) Successful in 45s
2026-05-21 19:47:28 +00:00
a4b645bab6 Add duplicate link action with unique name prefill 2026-05-21 19:46:34 +00:00
87c610b8d7 Remove auth form reload and keep SPA navigation 2026-05-21 19:46:16 +00:00
58f7702074 Add drag-drop and keyboard link ordering 2026-05-21 19:45:51 +00:00
7cdf9b95f7 Replace admin alerts with inline errors and toasts
All checks were successful
docker / test (push) Successful in 37s
docker / build-and-push (push) Successful in 59s
2026-05-21 16:25:43 +00:00
Space-Banane
fd874c9499 admin: add backup/restore flow and structured request logging
All checks were successful
docker / test (push) Successful in 14s
docker / build-and-push (push) Successful in 1m36s
2026-05-20 22:44:02 +02:00
9 changed files with 544 additions and 62 deletions

View File

@@ -6,6 +6,11 @@ on:
paths-ignore:
- '**/*.md'
- '**/*.txt'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- '**/*.txt'
workflow_dispatch:
jobs:
@@ -44,6 +49,7 @@ jobs:
build-and-push:
needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -134,3 +140,19 @@ jobs:
print(f"link failed: status={exc.code} body={body}")
raise
PY
build-verify-pr:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
build-args: |
VCS_REF=${{ github.sha }}
VCS_URL=${{ github.server_url }}/${{ github.repository }}

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ data
dist
*.log
__pycache__
.venv/

View File

@@ -14,6 +14,8 @@ Dark dashboard for Arr* services and custom links.
- Icon blobs stored in the database
- Containerized app deployment (requires MariaDB)
- Admin-managed service links
- Admin backup/export and restore with dry-run validation
- Structured JSON logs with request IDs (`x-request-id`)
## Local Dev
@@ -59,6 +61,12 @@ The app expects a MariaDB instance configured through environment variables.
- `USERNAME_MAX_LEN` (default: `64`)
- `PASSWORD_MIN_LEN` (default: `12`)
### Backup / Restore API
- `GET /api/admin/backup` exports users and links as JSON
- `POST /api/admin/restore?dry_run=true` validates a backup payload without applying
- `POST /api/admin/restore?dry_run=false` applies restore when body includes `"confirm": true`
## Gitea CI/CD
Add these secrets in Gitea:

42
TODO.md
View File

@@ -34,29 +34,29 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
- [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- [x] Remove duplicate connection pattern in create flow.
- [x] Use one DB transaction/connection per request path where possible.
- Add backup and restore flow in admin API/UI.
- Download full export.
- Upload validated import with explicit confirmation.
- Add dry-run validation mode before apply.
- Add structured logging.
- Log auth attempts, CRUD actions, and restore events with request IDs.
- [x] Add backup and restore flow in admin API/UI.
- [x] Download full export.
- [x] Upload validated import with explicit confirmation.
- [x] Add dry-run validation mode before apply.
- [x] Add structured logging.
- [x] Log auth attempts, CRUD actions, and restore events with request IDs.
## P2 - UX and Product Improvements
- Replace browser `alert()` with inline form errors/toasts.
- Show server errors near submit controls.
- Add success toasts for create/update/delete.
- Remove forced reload in auth forms.
- Replace `location.reload()` with state refresh only.
- Keep SPA navigation predictable on setup/login/logout.
- Add drag-and-drop ordering in admin.
- Persist `sort_order` updates.
- Provide keyboard-accessible move controls as fallback.
- Add duplicate/cloning for links.
- Pre-fill form from an existing link.
- Save as new record with unique name validation.
- Add public read-only mode toggle.
- Hide admin entry points and editing affordances for non-admin view.
- [x] Replace browser `alert()` with inline form errors/toasts.
- [x] Show server errors near submit controls.
- [x] Add success toasts for create/update/delete.
- [x] Remove forced reload in auth forms.
- [x] Replace `location.reload()` with state refresh only.
- [x] Keep SPA navigation predictable on setup/login/logout.
- [x] Add drag-and-drop ordering in admin.
- [x] Persist `sort_order` updates.
- [x] Provide keyboard-accessible move controls as fallback.
- [x] Add duplicate/cloning for links.
- [x] Pre-fill form from an existing link.
- [x] Save as new record with unique name validation.
- [x] Add public read-only mode toggle.
- [x] Hide admin entry points and editing affordances for non-admin view.
## P3 - Nice-to-Have
@@ -65,4 +65,4 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
- Add JSON import/export for services with icons.
- Add keyboard shortcuts for search/quick launch.
- Add Open Graph metadata and richer SEO tags.
- Add CI verification that builds container image for pull requests.
- [x] Add CI verification that builds container image for pull requests.

View File

@@ -3,6 +3,10 @@ from __future__ import annotations
import secrets
import os
import time
import json
import logging
import uuid
import base64
from datetime import datetime, timezone
from contextlib import contextmanager
from pathlib import Path
@@ -45,6 +49,9 @@ app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
login_attempts: dict[str, list[float]] = {}
login_lockouts: dict[str, float] = {}
logger = logging.getLogger("jellomator")
if not logger.handlers:
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(message)s")
@app.get("/healthz")
@@ -186,6 +193,20 @@ class LoginIn(BaseModel):
password: str
class RestoreIn(BaseModel):
data: dict
confirm: bool = False
class ReorderItem(BaseModel):
id: int
sort_order: int
class ReorderIn(BaseModel):
items: list[ReorderItem]
def utc_now_iso() -> str:
return datetime.utcnow().isoformat()
@@ -194,6 +215,10 @@ def utc_now_db() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def utc_now_iso_z() -> str:
return datetime.now(timezone.utc).isoformat()
def expires_at_iso() -> str:
now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
@@ -300,6 +325,66 @@ def require_csrf(request: Request):
raise HTTPException(403, "Invalid CSRF token")
def log_event(request: Request | None, event: str, **fields):
payload = {"event": event, "ts": utc_now_iso_z(), **fields}
if request is not None:
payload["request_id"] = getattr(request.state, "request_id", None)
payload["method"] = request.method
payload["path"] = request.url.path
payload["client_ip"] = client_ip(request)
logger.info(json.dumps(payload, separators=(",", ":")))
def parse_backup_payload(data: dict) -> tuple[list[dict], list[dict]]:
if not isinstance(data, dict):
raise HTTPException(422, "backup payload must be an object")
users = data.get("users")
links = data.get("links")
if not isinstance(users, list) or not isinstance(links, list):
raise HTTPException(422, "backup payload must include users[] and links[]")
parsed_users: list[dict] = []
parsed_links: list[dict] = []
for idx, user in enumerate(users):
if not isinstance(user, dict):
raise HTTPException(422, f"users[{idx}] must be an object")
username = str(user.get("username", "")).strip()
role = str(user.get("role", "")).strip() or "admin"
password_hash_b64 = str(user.get("password_hash_b64", "")).strip()
if not username or not password_hash_b64:
raise HTTPException(422, f"users[{idx}] must include username and password_hash_b64")
try:
password_hash = base64.b64decode(password_hash_b64.encode("ascii"), validate=True)
except Exception as exc:
raise HTTPException(422, f"users[{idx}] has invalid password_hash_b64") from exc
parsed_users.append({"username": username, "role": role, "password_hash": password_hash})
for idx, link in enumerate(links):
if not isinstance(link, dict):
raise HTTPException(422, f"links[{idx}] must be an object")
name = str(link.get("name", "")).strip()
url = str(link.get("url", "")).strip()
description = str(link.get("description", "") or "")
category = str(link.get("category", "") or "General")
icon_url = link.get("icon_url")
sort_order = int(link.get("sort_order", 0) or 0)
enabled = bool(link.get("enabled", True))
validate_link_payload(name, url, description, category, icon_url)
parsed_links.append(
{
"name": name,
"url": url,
"description": description,
"category": category,
"icon_url": icon_url,
"sort_order": sort_order,
"enabled": enabled,
}
)
lower_names = [l["name"].lower() for l in parsed_links]
if len(lower_names) != len(set(lower_names)):
raise HTTPException(422, "backup has duplicate link names")
return parsed_users, parsed_links
def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
with conn.cursor() as cur:
if exclude_id is None:
@@ -350,6 +435,15 @@ def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]:
return blob, icon.content_type
@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["x-request-id"] = request_id
return response
@app.get("/api/me")
def me(request: Request, response: Response):
current = current_user(request, response)
@@ -370,6 +464,7 @@ def setup(inp: SetupIn):
raise HTTPException(400, "Setup already complete")
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
log_event(None, "auth.setup_complete", username=inp.username)
return {"ok": True}
@@ -381,6 +476,7 @@ def login(request: Request, inp: LoginIn):
key = login_key(request, inp.username)
locked_until = login_lockouts.get(key)
if locked_until and locked_until > now_ts:
log_event(request, "auth.login_blocked", username=inp.username, locked_until=locked_until)
raise HTTPException(429, "Too many login attempts. Try again later.")
with db() as c:
with c.cursor() as cur:
@@ -392,6 +488,7 @@ def login(request: Request, inp: LoginIn):
login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS]
if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS:
login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS
log_event(request, "auth.login_failed", username=inp.username)
raise HTTPException(401, "Invalid credentials")
login_attempts.pop(key, None)
login_lockouts.pop(key, None)
@@ -416,6 +513,7 @@ def login(request: Request, inp: LoginIn):
max_age=SESSION_TTL_SECONDS,
path="/",
)
log_event(request, "auth.login_success", username=inp.username)
return response
@@ -429,6 +527,7 @@ def logout(request: Request):
cur.execute("delete from sessions where token=%s", (token,))
resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/")
log_event(request, "auth.logout")
return resp
@@ -486,6 +585,7 @@ def create_link(
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
)
log_event(request, "links.create", name=name)
return {"ok": True}
@@ -507,6 +607,7 @@ def delete_link(request: Request, link_id: int):
with db() as c:
with c.cursor() as cur:
cur.execute("delete from links where id=%s", (link_id,))
log_event(request, "links.delete", link_id=link_id)
return {"ok": True}
@@ -541,9 +642,128 @@ def update_link(
"""update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""",
(name, url, description, category, icon_url, int(enabled), now, link_id),
)
log_event(request, "links.update", link_id=link_id, name=name)
return {"ok": True}
@app.patch("/api/links/order")
def reorder_links(request: Request, inp: ReorderIn):
require_admin(request)
require_csrf(request)
if not inp.items:
raise HTTPException(422, "items must not be empty")
seen: set[int] = set()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
for item in inp.items:
if item.id in seen:
raise HTTPException(422, "duplicate link id in reorder payload")
seen.add(item.id)
cur.execute("update links set sort_order=%s,updated_at=%s where id=%s", (item.sort_order, utc_now_db(), item.id))
if cur.rowcount == 0:
raise HTTPException(404, f"link {item.id} not found")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "links.reorder", count=len(inp.items))
return {"ok": True}
@app.get("/api/admin/backup")
def backup(request: Request):
require_admin(request)
with db() as c:
with c.cursor() as cur:
cur.execute("select username, role, password_hash from users order by id asc")
users = cur.fetchall()
cur.execute(
"select name,url,description,category,sort_order,icon_url,enabled,created_at,updated_at from links order by id asc"
)
links_rows = cur.fetchall()
users_out = [
{
"username": u["username"],
"role": u["role"],
"password_hash_b64": base64.b64encode(u["password_hash"]).decode("ascii"),
}
for u in users
]
links_out = []
for link in links_rows:
links_out.append(
{
"name": link["name"],
"url": link["url"],
"description": link["description"],
"category": link["category"],
"sort_order": link["sort_order"],
"icon_url": link["icon_url"],
"enabled": bool(link["enabled"]),
"created_at": link["created_at"].replace(tzinfo=timezone.utc).isoformat() if link["created_at"] else None,
"updated_at": link["updated_at"].replace(tzinfo=timezone.utc).isoformat() if link["updated_at"] else None,
}
)
out = {"version": 1, "exported_at": utc_now_iso_z(), "users": users_out, "links": links_out}
log_event(request, "backup.export", users=len(users_out), links=len(links_out))
return out
@app.post("/api/admin/restore")
def restore(request: Request, inp: RestoreIn, dry_run: bool = True):
require_admin(request)
require_csrf(request)
parsed_users, parsed_links = parse_backup_payload(inp.data)
if dry_run:
log_event(request, "backup.restore_dry_run", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": True, "users": len(parsed_users), "links": len(parsed_links)}
if not inp.confirm:
raise HTTPException(400, "confirm=true is required to apply restore")
now = utc_now_db()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
cur.execute("set foreign_key_checks=0")
cur.execute("delete from sessions")
cur.execute("delete from users")
cur.execute("delete from links")
for user in parsed_users:
cur.execute(
"insert into users(username,password_hash,role) values (%s,%s,%s)",
(user["username"], user["password_hash"], user["role"]),
)
for link in parsed_links:
cur.execute(
"""
insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
link["name"],
link["url"],
link["description"],
link["category"],
link["sort_order"],
None,
None,
link["icon_url"],
int(link["enabled"]),
now,
now,
),
)
cur.execute("set foreign_key_checks=1")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "backup.restore_apply", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": False, "users": len(parsed_users), "links": len(parsed_links)}
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
if PUBLIC_DIR.exists():

View File

@@ -16,6 +16,8 @@ export default function App() {
const [links, setLinks] = useState<LinkItem[]>([]);
const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null);
const [publicMode, setPublicMode] = useState<boolean>(() => window.localStorage.getItem('public_mode') === '1');
async function refresh() {
const current = await api.request<SetupState>('/api/me');
@@ -28,14 +30,21 @@ export default function App() {
if (!current.current_user) {
setPage(path.startsWith('/admin') ? 'login' : 'dashboard');
} else {
setPage(path.startsWith('/admin') ? 'admin' : 'dashboard');
setPage(path.startsWith('/admin') && !publicMode ? 'admin' : 'dashboard');
}
setLinks(await api.request<LinkItem[]>('/api/links'));
}
function showToast(message: string, tone: 'success' | 'error' = 'success') {
setToast({ message, tone });
window.setTimeout(() => {
setToast((current) => (current?.message === message ? null : current));
}, 2500);
}
useEffect(() => {
refresh().catch(() => setPage('setup'));
}, []);
}, [publicMode]);
useEffect(() => {
const handlePopState = () => {
@@ -67,22 +76,24 @@ export default function App() {
onSave={async (payload, file, editingId) => {
const fd = toFormData(payload, file);
const url = editingId ? `/api/links/${editingId}` : '/api/links';
try {
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Link name already exists')) {
alert('Link names must be unique.');
return;
}
throw err;
}
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
showToast(editingId ? 'Link updated.' : 'Link created.');
await refresh();
}}
onDelete={async (id) => {
await api.request(`/api/links/${id}`, { method: 'DELETE' });
showToast('Link deleted.');
await refresh();
}}
onReorder={async (items) => {
await api.request('/api/links/order', {
method: 'PATCH',
body: JSON.stringify({ items: items.map((item, index) => ({ id: item.id, sort_order: index })) }),
});
showToast('Order saved.');
await refresh();
}}
showToast={showToast}
/>
</div>
</Shell>
@@ -91,11 +102,20 @@ export default function App() {
return (
<Shell>
{toast ? <Toast message={toast.message} tone={toast.tone} /> : null}
<Dashboard
links={links}
query={query}
setQuery={setQuery}
canLogout={Boolean(state?.current_user)}
canAdmin={Boolean(state?.current_user) && !publicMode}
publicMode={publicMode}
onTogglePublicMode={() => {
const next = !publicMode;
setPublicMode(next);
window.localStorage.setItem('public_mode', next ? '1' : '0');
if (next && window.location.pathname.startsWith('/admin')) nav('/');
}}
onAdmin={() => nav('/admin')}
onLogout={async () => {
await api.request('/api/logout', { method: 'POST' });
@@ -127,6 +147,14 @@ function Shell({ children }: { children: ReactNode }) {
return <div className="min-h-screen">{children}</div>;
}
function Toast({ message, tone }: { message: string; tone: 'success' | 'error' }) {
return (
<div className="toast-wrap" role="status" aria-live="polite">
<div className={`toast ${tone === 'error' ? 'toast-error' : 'toast-success'}`}>{message}</div>
</div>
);
}
function Centered({ children }: { children: ReactNode }) {
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
}
@@ -136,6 +164,9 @@ function Dashboard({
query,
setQuery,
canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin,
onLogout,
}: {
@@ -143,6 +174,9 @@ function Dashboard({
query: string;
setQuery: (value: string) => void;
canLogout: boolean;
canAdmin: boolean;
publicMode: boolean;
onTogglePublicMode: () => void;
onAdmin: () => void;
onLogout: () => Promise<void>;
}) {
@@ -172,8 +206,11 @@ function Dashboard({
<header className="row-between">
<h1 className="title-sm">Jellomator</h1>
<div className="flex items-center gap-2">
<button type="button" className="btn-subtle" onClick={onTogglePublicMode}>
{publicMode ? 'Exit public mode' : 'Public mode'}
</button>
{canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button>
{canAdmin ? <button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> : null}
</div>
</header>
@@ -243,6 +280,7 @@ function SetupPage({ onDone }: { onDone: () => Promise<void> }) {
action="Create admin"
onSubmit={async (fd) => {
await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/admin');
await onDone();
}}
fields={['username', 'password']}
@@ -257,7 +295,6 @@ function LoginPage({ onDone }: { onDone: () => Promise<void> }) {
action="Sign in"
onSubmit={async (fd) => {
await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/');
await onDone();
}}
fields={['username', 'password']}
@@ -276,20 +313,28 @@ function AuthCard({
fields: string[];
onSubmit: (fd: FormData) => Promise<void>;
}) {
const [submitError, setSubmitError] = useState<string | null>(null);
return (
<div className="grid min-h-screen place-items-center p-4">
<form
className="panel w-full max-w-sm space-y-3"
onSubmit={async (e) => {
e.preventDefault();
await onSubmit(new FormData(e.currentTarget));
location.reload();
setSubmitError(null);
try {
await onSubmit(new FormData(e.currentTarget));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSubmitError(message || 'Request failed. Please try again.');
}
}}
>
<h1 className="title-sm">{title}</h1>
{fields.map((f) => (
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
))}
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
<button className="btn-subtle w-full" type="submit">{action}</button>
</form>
</div>
@@ -300,14 +345,26 @@ function AdminPage({
links,
onSave,
onDelete,
onReorder,
showToast,
}: {
links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
onDelete: (id: number) => Promise<void>;
onReorder: (items: LinkItem[]) => Promise<void>;
showToast: (message: string, tone?: 'success' | 'error') => void;
}) {
const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [orderedLinks, setOrderedLinks] = useState<LinkItem[]>(links);
const [draggingId, setDraggingId] = useState<number | null>(null);
useEffect(() => {
setOrderedLinks(links);
}, [links]);
const startEdit = (link: LinkItem) => {
setEditingId(link.id);
@@ -322,47 +379,178 @@ function AdminPage({
});
};
const startDuplicate = (link: LinkItem) => {
const existingNames = new Set(orderedLinks.map((item) => item.name.toLowerCase()));
const base = `${link.name} (copy)`;
let candidate = base;
let counter = 2;
while (existingNames.has(candidate.toLowerCase())) {
candidate = `${base} ${counter}`;
counter += 1;
}
setEditingId(null);
setFile(null);
setForm({
name: candidate,
url: link.url,
description: link.description,
category: link.category,
icon_url: link.icon_url ?? '',
enabled: link.enabled,
});
};
const reset = () => {
setEditingId(null);
setFile(null);
setForm(emptyForm());
};
const moveBy = async (id: number, delta: number) => {
const index = orderedLinks.findIndex((link) => link.id === id);
const nextIndex = index + delta;
if (index < 0 || nextIndex < 0 || nextIndex >= orderedLinks.length) return;
const next = [...orderedLinks];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
setOrderedLinks(next);
await onReorder(next);
};
const handleDropOn = async (targetId: number) => {
if (draggingId === null || draggingId === targetId) return;
const from = orderedLinks.findIndex((link) => link.id === draggingId);
const to = orderedLinks.findIndex((link) => link.id === targetId);
if (from < 0 || to < 0) return;
const next = [...orderedLinks];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
setOrderedLinks(next);
setDraggingId(null);
await onReorder(next);
};
return (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<form
className="panel space-y-2"
onSubmit={async (event) => {
event.preventDefault();
await onSave(form, file, editingId ?? undefined);
reset();
}}
>
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enabled
</label>
<div className="flex gap-2 pt-1">
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
<div className="space-y-4">
<form
className="panel space-y-2"
onSubmit={async (event) => {
event.preventDefault();
setSubmitError(null);
try {
await onSave(form, file, editingId ?? undefined);
reset();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Link name already exists')) {
setSubmitError('Link names must be unique.');
return;
}
setSubmitError(message || 'Saving failed. Please try again.');
}
}}
>
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enabled
</label>
<div className="flex gap-2 pt-1">
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div>
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
</form>
<div className="panel space-y-2">
<div className="text-xs text-slate-400">Backup / Restore</div>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const backup = await api.request<Record<string, unknown>>('/api/admin/backup');
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `jellomator-backup-${new Date().toISOString().replaceAll(':', '-')}.json`;
a.click();
URL.revokeObjectURL(a.href);
}}
>
Export backup
</button>
<input
type="file"
accept="application/json"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setRestoreText(await f.text());
}}
/>
</div>
<textarea
className="input min-h-32"
placeholder="Paste backup JSON here"
value={restoreText}
onChange={(e) => setRestoreText(e.target.value)}
/>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
showToast('Restore dry-run passed.');
}}
>
Validate (dry-run)
</button>
<button
type="button"
className="btn-subtle"
onClick={async () => {
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
showToast('Restore applied.');
window.location.reload();
}}
>
Apply restore
</button>
</div>
</div>
</form>
</div>
<div className="panel">
<ul className="admin-list">
{links.map((link) => (
<li key={link.id} className="admin-row">
{orderedLinks.map((link, index) => (
<li
key={link.id}
className="admin-row"
draggable
onDragStart={() => setDraggingId(link.id)}
onDragOver={(event) => event.preventDefault()}
onDrop={async () => {
await handleDropOn(link.id);
}}
>
<div className="min-w-0">
<div className="truncate text-sm text-slate-100">{link.name}</div>
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
</div>
<div className="flex gap-2">
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, -1)} disabled={index === 0}>Up</button>
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, 1)} disabled={index === orderedLinks.length - 1}>Down</button>
<button type="button" className="btn-subtle" onClick={() => startDuplicate(link)}>Duplicate</button>
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
</div>

View File

@@ -5,6 +5,7 @@ export type LinkItem = {
url: string;
description: string;
category: string;
sort_order?: number;
enabled: boolean;
icon_url: string | null;
};

View File

@@ -74,3 +74,19 @@ body {
.admin-row {
@apply flex items-center justify-between gap-3 py-2;
}
.toast-wrap {
@apply pointer-events-none fixed right-4 top-4 z-50;
}
.toast {
@apply rounded-md border px-3 py-2 text-xs shadow-lg;
}
.toast-success {
@apply border-emerald-800 bg-emerald-950/95 text-emerald-200;
}
.toast-error {
@apply border-rose-800 bg-rose-950/95 text-rose-200;
}

View File

@@ -129,3 +129,29 @@ def test_login_rate_limit_lockout(client: TestClient):
assert resp.status_code == 401
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert locked.status_code == 429
def test_backup_restore_dry_run_and_apply(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
client.post("/api/login", json={"username": "admin", "password": "123456789012"})
client.post(
"/api/links",
data={
"name": "Sonarr",
"url": "https://sonarr.example.com",
"description": "TV",
"category": "Arr",
"enabled": "true",
},
)
backup_resp = client.get("/api/admin/backup")
assert backup_resp.status_code == 200
backup = backup_resp.json()
assert isinstance(backup.get("users"), list)
assert isinstance(backup.get("links"), list)
dry = client.post("/api/admin/restore?dry_run=true", json={"data": backup, "confirm": False})
assert dry.status_code == 200
assert dry.json()["dry_run"] is True
apply_resp = client.post("/api/admin/restore?dry_run=false", json={"data": backup, "confirm": True})
assert apply_resp.status_code == 200
assert apply_resp.json()["ok"] is True