added settings page

This commit is contained in:
Markbeep
2025-02-16 23:07:06 +01:00
parent 4b0229db47
commit 9051e98d63
13 changed files with 617 additions and 114 deletions

View File

@@ -0,0 +1,35 @@
"""user group enum & config
Revision ID: d6a02deef57b
Revises: 6477fe89a011
Create Date: 2025-02-16 22:23:25.519453
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'd6a02deef57b'
down_revision: Union[str, None] = '6477fe89a011'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('config',
sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('value', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('key')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('config')
# ### end Alembic commands ###

View File

@@ -5,13 +5,14 @@ from sqlalchemy import func
from sqlmodel import select
from app.db import get_session
from app.models import User
from app.routers import root, search, wishlist
from app.routers import root, search, settings, wishlist
app = FastAPI()
app.include_router(root.router)
app.include_router(search.router)
app.include_router(wishlist.router)
app.include_router(settings.router)
user_exists = False

View File

@@ -1,5 +1,6 @@
# pyright: reportUnknownVariableType=false
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
@@ -8,11 +9,18 @@ class BaseModel(SQLModel):
pass
class GroupEnum(str, Enum):
untrusted = "untrusted"
trusted = "trusted"
admin = "admin"
class User(BaseModel, table=True):
username: str = Field(primary_key=True)
password: str
group: str = Field(
default="untrusted", sa_column_kwargs={"server_default": "untrusted"}
group: GroupEnum = Field(
default=GroupEnum.untrusted,
sa_column_kwargs={"server_default": "untrusted"},
)
"""
untrusted: Requests need to be manually reviewed
@@ -21,7 +29,7 @@ class User(BaseModel, table=True):
"""
def is_admin(self):
return self.group == "admin"
return self.group == GroupEnum.admin
class BookRequest(BaseModel, table=True):
@@ -54,3 +62,8 @@ class Indexer(BaseModel, table=True):
name: str
enabled: bool
privacy: str
class Config(BaseModel, table=True):
key: str = Field(primary_key=True)
value: str

View File

@@ -1,14 +1,17 @@
import re
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.responses import FileResponse
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlmodel import Session
from app.db import get_session
from app.models import User
from app.util.auth import create_user, get_authenticated_user
from app.models import User, GroupEnum
from app.util.auth import (
create_user,
get_authenticated_user,
raise_for_invalid_password,
)
router = APIRouter()
@@ -35,12 +38,7 @@ def read_init(request: Request):
return templates.TemplateResponse("init.html", {"request": request})
validate_password_regex = re.compile(
r"^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$"
)
@router.post("/init", status_code=201)
@router.post("/init")
def create_init(
username: Annotated[str, Form()],
password: Annotated[str, Form()],
@@ -50,12 +48,10 @@ def create_init(
if password != confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
if not validate_password_regex.match(password):
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number",
)
raise_for_invalid_password(password)
user = create_user(username, password, "admin")
user = create_user(username, password, GroupEnum.admin)
session.add(user)
session.commit()
return Response(status_code=201, headers={"HX-Redirect": "/"})

170
app/routers/settings.py Normal file
View File

@@ -0,0 +1,170 @@
from typing import Annotated, Any, Optional
from urllib.parse import quote_plus
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlmodel import Session, select
from app.db import get_session
from app.models import Config, User, GroupEnum
from app.util.auth import (
create_user,
get_authenticated_user,
raise_for_invalid_password,
)
router = APIRouter(prefix="/settings")
templates = Jinja2Blocks(directory="templates")
templates.env.filters["quote_plus"] = lambda u: quote_plus(u) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportUnknownArgumentType]
@router.get("/")
def read_settings(
request: Request,
user: Annotated[User, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
prowlarr_misconfigured: Optional[Any] = None,
):
if user.is_admin():
users = session.exec(select(User)).all()
else:
users = []
prowlarr_base_url = session.exec(
select(Config.value).where(Config.key == "prowlarr_base_url")
).one_or_none()
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"user": user,
"users": users,
"prowlarr_base_url": prowlarr_base_url or "",
"prowlarr_misconfigured": True if prowlarr_misconfigured else False,
},
)
@router.post("/user")
def create_new_user(
request: Request,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
group: Annotated[str, Form()],
session: Annotated[Session, Depends(get_session)],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
):
if password != confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
if group not in GroupEnum.__members__:
raise HTTPException(status_code=400, detail="Invalid group")
group = GroupEnum[group]
raise_for_invalid_password(password)
user = session.exec(select(User).where(User.username == username)).first()
if user:
raise HTTPException(status_code=400, detail="Username already exists")
user = create_user(username, password, group)
session.add(user)
session.commit()
users = session.exec(select(User)).all()
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "users": users},
block_name="user_block",
)
@router.delete("/user")
def delete_user(
request: Request,
username: str,
session: Annotated[Session, Depends(get_session)],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
):
if username == admin_user.username:
raise HTTPException(status_code=400, detail="Cannot delete own user")
user = session.exec(select(User).where(User.username == username)).one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
session.delete(user)
session.commit()
users = session.exec(select(User)).all()
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "users": users},
block_name="user_block",
)
@router.post("/password")
def change_password(
password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
session: Annotated[Session, Depends(get_session)],
user: Annotated[User, Depends(get_authenticated_user())],
):
if password != confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match")
raise_for_invalid_password(password)
new_user = create_user(user.username, password, user.group)
user.password = new_user.password
session.add(user)
session.commit()
return Response(status_code=204, headers={"HX-Refresh": "true"})
@router.put("/prowlarr/api-key")
def update_prowlarr_api_key(
api_key: Annotated[str, Form()],
session: Annotated[Session, Depends(get_session)],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
):
config = session.exec(
select(Config).where(Config.key == "prowlarr_api_key")
).one_or_none()
if config:
config.value = api_key
else:
config = Config(key="prowlarr_api_key", value=api_key)
session.add(config)
session.commit()
return Response(status_code=204, headers={"HX-Refresh": "true"})
@router.put("/prowlarr/base-url")
def update_prowlarr_base_url(
base_url: Annotated[str, Form()],
session: Annotated[Session, Depends(get_session)],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
):
config = session.exec(
select(Config).where(Config.key == "prowlarr_base_url")
).one_or_none()
base_url = base_url.strip("/")
if config:
config.value = base_url
else:
config = Config(key="prowlarr_base_url", value=base_url)
session.add(config)
session.commit()
return Response(status_code=204, headers={"HX-Refresh": "true"})

View File

@@ -3,16 +3,23 @@ from typing import Annotated
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from jinja2_fragments.fastapi import Jinja2Blocks
from sqlalchemy import func
from sqlmodel import Session, col, select
from app.db import get_session
from app.models import BookRequest, Indexer, User
from app.models import BookRequest, GroupEnum, Indexer, User
from app.util.auth import get_authenticated_user
from app.util.book_search import get_audnexus_book
from app.util.connection import get_connection
from app.util.prowlarr import get_indexers, query_prowlarr, start_download
from app.util.prowlarr import (
ProwlarrConfig,
get_indexers,
get_prowlarr_config,
query_prowlarr,
start_download,
)
router = APIRouter(prefix="/wishlist")
@@ -50,23 +57,21 @@ async def wishlist(
)
@router.post("")
async def refresh_request(
user: Annotated[User, Depends(get_authenticated_user("trusted"))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
return {"message": "Refreshed"}
@router.get("/sources/{asin}")
async def list_sources(
request: Request,
asin: str,
admin_user: Annotated[User, Depends(get_authenticated_user("admin"))],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
try:
prowlarr_config = get_prowlarr_config(session)
except HTTPException:
return RedirectResponse(
"/settings?prowlarr_misconfigured=1#prowlarr-base-url", status_code=302
)
book = session.exec(select(BookRequest).where(BookRequest.asin == asin)).first()
if not book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -76,13 +81,13 @@ async def list_sources(
raise HTTPException(status_code=500, detail="Book asin error")
query = book.title + " " + " ".join(book.authors)
sources = await query_prowlarr(query)
sources = await query_prowlarr(prowlarr_config, query)
if len(sources) > 0:
indexers = session.exec(select(Indexer)).all()
indexers = {indexer.id: indexer for indexer in indexers}
if len(indexers) == 0:
indexers = await get_indexers(client_session)
indexers = await get_indexers(prowlarr_config, client_session)
for indexer in indexers.values():
session.add(indexer)
session.commit()
@@ -111,11 +116,12 @@ async def download_book(
asin: str,
guid: str,
indexer_id: int,
admin_user: Annotated[User, Depends(get_authenticated_user("admin"))],
admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
prowlarr_config: Annotated[ProwlarrConfig, Depends(get_prowlarr_config)],
):
resp = await start_download(client_session, guid, indexer_id)
resp = await start_download(prowlarr_config, client_session, guid, indexer_id)
if not resp.ok:
raise HTTPException(status_code=500, detail="Failed to start download")

View File

@@ -1,4 +1,5 @@
from typing import Annotated, Literal
import re
from typing import Annotated
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
@@ -7,23 +8,36 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
from sqlmodel import Session, select
from app.db import get_session
from app.models import User
from app.models import User, GroupEnum
security = HTTPBasic()
ph = PasswordHasher()
validate_password_regex = re.compile(
r"^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$"
)
def raise_for_invalid_password(password: str):
if not validate_password_regex.match(password):
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number",
)
def create_user(
username: str,
password: str,
group: Literal["admin", "trusted", "untrusted"] = "untrusted",
group: GroupEnum = GroupEnum.untrusted,
) -> User:
password_hash = ph.hash(password)
return User(username=username, password=password_hash, group=group)
def get_authenticated_user(
lowest_allowed_group: Literal["admin", "trusted", "untrusted"] = "untrusted",
lowest_allowed_group: GroupEnum = GroupEnum.untrusted,
):
def get_user(
session: Annotated[Session, Depends(get_session)],
@@ -55,10 +69,10 @@ def get_authenticated_user(
session.commit()
if lowest_allowed_group == "admin":
if user.group != "admin":
if user.group != GroupEnum.admin:
raise HTTPException(status_code=403, detail="Forbidden")
elif lowest_allowed_group == "trusted":
if user.group not in ["admin", "trusted"]:
if user.group not in [GroupEnum.admin, GroupEnum.trusted]:
raise HTTPException(status_code=403, detail="Forbidden")
return user

View File

@@ -1,30 +1,57 @@
import logging
import os
from datetime import datetime
from typing import Any, Optional
from typing import Annotated, Any, Optional
from urllib.parse import urlencode, urljoin
from aiohttp import ClientResponse, ClientSession
from async_lru import alru_cache
from fastapi import Depends, HTTPException
import pydantic
from sqlmodel import Session, select
from app.models import Indexer, ProwlarrSource
from app.db import get_session
from app.models import Config, Indexer, ProwlarrSource
logger = logging.getLogger(__name__)
prowlarr_base_url = os.getenv("PROWLARR_BASE_URL", "")
prowlarr_api_key = os.getenv("PROWLARR_API_KEY", "")
class ProwlarrConfig(pydantic.BaseModel):
base_url: str
api_key: str
def __hash__(self) -> int:
return hash((self.base_url, self.api_key))
def get_prowlarr_config(
session: Annotated[Session, Depends(get_session)],
) -> ProwlarrConfig:
api_key = session.exec(
select(Config.value).where(Config.key == "prowlarr_api_key")
).one_or_none()
base_url = session.exec(
select(Config.value).where(Config.key == "prowlarr_base_url")
).one_or_none()
if not api_key or not base_url:
raise HTTPException(500, "Prowlarr configuration missing")
return ProwlarrConfig(base_url=base_url, api_key=api_key)
async def start_download(
session: ClientSession, guid: str, indexer_id: int
config: ProwlarrConfig,
session: ClientSession,
guid: str,
indexer_id: int,
) -> ClientResponse:
url = prowlarr_base_url + "/api/v1/search"
url = config.base_url + "/api/v1/search"
logger.debug("Starting download for %s", guid)
async with session.post(
url,
json={"guid": guid, "indexerId": indexer_id},
headers={"X-Api-Key": prowlarr_api_key},
headers={"X-Api-Key": config.api_key},
) as response:
if not response.ok:
print(response)
@@ -34,12 +61,15 @@ async def start_download(
return response
async def get_indexers(session: ClientSession) -> dict[int, Indexer]:
url = prowlarr_base_url + "/api/v1/indexer"
async def get_indexers(
config: ProwlarrConfig,
session: ClientSession,
) -> dict[int, Indexer]:
url = config.base_url + "/api/v1/indexer"
async with session.get(
url,
headers={"X-Api-Key": prowlarr_api_key},
headers={"X-Api-Key": config.api_key},
) as response:
indexers = await response.json()
@@ -56,7 +86,9 @@ async def get_indexers(session: ClientSession) -> dict[int, Indexer]:
@alru_cache(ttl=300)
async def query_prowlarr(
query: Optional[str], indexer_ids: Optional[list[int]] = None
config: ProwlarrConfig,
query: Optional[str],
indexer_ids: Optional[list[int]] = None,
) -> list[ProwlarrSource]:
if not query:
return []
@@ -70,14 +102,14 @@ async def query_prowlarr(
if indexer_ids is not None:
params["indexerIds"] = indexer_ids
url = urljoin(prowlarr_base_url, f"/api/v1/search?{urlencode(params, doseq=True)}")
url = urljoin(config.base_url, f"/api/v1/search?{urlencode(params, doseq=True)}")
logger.info("Querying prowlarr: %s", url)
async with ClientSession() as session:
async with session.get(
url,
headers={"X-Api-Key": prowlarr_api_key},
headers={"X-Api-Key": config.api_key},
) as response:
search_results = await response.json()

View File

@@ -6,6 +6,11 @@
<title>AudioBookRequest</title>
{% endblock %}
<link rel="stylesheet" href="/globals.css" />
<script
src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"
></script>
</head>
<body class="w-screen min-h-screen overflow-x-hidden">
<header class="bg-accent text-primary-content shadow-lg">
@@ -78,7 +83,11 @@
</div>
<div class="flex-none pr-4">
<a class="btn btn-ghost btn-square group" title="Settings">
<a
href="/settings"
class="btn btn-ghost btn-square group"
title="Settings"
>
<svg
class="group-hover:rotate-90 transition-all duration-500 ease-in-out"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -26,31 +26,7 @@
</script>
{% endblock %} {% block body %}
<div class="h-screen w-full flex items-center justify-center">
<form class="flex flex-col gap-2 max-w-[30rem]" method="post" id="form">
<script>
// Redirect to the home page after initialization
const onSubmit = event => {
event.preventDefault();
const formData = new FormData(event.target);
console.log(formData);
fetch("/init", {
method: "POST",
body: formData,
})
.then(response => {
if (response.ok) {
window.location.href = "/";
} else {
alert("An error occurred. Please try again.");
}
})
.catch(error => {
console.error("Error:", error);
});
};
document.getElementById("form").addEventListener("submit", onSubmit);
</script>
<form class="flex flex-col gap-2 max-w-[30rem]" hx-post="/init" id="form">
<p>
No user was found in the database. Please create an admin user to get set
up.

View File

@@ -26,14 +26,14 @@ const onPageChange = (page) => {
{% endblock %} {% block body %}
<div class="w-screen flex items-center justify-center p-8 overflow-x-hidden gap-4">
<div class="flex flex-col gap-4 justify-start items-center">
<form class="flex items-start gap-2 w-full" onsubmit="onSearch();">
<form class="flex items-start w-full join" onsubmit="onSearch();">
<input
name="q"
class="input input-bordered"
class="input input-bordered join-item"
placeholder="Book name..."
value="{{ search_term }}"
/>
<select class="select" name="region">
<select class="select join-item max-w-[5rem]" name="region">
{% for region in regions %}
<option
value="{{ region }}"
@@ -43,7 +43,7 @@ const onPageChange = (page) => {
</option>
{% endfor %}
</select>
<button id="search" class="btn btn-primary" type="submit">
<button id="search" class="btn btn-primary join-item" type="submit">
<span id="search-text">Search</span>
<span id="search-spinner" class="loading" style="display: none"></span>
</button>
@@ -120,9 +120,9 @@ const onPageChange = (page) => {
</div>
</div>
<div class="text-sm text-primary font-semibold pt-1" title="Title">{{ book.title }}</div>
{% if book.subtitle %}<div class="text-neutral text-xs" title="Subtitle">{{ book.subtitle }}</div>{% endif %}
<div class="text-neutral text-xs" title="Authors">
<div class="text-sm text-primary font-bold pt-1" title="Title">{{ book.title }}</div>
{% if book.subtitle %}<div class="text-neutral/60 font-semibold text-xs" title="Subtitle">{{ book.subtitle }}</div>{% endif %}
<div class="text-xs text-neutral font-semibold" title="Authors">
{{ book.authors | join(", ") }}
</div>
</div>

251
templates/settings.html Normal file
View File

@@ -0,0 +1,251 @@
{% extends "base.html" %} {% block head %}
<title>Settings</title>
<script>
const checkEqualPasswords = function (formId) {
const form = document.getElementById(formId);
const firstPw = form.elements["password"].value;
const secondPw = form.elements["confirm_password"].value;
const submit = form.elements["submit"];
const nonEqualMessage = form.querySelectorAll(
"[name='non-equal-message']",
)[0];
const invalidPwMessage = form.querySelectorAll(
"[name='invalid-pw-message']",
)[0];
if (firstPw.length === 0 && secondPw.length === 0) {
invalidPwMessage.style.display = "none";
nonEqualMessage.style.display = "none";
submit.disabled = true;
return;
}
if (firstPw !== secondPw && firstPw.length > 0 && secondPw.length > 0) {
nonEqualMessage.style.display = "block";
submit.disabled = true;
} else {
nonEqualMessage.style.display = "none";
if (firstPw.length > 0 && secondPw.length > 0) {
submit.disabled = false;
}
}
const rule = /^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$/;
if (!rule.test(firstPw)) {
invalidPwMessage.style.display = "block";
submit.disabled = true;
} else {
invalidPwMessage.style.display = "none";
}
};
</script>
{% endblock %} {% block body %}
<div class="flex items-start justify-center p-2 md:p-4">
<main class="flex flex-col w-[90%] md:w-3/4 max-w-[40rem] gap-4 gap-y-8 pb-[20rem]">
<h1 class="text-3xl font-bold">Settings</h1>
<form
id="change-password-form"
hx-post="/settings/password"
method="post"
class="flex flex-col gap-2"
>
<h2 class="text-lg">Change Password</h2>
<label for="change-password-1">Password</label>
<input
id="change-password-1"
name="password"
type="password"
class="input w-full"
onkeyup="checkEqualPasswords('change-password-form');"
required
/>
<label for="change-password-2">Confirm password</label>
<input
id="change-password-2"
name="confirm_password"
type="password"
class="input w-full"
onkeyup="checkEqualPasswords('change-password-form');"
required
/>
<span name="non-equal-message" class="text-red-400 hidden"
>Passwords do not match</span
>
<span name="invalid-pw-message" class="text-red-400 hidden"
>Password must be at least 8 characters long and contain at least one
uppercase letter, one lowercase letter, and one number</span
>
<button name="submit" class="btn btn-primary" type="submit" disabled>
Change password
</button>
</form>
{% if user.is_admin() %}
<form
id="create-user-form"
class="flex flex-col gap-2 p-2 pt-4 border-base-200 border-t"
hx-post="/settings/user"
hx-target="#user-list"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<h2 class="text-lg">Create user</h2>
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
class="input w-full"
required
/>
<label for="confirm-password-1">Confirm password</label>
<input
id="confirm-password-1"
name="confirm_password"
type="password"
class="input w-full"
onkeyup="checkEqualPasswords('create-user-form');"
required
/>
<label for="confirm-password-2">Password</label>
<input
id="confirm-password-2"
name="password"
type="password"
class="input w-full"
onkeyup="checkEqualPasswords('create-user-form');"
required
/>
<label for="select-group">Group</label>
<select id="select-group" name="group" class="select w-full" required>
<option value="untrusted" selected>Untrusted</option>
<option value="trusted">Trusted</option>
<option value="admin">Admin</option>
</select>
<span name="non-equal-message" class="text-red-400 hidden"
>Passwords do not match</span
>
<span name="invalid-pw-message" class="text-red-400 hidden"
>Password must be at least 8 characters long and contain at least one
uppercase letter, one lowercase letter, and one number</span
>
<button id="submit" class="btn btn-primary" type="submit" disabled>
Create user
</button>
</form>
{% block user_block %}
<div class="pt-4 border-t border-base-200">
<h2 class="text-lg">Users</h2>
<div class="max-h-[30rem] overflow-x-auto">
<table id="user-list" class="table table-pin-rows">
<thead>
<tr>
<th></th>
<th>Username</th>
<th>Group</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<th>{{ loop.index }}</th>
<td>{{ u.username }}</td>
<td>{{ u.group.value.capitalize() }}</td>
<td>
<button
class="btn btn-square btn-ghost"
onclick="delete_modal_{{ loop.index }}.showModal()"
{% if u.username==user.username %}disabled{% endif %}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
width="24"
height="24"
stroke-width="2"
style="--darkreader-inline-stroke: currentColor"
data-darkreader-inline-stroke=""
>
<path d="M4 7h16"></path>
<path
d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"
></path>
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path>
<path d="M10 12l4 4m0 -4l-4 4"></path>
</svg>
</button>
<dialog id="delete_modal_{{ loop.index }}" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
Are you sure you want to delete a user?
</h3>
<div class="grid grid-cols-2 py-4">
<span class="font-semibold mr-2">Username</span
><span class="font-mono">{{ u.username }}</span>
<span class="font-semibold mr-2">Group</span
><span class="font-mono"
>{{ u.group.value.capitalize() }}</span
>
</div>
<form method="dialog" class="flex justify-between">
<button class="btn">Cancel</button>
<button
class="btn bg-primary"
hx-delete="/settings/user?username={{ u.username|quote_plus }}"
hx-target="#user-list"
>
Delete
</button>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
<div class="pt-4 border-t border-base-200 flex flex-col">
<h2 class="text-lg">Prowlarr</h2>
{% if prowlarr_misconfigured %}
<p class="text-red-400">
Prowlarr is misconfigured. Please configure it.
</p>
{% endif %}
<label for="prowlarr-api-key">API Key</label>
<form class="join w-full" hx-put="/settings/prowlarr/api-key">
<input id="prowlarr-api-key" name="api_key" type="password" placeholder="●●●●●●●●●●●●●●●●●" class="input join-item w-full" />
<button class="join-item btn">Save</button>
</form>
<label for="prowlarr-base-url" class="pt-2">Base URL</label>
<form class="join w-full" hx-put="/settings/prowlarr/base-url">
<input id="prowlarr-base-url" name="base_url" type="url" value="{{ prowlarr_base_url }}" class="input join-item w-full" />
<button class="join-item btn">Save</button>
</form>
</div>
{% endif %}
</main>
</div>
{% endblock %}

View File

@@ -15,32 +15,30 @@
</script>
{% endblock %} {% block body %}
<div class="w-screen p-2 md:p-4 lg:p-8 min-h-screen flex flex-col gap-2">
<div class="w-screen p-2 md:p-4 lg:p-8 flex flex-col gap-2">
<h1 class="text-3xl font-bold">Sources for {{ book.title }}</h1>
<tbody>
{% if not sources %}
<div role="alert" class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span
>No results found for "{{ book.title }}" by
{{book.authors|join(",")}}. Might have to be looked up
manually.</span
>
</div>
{% endif %} {% for source in sources %}
<div class="overflow-x-auto h-[90vh] rounded-md outline outline-primary">
{% if not sources %}
<div role="alert" class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span
>No results found for "{{ book.title }}" by {{book.authors|join(",")}}.
Might have to be looked up manually.</span
>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="table table-zebra table-pin-rows min-w-[60rem]">
<thead>
<tr>
@@ -53,6 +51,8 @@
<th></th>
</tr>
</thead>
<tbody>
{% for source in sources %}
<tr class="text-xs lg:text-sm">
<th>{{ loop.index }}</th>