2 Commits

Author SHA1 Message Date
619708cae0 Align referral uses field 2026-01-23 23:07:13 +13:00
af67c888c6 Fix invite referral uses hint layout 2026-01-23 23:05:09 +13:00
20 changed files with 928 additions and 2231 deletions

View File

@@ -18,16 +18,6 @@ class JellyseerrClient(ApiClient):
},
)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/user",
params={
"take": take,
"skip": skip,
"sort": "createdAt",
},
)
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/media/{media_id}")

View File

@@ -105,159 +105,6 @@ class Settings(BaseSettings):
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
invites_enabled: bool = Field(default=True, validation_alias=AliasChoices("INVITES_ENABLED"))
invites_require_captcha: bool = Field(
default=False, validation_alias=AliasChoices("INVITES_REQUIRE_CAPTCHA")
)
invite_default_profile_id: Optional[int] = Field(
default=None, validation_alias=AliasChoices("INVITE_DEFAULT_PROFILE_ID")
)
signup_allow_referrals: bool = Field(
default=True, validation_alias=AliasChoices("SIGNUP_ALLOW_REFERRALS")
)
referral_default_uses: int = Field(
default=1, validation_alias=AliasChoices("REFERRAL_DEFAULT_USES")
)
password_min_length: int = Field(
default=8, validation_alias=AliasChoices("PASSWORD_MIN_LENGTH")
)
password_require_upper: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_UPPER")
)
password_require_lower: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_LOWER")
)
password_require_number: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_NUMBER")
)
password_require_symbol: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_SYMBOL")
)
password_reset_enabled: bool = Field(
default=True, validation_alias=AliasChoices("PASSWORD_RESET_ENABLED")
)
captcha_provider: str = Field(
default="none", validation_alias=AliasChoices("CAPTCHA_PROVIDER")
)
hcaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SITE_KEY")
)
hcaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SECRET_KEY")
)
recaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SITE_KEY")
)
recaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SECRET_KEY")
)
turnstile_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SITE_KEY")
)
turnstile_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SECRET_KEY")
)
smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_HOST")
)
smtp_port: Optional[int] = Field(
default=587, validation_alias=AliasChoices("SMTP_PORT")
)
smtp_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_USER")
)
smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_PASSWORD")
)
smtp_from: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_FROM")
)
smtp_tls: bool = Field(default=False, validation_alias=AliasChoices("SMTP_TLS"))
smtp_starttls: bool = Field(default=True, validation_alias=AliasChoices("SMTP_STARTTLS"))
notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_EMAIL_ENABLED")
)
notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_DISCORD_ENABLED")
)
notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_TELEGRAM_ENABLED")
)
notify_matrix_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_MATRIX_ENABLED")
)
notify_pushover_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHOVER_ENABLED")
)
notify_pushbullet_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHBULLET_ENABLED")
)
notify_gotify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_GOTIFY_ENABLED")
)
notify_ntfy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_NTFY_ENABLED")
)
telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_BOT_TOKEN")
)
telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_CHAT_ID")
)
matrix_homeserver: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_HOMESERVER")
)
matrix_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_USER")
)
matrix_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_PASSWORD")
)
matrix_access_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ACCESS_TOKEN")
)
matrix_room_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ROOM_ID")
)
pushover_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_TOKEN")
)
pushover_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_USER_KEY")
)
pushbullet_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHBULLET_TOKEN")
)
gotify_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_URL")
)
gotify_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_TOKEN")
)
ntfy_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_URL")
)
ntfy_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_TOPIC")
)
expiry_default_days: int = Field(
default=0, validation_alias=AliasChoices("EXPIRY_DEFAULT_DAYS")
)
expiry_default_action: str = Field(
default="disable", validation_alias=AliasChoices("EXPIRY_DEFAULT_ACTION")
)
expiry_warning_days: int = Field(
default=3, validation_alias=AliasChoices("EXPIRY_WARNING_DAYS")
)
expiry_check_interval_minutes: int = Field(
default=60, validation_alias=AliasChoices("EXPIRY_CHECK_INTERVAL_MINUTES")
)
jellyseerr_sync_users: bool = Field(
default=True, validation_alias=AliasChoices("JELLYSEERR_SYNC_USERS")
)
jellyseerr_sync_interval_minutes: int = Field(
default=1440, validation_alias=AliasChoices("JELLYSEERR_SYNC_INTERVAL_MINUTES")
)
settings = Settings()

View File

