From d2ff2b3e415b86d5c013f1a3bae41a2682f9be6a Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 18:51:15 +1300 Subject: [PATCH] Sync dev changes into release-1.0 --- backend/app/clients/jellyseerr.py | 10 + backend/app/config.py | 153 +++++ backend/app/db.py | 739 ++++++++++++++++++++++++ backend/app/main.py | 4 + backend/app/routers/admin.py | 238 ++++++++ backend/app/routers/auth.py | 253 +++++++- backend/app/runtime.py | 27 + backend/app/services/captcha.py | 43 ++ backend/app/services/expiry.py | 47 ++ backend/app/services/jellyseerr_sync.py | 56 ++ backend/app/services/notifications.py | 216 +++++++ frontend/app/admin/[section]/page.tsx | 6 + frontend/app/login/page.tsx | 3 + frontend/app/profile/page.tsx | 155 +++++ frontend/app/register/page.tsx | 194 +++++++ frontend/app/ui/AdminSidebar.tsx | 11 + frontend/app/users/page.tsx | 77 +++ 17 files changed, 2227 insertions(+), 5 deletions(-) create mode 100644 backend/app/services/captcha.py create mode 100644 backend/app/services/expiry.py create mode 100644 backend/app/services/jellyseerr_sync.py create mode 100644 backend/app/services/notifications.py create mode 100644 frontend/app/register/page.tsx diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py index fdb32fa..39db292 100644 --- a/backend/app/clients/jellyseerr.py +++ b/backend/app/clients/jellyseerr.py @@ -18,6 +18,16 @@ 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}") diff --git a/backend/app/config.py b/backend/app/config.py index 902d658..3a0dbc0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -105,6 +105,159 @@ 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() diff --git a/backend/app/db.py b/backend/app/db.py index 72cac98..e186225 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -65,6 +65,111 @@ 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 ( @@ -271,6 +376,32 @@ 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( @@ -327,6 +458,17 @@ 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: @@ -347,6 +489,603 @@ 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( diff --git a/backend/app/main.py b/backend/app/main.py index 4d9a876..0c5183e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,8 @@ 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 @@ -43,10 +45,12 @@ 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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a5603f3..65f461b 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,4 +1,6 @@ from typing import Any, Dict, List +from datetime import datetime, timedelta, timezone +import secrets import os from fastapi import APIRouter, HTTPException, Depends, UploadFile, File @@ -8,7 +10,17 @@ 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, @@ -27,10 +39,12 @@ 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__) @@ -42,6 +56,17 @@ 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] = [ @@ -74,6 +99,59 @@ 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]]: @@ -208,6 +286,12 @@ 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() @@ -365,3 +449,157 @@ 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} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 97f0d67..878bf04 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,21 +1,60 @@ -from fastapi import APIRouter, HTTPException, status, Depends +from datetime import datetime, timedelta, timezone +from typing import Optional +from fastapi import APIRouter, HTTPException, status, Depends, Request 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: @@ -103,12 +142,216 @@ 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") - if len(new_password.strip()) < 8: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters." - ) + error = _validate_password(new_password.strip()) + if error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) 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} diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 1ad0fc4..8d9a0f8 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -2,16 +2,43 @@ 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", } diff --git a/backend/app/services/captcha.py b/backend/app/services/captcha.py new file mode 100644 index 0000000..7d8c140 --- /dev/null +++ b/backend/app/services/captcha.py @@ -0,0 +1,43 @@ +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 diff --git a/backend/app/services/expiry.py b/backend/app/services/expiry.py new file mode 100644 index 0000000..2c77611 --- /dev/null +++ b/backend/app/services/expiry.py @@ -0,0 +1,47 @@ +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) diff --git a/backend/app/services/jellyseerr_sync.py b/backend/app/services/jellyseerr_sync.py new file mode 100644 index 0000000..fc4b195 --- /dev/null +++ b/backend/app/services/jellyseerr_sync.py @@ -0,0 +1,56 @@ +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) diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..dd507d5 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,216 @@ +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 diff --git a/frontend/app/admin/[section]/page.tsx b/frontend/app/admin/[section]/page.tsx index de5832f..22c1da5 100644 --- a/frontend/app/admin/[section]/page.tsx +++ b/frontend/app/admin/[section]/page.tsx @@ -11,6 +11,12 @@ const ALLOWED_SECTIONS = new Set([ 'qbittorrent', 'requests', 'cache', + 'invites', + 'password', + 'captcha', + 'smtp', + 'notifications', + 'expiry', 'logs', 'maintenance', ]) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 44564a6..fa3bb1f 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -86,6 +86,9 @@ export default function LoginPage() { Sign in with Magent account +
+ Have an invite? Create an account +
) } diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index f1beac7..8556a0f 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -10,12 +10,25 @@ 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(null) + const [contact, setContact] = useState({}) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [status, setStatus] = useState(null) + const [contactStatus, setContactStatus] = useState(null) + const [referrals, setReferrals] = useState< + { code: string; uses_count?: number; max_uses?: number | null }[] + >([]) + const [referralStatus, setReferralStatus] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { @@ -38,6 +51,23 @@ 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.') @@ -78,6 +108,55 @@ 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
Loading profile...
} @@ -121,6 +200,82 @@ export default function ProfilePage() { )} +
+

Contact details

+ + + + + {contactStatus &&
{contactStatus}
} +
+ +
+
+
+

Referral invites

+

+ Share a referral invite with friends or family. Each invite has limited uses. +

+ {referralStatus &&
{referralStatus}
} +
+ +
+ {referrals.length === 0 ? ( +
No referral invites yet.
+ ) : ( +
+
+ Code + Uses +
+ {referrals.map((invite) => ( +
+ {invite.code} + + {invite.uses_count ?? 0}/{invite.max_uses ?? '∞'} + +
+ ))} +
+ )} +
) } diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..a527afc --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,194 @@ +'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(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(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 ( +
+ +

Create your account

+ {!config?.invites_enabled ? ( +
Sign-ups are currently closed.
+ ) : ( +
+ + + + + + + + {captchaProvider !== 'none' && captchaKey ? ( +
+ {captchaProvider === 'hcaptcha' && ( +
+ )} + {captchaProvider === 'recaptcha' && ( +
+ )} + {captchaProvider === 'turnstile' && ( +
+ )} +
+ ) : null} + {status &&
{status}
} +
+ +
+ + )} +
+ ) +} diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx index 972ab8a..4abda1a 100644 --- a/frontend/app/ui/AdminSidebar.tsx +++ b/frontend/app/ui/AdminSidebar.tsx @@ -22,6 +22,17 @@ 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: [ diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index 0171d4a..30a53d6 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -25,6 +25,9 @@ export default function UsersPage() { const [users, setUsers] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [selected, setSelected] = useState([]) + const [bulkAction, setBulkAction] = useState('block') + const [bulkRole, setBulkRole] = useState('user') const loadUsers = async () => { try { @@ -103,6 +106,43 @@ 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()) { @@ -128,6 +168,35 @@ export default function UsersPage() { >
{error &&
{error}
} + {users.length > 0 && ( +
+ +
+ + {bulkAction === 'role' && ( + + )} + +
+
+ )} {users.length === 0 ? (
No users found yet.
) : ( @@ -135,6 +204,14 @@ export default function UsersPage() { {users.map((user) => (
+ {user.username} Role: {user.role} Login type: {user.authProvider || 'local'}