@@ -65,111 +65,6 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_contacts (
user_id INTEGER PRIMARY KEY,
email TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
discord TEXT,
discord_verified INTEGER NOT NULL DEFAULT 0,
telegram TEXT,
telegram_verified INTEGER NOT NULL DEFAULT 0,
matrix TEXT,
matrix_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invite_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
max_uses INTEGER,
expires_in_days INTEGER,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
profile_id INTEGER,
created_by TEXT,
created_at TEXT NOT NULL,
expires_at TEXT,
max_uses INTEGER,
uses_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
is_referral INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_expiry (
user_id INTEGER PRIMARY KEY,
expires_at TEXT NOT NULL,
action TEXT NOT NULL,
warning_sent_at TEXT,
disabled_at TEXT,
deleted_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_by TEXT,
subject TEXT NOT NULL,
body_md TEXT NOT NULL,
channels_csv TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
recipient TEXT,
status TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -376,32 +271,6 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
}
def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.id, users.username, users.password_hash, users.role, users.auth_provider,
users.created_at, users.last_login_at, users.is_blocked
FROM users
JOIN user_contacts ON user_contacts.user_id = users.id
WHERE lower(user_contacts.email) = lower(?)
""",
(email,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
}
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
@@ -458,17 +327,6 @@ def set_user_role(username: str, role: str) -> None:
)
def delete_user(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute("DELETE FROM user_contacts WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM user_expiry WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM password_resets WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username)
if not user:
@@ -489,603 +347,6 @@ def set_user_password(username: str, password: str) -> None:
)
def get_user_id(username: str) -> Optional[int]:
with _connect() as conn:
row = conn.execute(
"SELECT id FROM users WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
return int(row[0])
def upsert_user_contact(
username: str,
email: Optional[str] = None,
discord: Optional[str] = None,
telegram: Optional[str] = None,
matrix: Optional[str] = None,
verified: Optional[Dict[str, bool]] = None,
) -> None:
user_id = get_user_id(username)
if user_id is None:
return
now = datetime.now(timezone.utc).isoformat()
verified = verified or {}
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_contacts (
user_id,
email,
email_verified,
discord,
discord_verified,
telegram,
telegram_verified,
matrix,
matrix_verified,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email = COALESCE(excluded.email, user_contacts.email),
email_verified = COALESCE(excluded.email_verified, user_contacts.email_verified),
discord = COALESCE(excluded.discord, user_contacts.discord),
discord_verified = COALESCE(excluded.discord_verified, user_contacts.discord_verified),
telegram = COALESCE(excluded.telegram, user_contacts.telegram),
telegram_verified = COALESCE(excluded.telegram_verified, user_contacts.telegram_verified),
matrix = COALESCE(excluded.matrix, user_contacts.matrix),
matrix_verified = COALESCE(excluded.matrix_verified, user_contacts.matrix_verified),
updated_at = excluded.updated_at
""",
(
user_id,
email,
1 if verified.get("email") else 0,
discord,
1 if verified.get("discord") else 0,
telegram,
1 if verified.get("telegram") else 0,
matrix,
1 if verified.get("matrix") else 0,
now,
now,
),
)
def get_user_contact(username: str) -> Optional[Dict[str, Any]]:
user_id = get_user_id(username)
if user_id is None:
return None
with _connect() as conn:
row = conn.execute(
"""
SELECT email, email_verified, discord, discord_verified, telegram, telegram_verified, matrix, matrix_verified
FROM user_contacts
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
return {
"email": row[0],
"email_verified": bool(row[1]),
"discord": row[2],
"discord_verified": bool(row[3]),
"telegram": row[4],
"telegram_verified": bool(row[5]),
"matrix": row[6],
"matrix_verified": bool(row[7]),
}
def get_all_contacts() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_contacts.email, user_contacts.discord,
user_contacts.telegram, user_contacts.matrix
FROM user_contacts
JOIN users ON users.id = user_contacts.user_id
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"email": row[1],
"discord": row[2],
"telegram": row[3],
"matrix": row[4],
}
)
return results
def set_user_expiry(username: str, expires_at: str, action: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_expiry (user_id, expires_at, action)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
expires_at = excluded.expires_at,
action = excluded.action
""",
(user_id, expires_at, action),
)
def get_expired_users(now_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, users.is_blocked, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.deleted_at IS NULL
""",
(now_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"is_blocked": bool(row[1]),
"expires_at": row[2],
"action": row[3],
"warning_sent_at": row[4],
}
)
return results
def get_users_expiring_by(cutoff_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.warning_sent_at IS NULL
""",
(cutoff_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"expires_at": row[1],
"action": row[2],
"warning_sent_at": row[3],
}
)
return results
def mark_expiry_warning_sent(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET warning_sent_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_disabled(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET disabled_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_deleted(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET deleted_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def list_invite_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action, created_at, updated_at
FROM invite_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
"created_at": row[11],
"updated_at": row[12],
}
)
return results
def get_invite_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action
FROM invite_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
}
def create_invite_profile(
name: str,
description: Optional[str] = None,
max_uses: Optional[int] = None,
expires_in_days: Optional[int] = None,
require_captcha: bool = False,
password_rules: Optional[Dict[str, Any]] = None,
allow_referrals: bool = False,
referral_uses: Optional[int] = None,
user_expiry_days: Optional[int] = None,
user_expiry_action: Optional[str] = None,
) -> int:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO invite_profiles (
name,
description,
max_uses,
expires_in_days,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
max_uses,
expires_in_days,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
now,
now,
),
)
return int(cursor.lastrowid)
def create_invite(
code: str,
created_by: Optional[str],
profile_id: Optional[int],
expires_at: Optional[str],
max_uses: Optional[int],
require_captcha: bool,
password_rules: Optional[Dict[str, Any]],
allow_referrals: bool,
referral_uses: Optional[int],
user_expiry_days: Optional[int],
user_expiry_action: Optional[str],
is_referral: bool = False,
) -> None:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
conn.execute(
"""
INSERT INTO invites (
code,
profile_id,
created_by,
created_at,
expires_at,
max_uses,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
is_referral
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
code,
profile_id,
created_by,
now,
expires_at,
max_uses,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
1 if is_referral else 0,
),
)
def list_invites(limit: int = 200) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
)
return results
def list_invites_by_creator(username: str, is_referral: bool = False) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT code, created_at, expires_at, max_uses, uses_count, disabled, is_referral
FROM invites
WHERE created_by = ? AND is_referral = ?
ORDER BY created_at DESC
""",
(username, 1 if is_referral else 0),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"code": row[0],
"created_at": row[1],
"expires_at": row[2],
"max_uses": row[3],
"uses_count": row[4],
"disabled": bool(row[5]),
"is_referral": bool(row[6]),
}
)
return results
def get_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
WHERE code = ?
""",
(code,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
def increment_invite_use(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET uses_count = uses_count + 1 WHERE code = ?
""",
(code,),
)
def disable_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET disabled = 1 WHERE code = ?
""",
(code,),
)
def delete_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
DELETE FROM invites WHERE code = ?
""",
(code,),
)
def create_password_reset(token: str, username: str, expires_at: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO password_resets (token, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(token, user_id, created_at, expires_at),
)
def get_password_reset(token: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.username, password_resets.expires_at, password_resets.used_at
FROM password_resets
JOIN users ON users.id = password_resets.user_id
WHERE password_resets.token = ?
""",
(token,),
).fetchone()
if not row:
return None
return {"username": row[0], "expires_at": row[1], "used_at": row[2]}
def mark_password_reset_used(token: str) -> None:
used_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE password_resets SET used_at = ? WHERE token = ?
""",
(used_at, token),
)
def save_announcement(
created_by: Optional[str],
subject: str,
body_md: str,
channels_csv: Optional[str],
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO announcements (created_by, subject, body_md, channels_csv, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(created_by, subject, body_md, channels_csv, created_at),
)
def log_notification(channel: str, recipient: Optional[str], status: str, detail: Optional[str]) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO notification_log (channel, recipient, status, detail, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(channel, recipient, status, detail, created_at),
)
def _backfill_auth_providers() -> None:
with _connect() as conn:
rows = conn.execute(

View File

@@ -19,8 +19,6 @@ from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.jellyseerr_sync import run_jellyseerr_sync_loop
from .services.expiry import run_expiry_loop
from .logging_config import configure_logging
from .runtime import get_runtime_settings
@@ -45,12 +43,10 @@ async def startup() -> None:
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
asyncio.create_task(run_jellyseerr_sync_loop())
asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup())
asyncio.create_task(run_expiry_loop())
app.include_router(requests_router)

View File

@@ -1,6 +1,4 @@
from typing import Any, Dict, List
from datetime import datetime, timedelta, timezone
import secrets
import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -10,17 +8,7 @@ from ..config import settings as env_settings
from ..db import (
delete_setting,
get_all_users,
get_invite_profile,
list_invite_profiles,
create_invite_profile,
create_invite,
list_invites,
disable_invite,
delete_invite,
delete_user,
get_all_contacts,
get_request_cache_overview,
save_announcement,
get_settings_overrides,
get_user_by_username,
set_setting,
@@ -39,12 +27,10 @@ from ..clients.radarr import RadarrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.jellyseerr_sync import sync_jellyseerr_users
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
from ..services.notifications import send_notification
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
logger = logging.getLogger(__name__)
@@ -56,17 +42,6 @@ SENSITIVE_KEYS = {
"radarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
"smtp_password",
"hcaptcha_secret_key",
"recaptcha_secret_key",
"turnstile_secret_key",
"telegram_bot_token",
"matrix_password",
"matrix_access_token",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_token",
}
SETTING_KEYS: List[str] = [
@@ -99,59 +74,6 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"invites_enabled",
"invites_require_captcha",
"invite_default_profile_id",
"signup_allow_referrals",
"referral_default_uses",
"password_min_length",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"captcha_provider",
"hcaptcha_site_key",
"hcaptcha_secret_key",
"recaptcha_site_key",
"recaptcha_secret_key",
"turnstile_site_key",
"turnstile_secret_key",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"telegram_bot_token",
"telegram_chat_id",
"matrix_homeserver",
"matrix_user",
"matrix_password",
"matrix_access_token",
"matrix_room_id",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_url",
"gotify_token",
"ntfy_url",
"ntfy_topic",
"expiry_default_days",
"expiry_default_action",
"expiry_warning_days",
"expiry_check_interval_minutes",
"jellyseerr_sync_users",
"jellyseerr_sync_interval_minutes",
]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
@@ -286,12 +208,6 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
return {"status": "ok", "imported": imported}
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
imported = await sync_jellyseerr_users()
return {"status": "ok", "imported": imported}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -449,157 +365,3 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.post("/users/bulk")
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
action = str(payload.get("action") or "").strip().lower()
usernames = payload.get("usernames")
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="User list required")
if action not in {"block", "unblock", "delete", "role"}:
raise HTTPException(status_code=400, detail="Invalid action")
updated = 0
for username in usernames:
if not isinstance(username, str) or not username.strip():
continue
name = username.strip()
if action == "block":
set_user_blocked(name, True)
elif action == "unblock":
set_user_blocked(name, False)
elif action == "delete":
delete_user(name)
elif action == "role":
role = str(payload.get("role") or "").strip().lower()
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="Invalid role")
set_user_role(name, role)
updated += 1
return {"status": "ok", "updated": updated}
@router.get("/invite-profiles")
async def invite_profiles() -> Dict[str, Any]:
return {"profiles": list_invite_profiles()}
@router.post("/invite-profiles")
async def create_profile(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Profile name required")
profile_id = create_invite_profile(
name=name,
description=str(payload.get("description") or "").strip() or None,
max_uses=payload.get("max_uses"),
expires_in_days=payload.get("expires_in_days"),
require_captcha=bool(payload.get("require_captcha")),
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
allow_referrals=bool(payload.get("allow_referrals")),
referral_uses=payload.get("referral_uses"),
user_expiry_days=payload.get("user_expiry_days"),
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
)
return {"status": "ok", "id": profile_id}
@router.get("/invites")
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
return {"invites": list_invites(limit)}
@router.post("/invites")
async def create_invite_endpoint(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
runtime = get_runtime_settings()
profile_id = payload.get("profile_id")
profile = None
if profile_id is not None:
try:
profile = get_invite_profile(int(profile_id))
except (TypeError, ValueError):
profile = None
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
expires_at = None
if expires_in_days:
try:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
).isoformat()
except (TypeError, ValueError):
expires_at = None
require_captcha = bool(payload.get("require_captcha"))
if not require_captcha and profile:
require_captcha = bool(profile.get("require_captcha"))
if not require_captcha:
require_captcha = runtime.invites_require_captcha
password_rules = payload.get("password_rules")
if not isinstance(password_rules, dict):
password_rules = profile.get("password_rules") if profile else None
allow_referrals = bool(payload.get("allow_referrals"))
if not allow_referrals and profile:
allow_referrals = bool(profile.get("allow_referrals"))
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=user.get("username"),
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
require_captcha=require_captcha,
password_rules=password_rules if isinstance(password_rules, dict) else None,
allow_referrals=allow_referrals,
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
user_expiry_days=user_expiry_days,
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
is_referral=bool(payload.get("is_referral")),
)
return {"status": "ok", "code": code}
@router.post("/invites/{code}/disable")
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
disable_invite(code)
return {"status": "ok", "code": code, "disabled": True}
@router.delete("/invites/{code}")
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
delete_invite(code)
return {"status": "ok", "code": code, "deleted": True}
@router.post("/announcements")
async def send_announcement(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "").strip()
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
if not subject or not body:
raise HTTPException(status_code=400, detail="Subject and message required")
results: Dict[str, Any] = {}
email_count = 0
email_failed = 0
if "email" in [str(c).lower() for c in channels]:
for contact in get_all_contacts():
email = contact.get("email")
if not email:
continue
outcome = await send_notification(subject, body, channels=["email"], email=email)
if outcome.get("email") == "sent":
email_count += 1
else:
email_failed += 1
results["email"] = {"sent": email_count, "failed": email_failed}
other_channels = [c for c in channels if str(c).lower() != "email"]
if other_channels:
results.update(await send_notification(subject, body, channels=other_channels))
save_announcement(user.get("username"), subject, body, ",".join(channels))
return {"status": "ok", "results": results}

View File

@@ -1,60 +1,21 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
import secrets
from ..db import (
verify_user_password,
create_user_if_missing,
create_user,
set_last_login,
get_user_by_username,
get_user_by_email,
set_user_password,
get_invite_by_code,
get_invite_profile,
increment_invite_use,
list_invites_by_creator,
create_invite,
upsert_user_contact,
get_user_contact,
set_user_expiry,
create_password_reset,
get_password_reset,
mark_password_reset_used,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token
from ..auth import get_current_user
from ..services.captcha import verify_captcha
from ..services.notifications import send_notification
router = APIRouter(prefix="/auth", tags=["auth"])
def _validate_password(password: str, rules: dict | None = None) -> Optional[str]:
runtime = get_runtime_settings()
rules = rules or {}
min_length = int(rules.get("min_length") or runtime.password_min_length or 8)
require_upper = bool(rules.get("require_upper", runtime.password_require_upper))
require_lower = bool(rules.get("require_lower", runtime.password_require_lower))
require_number = bool(rules.get("require_number", runtime.password_require_number))
require_symbol = bool(rules.get("require_symbol", runtime.password_require_symbol))
if len(password) < min_length:
return f"Password must be at least {min_length} characters."
if require_upper and password.lower() == password:
return "Password must include an uppercase letter."
if require_lower and password.upper() == password:
return "Password must include a lowercase letter."
if require_number and not any(char.isdigit() for char in password):
return "Password must include a number."
if require_symbol and password.isalnum():
return "Password must include a symbol."
return None
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@@ -142,216 +103,12 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
error = _validate_password(new_password.strip())
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
user = verify_user_password(current_user["username"], current_password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(current_user["username"], new_password.strip())
return {"status": "ok"}
@router.get("/contact")
async def get_contact(current_user: dict = Depends(get_current_user)) -> dict:
contact = get_user_contact(current_user["username"])
return {"contact": contact or {}}
@router.post("/contact")
async def update_contact(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
upsert_user_contact(
current_user["username"],
email=str(payload.get("email") or "").strip() or None,
discord=str(payload.get("discord") or "").strip() or None,
telegram=str(payload.get("telegram") or "").strip() or None,
matrix=str(payload.get("matrix") or "").strip() or None,
)
return {"status": "ok"}
@router.post("/register")
async def register(payload: dict, request: Request) -> dict:
runtime = get_runtime_settings()
if not runtime.invites_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invites are disabled")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
contact = payload.get("contact") if isinstance(payload, dict) else None
captcha_token = str(payload.get("captcha_token") or "").strip()
if not invite_code or not username or not password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite, username, and password required")
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
invite = get_invite_by_code(invite_code)
if not invite or invite.get("disabled"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite not found or disabled")
profile = None
if invite.get("profile_id"):
profile = get_invite_profile(int(invite["profile_id"]))
max_uses = invite.get("max_uses")
if max_uses is not None and invite.get("uses_count", 0) >= max_uses:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has been fully used")
expires_at = invite.get("expires_at")
if expires_at:
try:
if datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has expired")
except ValueError:
pass
require_captcha = (
bool(invite.get("require_captcha"))
or (bool(profile.get("require_captcha")) if profile else False)
or runtime.invites_require_captcha
)
if require_captcha:
ok = await verify_captcha(captcha_token, request.client.host if request.client else None)
if not ok:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Captcha failed")
rules = invite.get("password_rules") or (profile.get("password_rules") if profile else None)
error = _validate_password(password, rules)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
try:
create_user(username, password, role="user", auth_provider="local")
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(contact, dict):
upsert_user_contact(
username,
email=str(contact.get("email") or "").strip() or None,
discord=str(contact.get("discord") or "").strip() or None,
telegram=str(contact.get("telegram") or "").strip() or None,
matrix=str(contact.get("matrix") or "").strip() or None,
)
expiry_days = (
invite.get("user_expiry_days")
or (profile.get("user_expiry_days") if profile else None)
or runtime.expiry_default_days
)
expiry_action = (
invite.get("user_expiry_action")
or (profile.get("user_expiry_action") if profile else None)
or runtime.expiry_default_action
)
if expiry_days and expiry_action:
try:
expiry_days_float = float(expiry_days)
except (TypeError, ValueError):
expiry_days_float = 0
if expiry_days_float > 0:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=expiry_days_float)
).isoformat()
set_user_expiry(username, expires_at, str(expiry_action))
increment_invite_use(invite_code)
token = create_access_token(username, "user")
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/password/reset")
async def request_password_reset(payload: dict) -> dict:
runtime = get_runtime_settings()
if not runtime.password_reset_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset disabled")
identifier = str(payload.get("identifier") or "").strip()
if not identifier:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email required")
user = get_user_by_username(identifier)
if not user:
user = get_user_by_email(identifier)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.get("auth_provider") != "local":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset for local users only")
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
create_password_reset(token, user["username"], expires_at)
contact = get_user_contact(user["username"])
email = contact.get("email") if isinstance(contact, dict) else None
if not runtime.notify_email_enabled or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email notifications are not configured for password resets.",
)
await send_notification(
"Password reset request",
f"Your reset token is: {token}",
channels=["email"],
email=email,
)
return {"status": "ok"}
@router.post("/password/reset/confirm")
async def confirm_password_reset(payload: dict) -> dict:
token = str(payload.get("token") or "").strip()
new_password = str(payload.get("new_password") or "").strip()
if not token or not new_password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token and new password required")
reset = get_password_reset(token)
if not reset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reset token not found")
if reset.get("used_at"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token already used")
try:
expires_at = datetime.fromisoformat(reset["expires_at"])
if expires_at <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
except ValueError:
pass
error = _validate_password(new_password)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
set_user_password(reset["username"], new_password)
mark_password_reset_used(token)
return {"status": "ok"}
@router.get("/signup/config")
async def signup_config() -> dict:
runtime = get_runtime_settings()
return {
"invites_enabled": runtime.invites_enabled,
"captcha_provider": runtime.captcha_provider,
"hcaptcha_site_key": runtime.hcaptcha_site_key,
"recaptcha_site_key": runtime.recaptcha_site_key,
"turnstile_site_key": runtime.turnstile_site_key,
"password_min_length": runtime.password_min_length,
"password_require_upper": runtime.password_require_upper,
"password_require_lower": runtime.password_require_lower,
"password_require_number": runtime.password_require_number,
"password_require_symbol": runtime.password_require_symbol,
}
@router.get("/referrals")
async def list_referrals(current_user: dict = Depends(get_current_user)) -> dict:
invites = list_invites_by_creator(current_user["username"], is_referral=True)
return {"invites": invites}
@router.post("/referrals")
async def create_referral(current_user: dict = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
if not runtime.signup_allow_referrals:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Referrals are disabled")
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=current_user["username"],
profile_id=runtime.invite_default_profile_id,
expires_at=None,
max_uses=int(runtime.referral_default_uses or 1),
require_captcha=runtime.invites_require_captcha,
password_rules=None,
allow_referrals=False,
referral_uses=None,
user_expiry_days=None,
user_expiry_action=None,
is_referral=True,
)
return {"status": "ok", "code": code}

View File

@@ -2,43 +2,16 @@ from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
"expiry_check_interval_minutes",
"expiry_default_days",
"expiry_warning_days",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"invite_default_profile_id",
"referral_default_uses",
"jwt_exp_minutes",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"smtp_port",
"jellyseerr_sync_interval_minutes",
"password_min_length",
}
_BOOL_FIELDS = {
"invites_enabled",
"invites_require_captcha",
"signup_allow_referrals",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"jellyfin_sync_to_arr",
"jellyseerr_sync_users",
}

View File

@@ -1,43 +0,0 @@
from typing import Optional
import httpx
from ..runtime import get_runtime_settings
async def verify_captcha(token: Optional[str], remote_ip: Optional[str] = None) -> bool:
runtime = get_runtime_settings()
provider = (runtime.captcha_provider or "none").strip().lower()
if provider in {"", "none", "off", "disabled"}:
return True
if not token:
return False
if provider == "hcaptcha":
secret = runtime.hcaptcha_secret_key
url = "https://hcaptcha.com/siteverify"
payload = {"secret": secret, "response": token}
elif provider == "recaptcha":
secret = runtime.recaptcha_secret_key
url = "https://www.google.com/recaptcha/api/siteverify"
payload = {"secret": secret, "response": token}
elif provider == "turnstile":
secret = runtime.turnstile_secret_key
url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
payload = {"secret": secret, "response": token}
else:
return False
if not secret:
return False
if remote_ip:
payload["remoteip"] = remote_ip
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
data = response.json()
return bool(data.get("success"))
except httpx.HTTPError:
return False

View File

@@ -1,47 +0,0 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from ..db import (
get_users_expiring_by,
get_expired_users,
get_user_contact,
mark_expiry_warning_sent,
mark_expiry_disabled,
mark_expiry_deleted,
set_user_blocked,
delete_user,
)
from ..runtime import get_runtime_settings
from .notifications import send_notification
logger = logging.getLogger(__name__)
async def run_expiry_loop() -> None:
while True:
runtime = get_runtime_settings()
now = datetime.now(timezone.utc)
warn_days = int(runtime.expiry_warning_days or 0)
if warn_days > 0:
cutoff = (now + timedelta(days=warn_days)).isoformat()
for user in get_users_expiring_by(cutoff):
contact = get_user_contact(user["username"]) or {}
email = contact.get("email")
await send_notification(
"Account expiring soon",
f"Your account expires on {user['expires_at']}.",
channels=["email"] if email else [],
email=email,
)
mark_expiry_warning_sent(user["username"])
for expired in get_expired_users(now.isoformat()):
action = (expired.get("action") or "disable").lower()
if action in {"disable", "disable_then_delete"}:
set_user_blocked(expired["username"], True)
mark_expiry_disabled(expired["username"])
if action in {"delete", "disable_then_delete"}:
delete_user(expired["username"])
mark_expiry_deleted(expired["username"])
delay = max(60, int(runtime.expiry_check_interval_minutes or 60) * 60)
await asyncio.sleep(delay)

View File

@@ -1,56 +0,0 @@
import asyncio
import logging
from typing import Any
from fastapi import HTTPException
from ..clients.jellyseerr import JellyseerrClient
from ..db import create_user_if_missing, upsert_user_contact
from ..runtime import get_runtime_settings
logger = logging.getLogger(__name__)
async def sync_jellyseerr_users() -> int:
runtime = get_runtime_settings()
if not runtime.jellyseerr_base_url or not runtime.jellyseerr_api_key:
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
take = 50
skip = 0
imported = 0
while True:
data = await client.get_users(take=take, skip=skip)
if not isinstance(data, dict):
break
results = data.get("results")
if not isinstance(results, list) or not results:
break
for user in results:
if not isinstance(user, dict):
continue
username = user.get("username") or user.get("email") or user.get("displayName")
if not isinstance(username, str) or not username.strip():
continue
email = user.get("email") if isinstance(user.get("email"), str) else None
if create_user_if_missing(username.strip(), "jellyseerr-user", role="user", auth_provider="jellyseerr"):
imported += 1
if email:
upsert_user_contact(username.strip(), email=email.strip())
skip += take
return imported
async def run_jellyseerr_sync_loop() -> None:
while True:
runtime = get_runtime_settings()
if runtime.jellyseerr_sync_users:
try:
imported = await sync_jellyseerr_users()
logger.info("Jellyseerr sync complete: imported=%s", imported)
except HTTPException as exc:
logger.warning("Jellyseerr sync skipped: %s", exc.detail)
except Exception:
logger.exception("Jellyseerr sync failed")
delay = max(60, int(runtime.jellyseerr_sync_interval_minutes or 1440) * 60)
await asyncio.sleep(delay)

View File

@@ -1,216 +0,0 @@
from __future__ import annotations
from typing import Iterable, Optional
import asyncio
import logging
import smtplib
from email.message import EmailMessage
import httpx
from ..db import log_notification
from ..runtime import get_runtime_settings
logger = logging.getLogger(__name__)
def _normalize_channels(channels: Optional[Iterable[str]]) -> list[str]:
if not channels:
return []
return [str(channel).strip().lower() for channel in channels if str(channel).strip()]
def _send_email_sync(
smtp_host: str,
smtp_port: int,
smtp_user: Optional[str],
smtp_password: Optional[str],
smtp_from: str,
to_address: str,
subject: str,
body: str,
use_tls: bool,
use_starttls: bool,
) -> None:
message = EmailMessage()
message["From"] = smtp_from
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
if use_tls:
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server:
if smtp_user and smtp_password:
server.login(smtp_user, smtp_password)
server.send_message(message)
else:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
if use_starttls:
server.starttls()
if smtp_user and smtp_password:
server.login(smtp_user, smtp_password)
server.send_message(message)
async def _send_email(to_address: str, subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_email_enabled:
raise RuntimeError("Email notifications disabled")
if not runtime.smtp_host or not runtime.smtp_from:
raise RuntimeError("SMTP not configured")
await asyncio.to_thread(
_send_email_sync,
runtime.smtp_host,
int(runtime.smtp_port or 587),
runtime.smtp_user,
runtime.smtp_password,
runtime.smtp_from,
to_address,
subject,
body,
bool(runtime.smtp_tls),
bool(runtime.smtp_starttls),
)
async def _send_discord(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_discord_enabled:
raise RuntimeError("Discord notifications disabled")
if not runtime.discord_webhook_url:
raise RuntimeError("Discord webhook not configured")
payload = {"content": f"**{subject}**\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(runtime.discord_webhook_url, json=payload)
response.raise_for_status()
async def _send_telegram(subject: str, body: str, chat_id: Optional[str]) -> None:
runtime = get_runtime_settings()
if not runtime.notify_telegram_enabled:
raise RuntimeError("Telegram notifications disabled")
if not runtime.telegram_bot_token:
raise RuntimeError("Telegram bot token not configured")
target_chat = chat_id or runtime.telegram_chat_id
if not target_chat:
raise RuntimeError("Telegram chat ID not configured")
url = f"https://api.telegram.org/bot{runtime.telegram_bot_token}/sendMessage"
payload = {"chat_id": target_chat, "text": f"{subject}\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
async def _send_matrix(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_matrix_enabled:
raise RuntimeError("Matrix notifications disabled")
if not runtime.matrix_homeserver or not runtime.matrix_access_token or not runtime.matrix_room_id:
raise RuntimeError("Matrix not configured")
url = (
f"{runtime.matrix_homeserver}/_matrix/client/v3/rooms/"
f"{runtime.matrix_room_id}/send/m.room.message"
)
payload = {"msgtype": "m.text", "body": f"{subject}\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload, params={"access_token": runtime.matrix_access_token})
response.raise_for_status()
async def _send_pushover(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_pushover_enabled:
raise RuntimeError("Pushover notifications disabled")
if not runtime.pushover_token or not runtime.pushover_user_key:
raise RuntimeError("Pushover not configured")
payload = {
"token": runtime.pushover_token,
"user": runtime.pushover_user_key,
"title": subject,
"message": body,
}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=payload)
response.raise_for_status()
async def _send_pushbullet(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_pushbullet_enabled:
raise RuntimeError("Pushbullet notifications disabled")
if not runtime.pushbullet_token:
raise RuntimeError("Pushbullet not configured")
payload = {"type": "note", "title": subject, "body": body}
headers = {"Access-Token": runtime.pushbullet_token}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post("https://api.pushbullet.com/v2/pushes", json=payload, headers=headers)
response.raise_for_status()
async def _send_gotify(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_gotify_enabled:
raise RuntimeError("Gotify notifications disabled")
if not runtime.gotify_url or not runtime.gotify_token:
raise RuntimeError("Gotify not configured")
payload = {"title": subject, "message": body, "priority": 5}
url = f"{runtime.gotify_url.rstrip('/')}/message"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, params={"token": runtime.gotify_token}, json=payload)
response.raise_for_status()
async def _send_ntfy(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_ntfy_enabled:
raise RuntimeError("ntfy notifications disabled")
if not runtime.ntfy_url or not runtime.ntfy_topic:
raise RuntimeError("ntfy not configured")
url = f"{runtime.ntfy_url.rstrip('/')}/{runtime.ntfy_topic}"
headers = {"Title": subject}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, content=body.encode("utf-8"), headers=headers)
response.raise_for_status()
async def send_notification(
subject: str,
body: str,
channels: Optional[Iterable[str]] = None,
email: Optional[str] = None,
telegram_chat_id: Optional[str] = None,
) -> dict[str, str]:
requested = _normalize_channels(channels)
results: dict[str, str] = {}
if not requested:
return results
for channel in requested:
try:
if channel == "email":
if not email:
raise RuntimeError("Email address not provided")
await _send_email(email, subject, body)
elif channel == "discord":
await _send_discord(subject, body)
elif channel == "telegram":
await _send_telegram(subject, body, telegram_chat_id)
elif channel == "matrix":
await _send_matrix(subject, body)
elif channel == "pushover":
await _send_pushover(subject, body)
elif channel == "pushbullet":
await _send_pushbullet(subject, body)
elif channel == "gotify":
await _send_gotify(subject, body)
elif channel == "ntfy":
await _send_ntfy(subject, body)
else:
results[channel] = "unsupported"
continue
results[channel] = "sent"
log_notification(channel, email or telegram_chat_id, "sent", None)
except Exception as exc: # noqa: BLE001
logger.warning("Notification failed: channel=%s error=%s", channel, exc)
results[channel] = "failed"
log_notification(channel, email or telegram_chat_id, "failed", str(exc))
return results

View File

@@ -6,7 +6,7 @@ services:
env_file:
- ./.env
ports:
- "8001:8000"
- "8000:8000"
volumes:
- ./data:/app/data
@@ -18,6 +18,6 @@ services:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3001:3000"
- "3000:3000"
depends_on:
- backend

View File

@@ -29,9 +29,46 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request syncing',
invites: 'Invites',
password: 'Password rules',
captcha: 'Captcha',
smtp: 'Email (SMTP)',
notify: 'Notifications',
expiry: 'Account expiry',
}
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'invites_enabled',
'invites_require_captcha',
'signup_allow_referrals',
'password_require_upper',
'password_require_lower',
'password_require_number',
'password_require_symbol',
'password_reset_enabled',
'smtp_tls',
'smtp_starttls',
'notify_email_enabled',
'notify_discord_enabled',
'notify_telegram_enabled',
'notify_matrix_enabled',
'notify_pushover_enabled',
'notify_pushbullet_enabled',
'notify_gotify_enabled',
'notify_ntfy_enabled',
'jellyseerr_sync_users',
])
const NUMBER_SETTINGS = new Set([
'invite_default_profile_id',
'referral_default_uses',
'password_min_length',
'smtp_port',
'expiry_default_days',
'expiry_warning_days',
'expiry_check_interval_minutes',
'jellyseerr_sync_interval_minutes',
])
const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.',
@@ -44,6 +81,12 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.',
invites: 'Invite-only sign-ups and default rules.',
password: 'Set global password rules and local reset settings.',
captcha: 'Choose and configure captcha providers.',
smtp: 'Email delivery settings for password resets and notices.',
notify: 'Where system messages should be sent.',
expiry: 'Handle account expiry and automated actions.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -55,6 +98,12 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
invites: 'invites',
password: 'password',
captcha: 'captcha',
smtp: 'smtp',
notifications: 'notify',
expiry: 'expiry',
cache: null,
logs: 'log',
maintenance: null,
@@ -78,6 +127,59 @@ const labelFromKey = (key: string) =>
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
.replace('invites enabled', 'Allow invite sign-ups')
.replace('invites require captcha', 'Require captcha for invite sign-ups')
.replace('invite default profile id', 'Default invite profile')
.replace('signup allow referrals', 'Allow users to create referral invites')
.replace('referral default uses', 'Default referral invite uses')
.replace('password min length', 'Minimum password length')
.replace('password require upper', 'Require uppercase letters')
.replace('password require lower', 'Require lowercase letters')
.replace('password require number', 'Require numbers')
.replace('password require symbol', 'Require symbols')
.replace('password reset enabled', 'Allow password reset emails')
.replace('captcha provider', 'Captcha provider')
.replace('hcaptcha site key', 'hCaptcha site key')
.replace('hcaptcha secret key', 'hCaptcha secret key')
.replace('recaptcha site key', 'reCAPTCHA site key')
.replace('recaptcha secret key', 'reCAPTCHA secret key')
.replace('turnstile site key', 'Turnstile site key')
.replace('turnstile secret key', 'Turnstile secret key')
.replace('smtp host', 'SMTP host')
.replace('smtp port', 'SMTP port')
.replace('smtp user', 'SMTP username')
.replace('smtp password', 'SMTP password')
.replace('smtp from', 'SMTP from address')
.replace('smtp tls', 'Use TLS (SMTPS)')
.replace('smtp starttls', 'Use STARTTLS')
.replace('notify email enabled', 'Send emails')
.replace('notify discord enabled', 'Send Discord alerts')
.replace('notify telegram enabled', 'Send Telegram alerts')
.replace('notify matrix enabled', 'Send Matrix alerts')
.replace('notify pushover enabled', 'Send Pushover alerts')
.replace('notify pushbullet enabled', 'Send Pushbullet alerts')
.replace('notify gotify enabled', 'Send Gotify alerts')
.replace('notify ntfy enabled', 'Send ntfy alerts')
.replace('telegram bot token', 'Telegram bot token')
.replace('telegram chat id', 'Telegram chat ID')
.replace('matrix homeserver', 'Matrix homeserver')
.replace('matrix user', 'Matrix username')
.replace('matrix password', 'Matrix password')
.replace('matrix access token', 'Matrix access token')
.replace('matrix room id', 'Matrix room ID')
.replace('pushover token', 'Pushover app token')
.replace('pushover user key', 'Pushover user key')
.replace('pushbullet token', 'Pushbullet access token')
.replace('gotify url', 'Gotify URL')
.replace('gotify token', 'Gotify token')
.replace('ntfy url', 'ntfy URL')
.replace('ntfy topic', 'ntfy topic')
.replace('expiry default days', 'Default account expiry (days)')
.replace('expiry default action', 'Expiry action')
.replace('expiry warning days', 'Warn users this many days before expiry')
.replace('expiry check interval minutes', 'Expiry check interval (minutes)')
.replace('jellyseerr sync users', 'Sync Jellyseerr users into Magent')
.replace('jellyseerr sync interval minutes', 'Jellyseerr sync interval (minutes)')
type SettingsPageProps = {
section: string
@@ -106,6 +208,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [invites, setInvites] = useState<any[]>([])
const [inviteProfiles, setInviteProfiles] = useState<any[]>([])
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteForm, setInviteForm] = useState({
profile_id: '',
max_uses: '1',
require_captcha: false,
allow_referrals: false,
})
const [profileForm, setProfileForm] = useState({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
const [inviteExpiry, setInviteExpiry] = useState({ unit: 'days', value: '7' })
const [profileExpiry, setProfileExpiry] = useState({ unit: 'days', value: '' })
const [profileUserExpiry, setProfileUserExpiry] = useState({ unit: 'days', value: '' })
const [announcementSubject, setAnnouncementSubject] = useState('')
const [announcementBody, setAnnouncementBody] = useState('')
const [announcementChannels, setAnnouncementChannels] = useState<string[]>(['discord'])
const [announcementStatus, setAnnouncementStatus] = useState<string | null>(null)
const loadSettings = async () => {
const baseUrl = getApiBase()
@@ -198,6 +325,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'artwork') {
await loadArtworkPrefetchStatus()
}
if (section === 'invites') {
await loadInviteProfiles()
await loadInvites()
}
} catch (err) {
console.error(err)
setStatus('Could not load admin settings.')
@@ -251,6 +382,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache'
const showInviteExtras = section === 'invites'
const showNotificationExtras = section === 'notifications'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
@@ -289,6 +422,59 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
invites_enabled: 'Allow new users to register with invite links.',
invites_require_captcha: 'Require a captcha on invite sign-up.',
invite_default_profile_id: 'Default invite profile applied when creating invites.',
signup_allow_referrals: 'Let users create referral invites for friends/family.',
referral_default_uses: 'Default number of uses for referral invites.',
password_min_length: 'Minimum length required for passwords.',
password_require_upper: 'Require uppercase letters in passwords.',
password_require_lower: 'Require lowercase letters in passwords.',
password_require_number: 'Require numbers in passwords.',
password_require_symbol: 'Require symbols in passwords.',
password_reset_enabled: 'Allow local users to request password reset emails.',
captcha_provider: 'Choose which captcha provider to use for sign-up.',
hcaptcha_site_key: 'Public hCaptcha site key.',
hcaptcha_secret_key: 'Secret hCaptcha key.',
recaptcha_site_key: 'Public reCAPTCHA site key.',
recaptcha_secret_key: 'Secret reCAPTCHA key.',
turnstile_site_key: 'Public Turnstile site key.',
turnstile_secret_key: 'Secret Turnstile key.',
smtp_host: 'SMTP server hostname.',
smtp_port: 'SMTP server port.',
smtp_user: 'SMTP username (optional).',
smtp_password: 'SMTP password (optional).',
smtp_from: 'Default "from" address for system emails.',
smtp_tls: 'Use TLS for SMTP (SMTPS).',
smtp_starttls: 'Use STARTTLS for SMTP.',
notify_email_enabled: 'Send notices by email.',
notify_discord_enabled: 'Send notices to Discord.',
notify_telegram_enabled: 'Send notices to Telegram.',
notify_matrix_enabled: 'Send notices to Matrix.',
notify_pushover_enabled: 'Send notices to Pushover.',
notify_pushbullet_enabled: 'Send notices to Pushbullet.',
notify_gotify_enabled: 'Send notices to Gotify.',
notify_ntfy_enabled: 'Send notices to ntfy.',
telegram_bot_token: 'Telegram bot token for sending notices.',
telegram_chat_id: 'Default Telegram chat ID.',
matrix_homeserver: 'Matrix server base URL.',
matrix_user: 'Matrix bot username.',
matrix_password: 'Matrix bot password.',
matrix_access_token: 'Matrix access token.',
matrix_room_id: 'Matrix room ID for announcements.',
pushover_token: 'Pushover application token.',
pushover_user_key: 'Pushover user key.',
pushbullet_token: 'Pushbullet access token.',
gotify_url: 'Gotify server URL.',
gotify_token: 'Gotify app token.',
ntfy_url: 'ntfy server URL.',
ntfy_topic: 'ntfy topic for notifications.',
expiry_default_days: 'Default number of days before accounts expire.',
expiry_default_action: 'Action to take when an account expires.',
expiry_warning_days: 'How many days before expiry to warn the user.',
expiry_check_interval_minutes: 'How often expiry checks run.',
jellyseerr_sync_users: 'Sync Jellyseerr users into Magent.',
jellyseerr_sync_interval_minutes: 'How often Jellyseerr user sync runs.',
}
const buildSelectOptions = (
@@ -312,6 +498,62 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return list
}
const durationOptions = {
minutes: Array.from({ length: 60 }, (_, i) => String(i + 1)),
hours: Array.from({ length: 24 }, (_, i) => String(i + 1)),
days: Array.from({ length: 365 }, (_, i) => String(i + 1)),
}
const toDays = (choice: { unit: string; value: string }) => {
if (!choice || choice.unit === 'unlimited') return null
const amount = Number(choice.value)
if (!amount || amount <= 0) return null
if (choice.unit === 'minutes') return amount / 1440
if (choice.unit === 'hours') return amount / 24
if (choice.unit === 'months') return amount * 30
return amount
}
const renderDurationControl = (
label: string,
choice: { unit: string; value: string },
setChoice: (next: { unit: string; value: string }) => void
) => (
<label>
{label}
<div className="duration-row">
<input
type="number"
min={1}
placeholder="Amount"
value={choice.value}
onChange={(event) =>
setChoice({
unit: choice.unit,
value: event.target.value,
})
}
disabled={choice.unit === 'unlimited'}
/>
<select
value={choice.unit}
onChange={(event) =>
setChoice({
unit: event.target.value,
value: event.target.value === 'unlimited' ? '' : choice.value,
})
}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="months">Months</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
</label>
)
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
@@ -422,6 +664,174 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}
const loadInviteProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`)
if (!response.ok) {
throw new Error('Profiles unavailable')
}
const data = await response.json()
setInviteProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
} catch (err) {
console.error(err)
setInviteProfiles([])
}
}
const loadInvites = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`)
if (!response.ok) {
throw new Error('Invites unavailable')
}
const data = await response.json()
setInvites(Array.isArray(data?.invites) ? data.invites : [])
} catch (err) {
console.error(err)
setInvites([])
}
}
const createInviteProfile = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invite-profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...profileForm,
max_uses: profileForm.max_uses ? Number(profileForm.max_uses) : null,
expires_in_days: toDays(profileExpiry),
referral_uses: profileForm.referral_uses ? Number(profileForm.referral_uses) : null,
user_expiry_days: toDays(profileUserExpiry),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile creation failed')
}
setProfileForm({
name: '',
description: '',
max_uses: '',
require_captcha: false,
allow_referrals: false,
referral_uses: '',
user_expiry_action: 'disable',
})
setProfileExpiry({ unit: 'days', value: '' })
setProfileUserExpiry({ unit: 'days', value: '' })
setInviteStatus('Invite profile created.')
await loadInviteProfiles()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite profile.')
}
}
const createInviteCode = async () => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: inviteForm.profile_id ? Number(inviteForm.profile_id) : null,
max_uses: inviteForm.max_uses ? Number(inviteForm.max_uses) : null,
expires_in_days: toDays(inviteExpiry),
require_captcha: inviteForm.require_captcha,
allow_referrals: inviteForm.allow_referrals,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite creation failed')
}
setInviteStatus('Invite created.')
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not create invite.')
}
}
const disableInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${encodeURIComponent(code)}/disable`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Disable failed')
}
setInviteStatus(`Invite ${code} disabled.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not disable invite.')
}
}
const deleteInviteCode = async (code: string) => {
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/invites/${encodeURIComponent(code)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
throw new Error('Delete failed')
}
setInviteStatus(`Invite ${code} deleted.`)
await loadInvites()
} catch (err) {
console.error(err)
setInviteStatus('Could not delete invite.')
}
}
const toggleAnnouncementChannel = (channel: string) => {
setAnnouncementChannels((current) =>
current.includes(channel) ? current.filter((item) => item !== channel) : [...current, channel]
)
}
const sendAnnouncement = async () => {
setAnnouncementStatus(null)
if (!announcementSubject || !announcementBody) {
setAnnouncementStatus('Enter a subject and a message.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: announcementSubject,
body: announcementBody,
channels: announcementChannels,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Announcement failed')
}
setAnnouncementStatus('Announcement sent.')
setAnnouncementBody('')
setAnnouncementSubject('')
} catch (err) {
console.error(err)
setAnnouncementStatus('Could not send the announcement.')
}
}
const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null)
try {
@@ -1085,6 +1495,83 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label>
)
}
if (setting.key === 'captcha_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'none'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="none">No captcha</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Cloudflare Turnstile</option>
</select>
</label>
)
}
if (setting.key === 'expiry_default_action') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'disable'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
)
}
if (NUMBER_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<input
name={setting.key}
type="number"
min={0}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
@@ -1260,6 +1747,297 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
</section>
)}
{showInviteExtras && (
<section className="admin-section" id="invites">
<div className="section-header">
<h2>Invites</h2>
</div>
<div className="status-banner">
Create profiles for common rules, then issue invites in seconds.
</div>
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="invite-stack">
<div className="summary-card invite-card">
<h3>Create a profile</h3>
<p className="meta">Profiles save default rules for new invites.</p>
<div className="invite-grid">
<label>
Profile name
<input
value={profileForm.name}
onChange={(event) =>
setProfileForm((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label>
Description
<input
value={profileForm.description}
onChange={(event) =>
setProfileForm((current) => ({ ...current, description: event.target.value }))
}
/>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={profileForm.max_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', profileExpiry, setProfileExpiry)}
{renderDurationControl(
'User account expires after',
profileUserExpiry,
setProfileUserExpiry
)}
<label>
Expiry action
<select
value={profileForm.user_expiry_action}
onChange={(event) =>
setProfileForm((current) => ({
...current,
user_expiry_action: event.target.value,
}))
}
>
<option value="disable">Disable account</option>
<option value="delete">Delete account</option>
<option value="disable_then_delete">Disable, then delete</option>
</select>
</label>
<label>
Referral uses
<input
type="number"
min={0}
value={profileForm.referral_uses}
onChange={(event) =>
setProfileForm((current) => ({ ...current, referral_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for 1.</span>
</label>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.require_captcha}
onChange={(event) =>
setProfileForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={profileForm.allow_referrals}
onChange={(event) =>
setProfileForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Let users share their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteProfile}>
Save profile
</button>
</div>
</div>
<div className="summary-card invite-card">
<h3>Create an invite</h3>
<p className="meta">Pick a profile or customize a one-off invite.</p>
<div className="invite-grid">
<label>
Profile
<select
value={inviteForm.profile_id}
onChange={(event) =>
setInviteForm((current) => ({ ...current, profile_id: event.target.value }))
}
>
<option value="">No profile</option>
{inviteProfiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Max uses
<input
type="number"
min={0}
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
/>
<span className="field-help">Leave blank for unlimited.</span>
</label>
{renderDurationControl('Invite expires after', inviteExpiry, setInviteExpiry)}
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.require_captcha}
onChange={(event) =>
setInviteForm((current) => ({
...current,
require_captcha: event.target.checked,
}))
}
/>
<span>Require captcha</span>
</label>
<span className="toggle-help">Adds a human check to sign-up.</span>
</div>
<div className="toggle-row">
<label className="toggle">
<input
type="checkbox"
checked={inviteForm.allow_referrals}
onChange={(event) =>
setInviteForm((current) => ({
...current,
allow_referrals: event.target.checked,
}))
}
/>
<span>Allow referrals</span>
</label>
<span className="toggle-help">Lets this person create their own invite.</span>
</div>
<div className="admin-actions">
<button type="button" onClick={createInviteCode}>
Create invite
</button>
<button type="button" className="ghost-button" onClick={loadInvites}>
Refresh invites
</button>
</div>
</div>
</div>
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
<span>Expires</span>
<span>Status</span>
<span>Actions</span>
</div>
{invites.length === 0 ? (
<div className="meta">No invites yet.</div>
) : (
invites.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? 'Unlimited'}
</span>
<span>{invite.expires_at || 'Never'}</span>
<span>{invite.disabled ? 'Disabled' : 'Active'}</span>
<span className="cache-actions">
<button
type="button"
className="ghost-button"
onClick={() => disableInviteCode(invite.code)}
disabled={invite.disabled}
>
Disable
</button>
<button
type="button"
className="danger-button"
onClick={() => deleteInviteCode(invite.code)}
>
Delete
</button>
</span>
</div>
))
)}
</div>
</section>
)}
{showNotificationExtras && (
<section className="admin-section" id="announcements">
<div className="section-header">
<h2>Send an announcement</h2>
</div>
<div className="status-banner">
Send a message to all users through the channels you select.
</div>
{announcementStatus && <div className="status-banner">{announcementStatus}</div>}
<div className="admin-grid">
<label>
Subject
<input
value={announcementSubject}
onChange={(event) => setAnnouncementSubject(event.target.value)}
/>
</label>
<label>
Message
<textarea
rows={5}
value={announcementBody}
onChange={(event) => setAnnouncementBody(event.target.value)}
/>
</label>
<div className="settings-checkbox-grid">
{[
{ id: 'email', label: 'Email' },
{ id: 'discord', label: 'Discord' },
{ id: 'telegram', label: 'Telegram' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'pushover', label: 'Pushover' },
{ id: 'pushbullet', label: 'Pushbullet' },
{ id: 'gotify', label: 'Gotify' },
{ id: 'ntfy', label: 'ntfy' },
].map((channel) => (
<label key={channel.id} className="toggle">
<input
type="checkbox"
checked={announcementChannels.includes(channel.id)}
onChange={() => toggleAnnouncementChannel(channel.id)}
/>
<span>{channel.label}</span>
</label>
))}
</div>
</div>
<div className="admin-actions">
<button type="button" onClick={sendAnnouncement}>
Send announcement
</button>
</div>
</section>
)}
</AdminShell>
)
}

View File

@@ -11,12 +11,6 @@ const ALLOWED_SECTIONS = new Set([
'qbittorrent',
'requests',
'cache',
'invites',
'password',
'captcha',
'smtp',
'notifications',
'expiry',
'logs',
'maintenance',
])

View File

@@ -659,6 +659,21 @@ button span {
justify-items: end;
}
.user-bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.user-bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.toggle {
display: inline-flex;
gap: 8px;
@@ -790,6 +805,11 @@ button span {
gap: 10px;
}
.captcha-wrap {
display: flex;
justify-content: center;
}
.ghost-button {
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
@@ -937,6 +957,103 @@ button span {
gap: 16px;
}
.settings-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.duration-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 1fr);
gap: 10px;
align-items: center;
}
.duration-row input,
.duration-row select {
width: 100%;
}
.invite-stack {
display: grid;
gap: 16px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: stretch;
}
.invite-card h3 {
margin: 0;
font-size: 18px;
}
.invite-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
align-items: start;
}
.invite-card {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.invite-card .admin-actions {
margin-top: auto;
}
.invite-grid label {
display: grid;
gap: 8px;
font-size: 14px;
color: var(--ink-muted);
text-align: left;
}
.invite-grid input,
.invite-grid select {
width: 100%;
}
.toggle-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px 14px;
}
.toggle-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-inline {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(180px, 260px);
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--ink-muted);
}
.field-stack {
display: grid;
gap: 6px;
}
.field-stack .field-help {
text-align: left;
}
.admin-grid label {
display: grid;
gap: 8px;
@@ -1058,7 +1175,7 @@ button span {
.cache-row {
display: grid;
grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px;
grid-template-columns: minmax(140px, 1.4fr) 100px 140px 100px 180px;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
@@ -1066,6 +1183,7 @@ button span {
background: rgba(255, 255, 255, 0.06);
font-size: 13px;
color: var(--ink);
align-items: center;
}
.cache-row span {
@@ -1082,6 +1200,13 @@ button span {
letter-spacing: 0.08em;
}
.cache-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.maintenance-grid {
display: grid;
gap: 12px;
@@ -1399,6 +1524,22 @@ button span {
grid-template-columns: 1fr;
}
.duration-row {
grid-template-columns: 1fr;
}
.field-inline {
grid-template-columns: 1fr;
}
.cache-row {
grid-template-columns: 1fr;
}
.toggle-row {
grid-template-columns: 1fr;
}
.card {
padding: 24px;
}

View File

@@ -86,9 +86,6 @@ export default function LoginPage() {
Sign in with Magent account
</button>
</form>
<div className="status-banner">
Have an invite? <a href="/register">Create an account</a>
</div>
</main>
)
}

View File

@@ -10,25 +10,12 @@ type ProfileInfo = {
auth_provider: string
}
type ContactInfo = {
email?: string | null
discord?: string | null
telegram?: string | null
matrix?: string | null
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [contact, setContact] = useState<ContactInfo>({})
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [contactStatus, setContactStatus] = useState<string | null>(null)
const [referrals, setReferrals] = useState<
{ code: string; uses_count?: number; max_uses?: number | null }[]
>([])
const [referralStatus, setReferralStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -51,23 +38,6 @@ export default function ProfilePage() {
role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local',
})
const contactResponse = await authFetch(`${baseUrl}/auth/contact`)
if (contactResponse.ok) {
const contactData = await contactResponse.json()
setContact({
email: contactData?.contact?.email ?? '',
discord: contactData?.contact?.discord ?? '',
telegram: contactData?.contact?.telegram ?? '',
matrix: contactData?.contact?.matrix ?? '',
})
}
const referralResponse = await authFetch(`${baseUrl}/auth/referrals`)
if (referralResponse.ok) {
const referralData = await referralResponse.json()
if (Array.isArray(referralData?.invites)) {
setReferrals(referralData.invites)
}
}
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
@@ -108,55 +78,6 @@ export default function ProfilePage() {
}
}
const submitContact = async (event: React.FormEvent) => {
event.preventDefault()
setContactStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: contact.email,
discord: contact.discord,
telegram: contact.telegram,
matrix: contact.matrix,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setContactStatus('Contact details saved.')
} catch (err) {
console.error(err)
setContactStatus('Could not update contact details.')
}
}
const createReferral = async () => {
setReferralStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/referrals`, { method: 'POST' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Could not create referral invite')
}
const data = await response.json()
if (data?.code) {
setReferrals((current) => [
{ code: data.code, uses_count: 0, max_uses: 1 },
...current,
])
}
setReferralStatus('Referral invite created.')
} catch (err) {
console.error(err)
setReferralStatus('Could not create a referral invite.')
}
}
if (loading) {
return <main className="card">Loading profile...</main>
}
@@ -200,82 +121,6 @@ export default function ProfilePage() {
</div>
</form>
)}
<form onSubmit={submitContact} className="auth-form">
<h2>Contact details</h2>
<label>
Email address
<input
type="email"
value={contact.email || ''}
onChange={(event) => setContact((current) => ({ ...current, email: event.target.value }))}
autoComplete="email"
/>
</label>
<label>
Discord handle
<input
type="text"
value={contact.discord || ''}
onChange={(event) =>
setContact((current) => ({ ...current, discord: event.target.value }))
}
/>
</label>
<label>
Telegram ID
<input
type="text"
value={contact.telegram || ''}
onChange={(event) =>
setContact((current) => ({ ...current, telegram: event.target.value }))
}
/>
</label>
<label>
Matrix ID
<input
type="text"
value={contact.matrix || ''}
onChange={(event) =>
setContact((current) => ({ ...current, matrix: event.target.value }))
}
/>
</label>
{contactStatus && <div className="status-banner">{contactStatus}</div>}
<div className="auth-actions">
<button type="submit">Save contact details</button>
</div>
</form>
<section className="summary-card">
<h2>Referral invites</h2>
<p className="meta">
Share a referral invite with friends or family. Each invite has limited uses.
</p>
{referralStatus && <div className="status-banner">{referralStatus}</div>}
<div className="auth-actions">
<button type="button" onClick={createReferral}>
Create referral invite
</button>
</div>
{referrals.length === 0 ? (
<div className="meta">No referral invites yet.</div>
) : (
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
</div>
{referrals.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? '∞'}
</span>
</div>
))}
</div>
)}
</section>
</main>
)
}

View File

@@ -1,194 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase, setToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
type SignupConfig = {
invites_enabled: boolean
captcha_provider: string
hcaptcha_site_key?: string | null
recaptcha_site_key?: string | null
turnstile_site_key?: string | null
}
export default function RegisterPage() {
const router = useRouter()
const [config, setConfig] = useState<SignupConfig | null>(null)
const [inviteCode, setInviteCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [discord, setDiscord] = useState('')
const [telegram, setTelegram] = useState('')
const [matrix, setMatrix] = useState('')
const [captchaToken, setCaptchaToken] = useState('')
const [status, setStatus] = useState<string | null>(null)
useEffect(() => {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/signup/config`)
if (!response.ok) {
throw new Error('Signup unavailable')
}
const data = await response.json()
setConfig(data)
} catch (err) {
console.error(err)
setStatus('Sign-up is not available right now.')
}
}
void load()
}, [])
useEffect(() => {
if (!config?.captcha_provider || config.captcha_provider === 'none') {
return
}
const provider = config.captcha_provider
const script = document.createElement('script')
if (provider === 'hcaptcha') {
script.src = 'https://js.hcaptcha.com/1/api.js'
} else if (provider === 'recaptcha') {
script.src = 'https://www.google.com/recaptcha/api.js'
} else if (provider === 'turnstile') {
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
}
script.async = true
script.defer = true
document.body.appendChild(script)
;(window as any).magentCaptchaCallback = (token: string) => {
setCaptchaToken(token)
}
return () => {
document.body.removeChild(script)
}
}, [config?.captcha_provider])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_code: inviteCode,
username,
password,
contact: { email, discord, telegram, matrix },
captcha_token: captchaToken,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Registration failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
}
router.push('/')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not complete sign-up.'
setStatus(message)
}
}
const captchaProvider = config?.captcha_provider || 'none'
const captchaKey =
captchaProvider === 'hcaptcha'
? config?.hcaptcha_site_key
: captchaProvider === 'recaptcha'
? config?.recaptcha_site_key
: config?.turnstile_site_key
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create your account</h1>
{!config?.invites_enabled ? (
<div className="status-banner">Sign-ups are currently closed.</div>
) : (
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<input
type="text"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
required
/>
</label>
<label>
Username
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<label>
Email address (optional)
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label>
Discord handle (optional)
<input
type="text"
value={discord}
onChange={(event) => setDiscord(event.target.value)}
/>
</label>
<label>
Telegram ID (optional)
<input
type="text"
value={telegram}
onChange={(event) => setTelegram(event.target.value)}
/>
</label>
<label>
Matrix ID (optional)
<input type="text" value={matrix} onChange={(event) => setMatrix(event.target.value)} />
</label>
{captchaProvider !== 'none' && captchaKey ? (
<div className="captcha-wrap">
{captchaProvider === 'hcaptcha' && (
<div className="h-captcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
{captchaProvider === 'recaptcha' && (
<div className="g-recaptcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
{captchaProvider === 'turnstile' && (
<div className="cf-turnstile" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
</div>
) : null}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Create account</button>
</div>
</form>
)}
</main>
)
}

View File

@@ -22,17 +22,6 @@ const NAV_GROUPS = [
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Accounts',
items: [
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/password', label: 'Password rules' },
{ href: '/admin/captcha', label: 'Captcha' },
{ href: '/admin/smtp', label: 'Email (SMTP)' },
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/expiry', label: 'Account expiry' },
],
},
{
title: 'Admin',
items: [

View File

@@ -25,9 +25,6 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string[]>([])
const [bulkAction, setBulkAction] = useState('block')
const [bulkRole, setBulkRole] = useState('user')
const loadUsers = async () => {
try {
@@ -106,43 +103,6 @@ export default function UsersPage() {
}
}
const toggleSelect = (username: string, isChecked: boolean) => {
setSelected((current) =>
isChecked ? [...new Set([...current, username])] : current.filter((name) => name !== username)
)
}
const toggleSelectAll = (isChecked: boolean) => {
setSelected(isChecked ? users.map((user) => user.username) : [])
}
const runBulkAction = async () => {
if (selected.length === 0) {
setError('Select at least one user to run a bulk action.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: bulkAction,
role: bulkRole,
usernames: selected,
}),
})
if (!response.ok) {
throw new Error('Bulk update failed')
}
setSelected([])
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not run the bulk action.')
}
}
useEffect(() => {
if (!getToken()) {
@@ -168,35 +128,6 @@ export default function UsersPage() {
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{users.length > 0 && (
<div className="summary-card user-bulk-bar">
<label className="toggle">
<input
type="checkbox"
checked={selected.length === users.length}
onChange={(event) => toggleSelectAll(event.target.checked)}
/>
<span>Select all</span>
</label>
<div className="user-bulk-actions">
<select value={bulkAction} onChange={(event) => setBulkAction(event.target.value)}>
<option value="block">Block access</option>
<option value="unblock">Allow access</option>
<option value="role">Set role</option>
<option value="delete">Delete users</option>
</select>
{bulkAction === 'role' && (
<select value={bulkRole} onChange={(event) => setBulkRole(event.target.value)}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
<button type="button" onClick={runBulkAction}>
Apply to {selected.length} selected
</button>
</div>
</div>
)}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
@@ -204,14 +135,6 @@ export default function UsersPage() {
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<label className="toggle">
<input
type="checkbox"
checked={selected.includes(user.username)}
onChange={(event) => toggleSelect(user.username, event.target.checked)}
/>
<span>Select</span>
</label>
<strong>{user.username}</strong>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span>