From 3f9c395b17e9d948cf26839b3669a83f18f17941 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 19:52:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20user=20management=20=E2=80=94=20?= =?UTF-8?q?roles,=20suspend,=20delete=20+=20lifecycle=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration page, both via a reusable Tabs component (persisted active tab). - Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts from the Users & roles tab, with guards (demo / self / last admin). - Email notifications to the affected user: suspended (reactive, on a blocked sign-in), reinstated, role changed, and account deleted; the approval email now carries a clickable app link. The scheduled/manual demo reset also seeds sample channel subscriptions. - Hide the 'unverified email' chip for Google accounts (Google attests the email). --- .../alembic/versions/0023_user_suspension.py | 30 +++++ backend/app/email.py | 62 ++++++++- backend/app/models.py | 6 + backend/app/routes/admin.py | 101 +++++++++++++- frontend/src/components/AdminUsers.tsx | 123 +++++++++++++++--- frontend/src/components/ConfigPanel.tsx | 28 ++-- frontend/src/components/Tabs.tsx | 62 +++++++++ frontend/src/i18n/locales/de/users.json | 27 +++- frontend/src/i18n/locales/en/users.json | 27 +++- frontend/src/i18n/locales/hu/users.json | 27 +++- 10 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 backend/alembic/versions/0023_user_suspension.py create mode 100644 frontend/src/components/Tabs.tsx diff --git a/backend/alembic/versions/0023_user_suspension.py b/backend/alembic/versions/0023_user_suspension.py new file mode 100644 index 0000000..51f20fa --- /dev/null +++ b/backend/alembic/versions/0023_user_suspension.py @@ -0,0 +1,30 @@ +"""admin account suspension + +Revision ID: 0023_user_suspension +Revises: 0022_password_auth +Create Date: 2026-06-19 + +Adds users.is_suspended — an admin-controlled access block, distinct from is_active (the +approval lifecycle). A suspended account can't sign in (password or Google) and any live +session is rejected; the user is told why and given the operator's contact. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0023_user_suspension" +down_revision: Union[str, None] = "0022_password_auth" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_suspended", sa.Boolean(), nullable=False, server_default="false"), + ) + + +def downgrade() -> None: + op.drop_column("users", "is_suspended") diff --git a/backend/app/email.py b/backend/app/email.py index d1af768..8e1e653 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -74,8 +74,8 @@ def send_access_approved(email: str) -> bool: body = ( "Hi,\n\n" "Your request to use Siftlode has been approved — welcome aboard.\n\n" - "Just open Siftlode and sign in with this Google account, and your YouTube\n" - "subscriptions feed will be ready.\n\n" + "Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n" + f"{settings.app_base}\n\n" "If you weren't expecting this, you can ignore the message.\n\n" "— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") @@ -83,6 +83,64 @@ def send_access_approved(email: str) -> bool: return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) +def send_role_changed(to: str, role: str, operator: str | None) -> bool: + line = ( + "You've been granted admin access on Siftlode — you can now manage settings, the " + "scheduler and users." + if role == "admin" + else "Your admin access on Siftlode has been removed — you're now a regular user." + ) + contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else "" + body = "Hi,\n\n" + line + contact + "\n\n— Siftlode" + return _send([to], "Your Siftlode role has changed", body, reply_to=operator) + + +def send_account_unsuspended(to: str, operator: str | None) -> bool: + contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else "" + body = ( + "Hi,\n\n" + "Your Siftlode account has been reinstated — the suspension was lifted and you can sign " + "in again." + + contact + + "\n\n— Siftlode" + ) + return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator) + + +def send_account_deleted(to: str, operator: str | None) -> bool: + contact = ( + f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}." + if operator + else "" + ) + body = ( + "Hi,\n\n" + "Your Siftlode account and all associated data — subscriptions, tags, watch history, " + "playlists and settings — have been permanently deleted. Nothing is retained.\n\n" + "Thanks for having used Siftlode." + + contact + + "\n\n— Siftlode" + ) + return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator) + + +def send_account_suspended(to: str, operator: str | None) -> bool: + contact = ( + f"If you think this is a mistake or have questions, contact the Siftlode operator " + f"at {operator}.\n\n" + if operator + else "If you think this is a mistake, please contact the Siftlode operator.\n\n" + ) + body = ( + "Hi,\n\n" + "A sign-in to your Siftlode account was just attempted, but the account is currently " + "suspended, so access was denied.\n\n" + + contact + + "— Siftlode" + ) + return _send([to], "Your Siftlode account is suspended", body, reply_to=operator) + + def send_admin_new_request(admins: list[str], requester: str) -> bool: body = ( f"{requester} just requested access to Siftlode.\n\n" diff --git a/backend/app/models.py b/backend/app/models.py index 4f12e9f..388ba36 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -37,6 +37,12 @@ class User(Base): # Admin-approved & enabled. A pending password registration starts inactive; admin # approval activates it. A deactivated account can't log in. is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + # Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended + # account can't sign in by any method and its live sessions are rejected; the user is told + # why and pointed at the operator's contact. The demo account is never suspendable. + is_suspended: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 176c1b9..1f7d3d8 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -8,13 +8,14 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import email as email_mod -from app.auth import current_user, get_or_create_demo_user +from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user from app.db import get_db from app.models import ( DemoWhitelist, Invite, Playlist, PlaylistItem, + Subscription, User, Video, VideoState, @@ -132,6 +133,7 @@ def _serialize_user(u: User) -> dict: "display_name": u.display_name, "role": u.role, "is_active": u.is_active, + "is_suspended": u.is_suspended, "email_verified": u.email_verified, "is_demo": u.is_demo, "has_password": u.password_hash is not None, @@ -150,11 +152,12 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> def set_user_role( user_id: int, payload: dict, + background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Promote/demote a user. Guards: can't change your own role, can't touch the demo - account, and can't remove the last remaining admin.""" + account, and can't remove the last remaining admin. Emails the user when it actually changes.""" role = payload.get("role") if role not in ("user", "admin"): raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") @@ -169,11 +172,82 @@ def set_user_role( admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) if (admin_count or 0) <= 1: raise HTTPException(status_code=400, detail="Can't remove the last admin.") + changed = target.role != role target.role = role db.commit() + if changed: + background.add_task(email_mod.send_role_changed, target.email, role, operator_contact()) return _serialize_user(target) +@router.patch("/users/{user_id}/suspend") +def set_user_suspended( + user_id: int, + payload: dict, + background: BackgroundTasks, + admin: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Suspend or un-suspend an account. A suspended user can't sign in by any method and any + live session is dropped on its next request. Guards: can't suspend the demo account, can't + suspend yourself, and can't suspend the last active admin (that would lock admin out). On + un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked + sign-in).""" + suspended = bool(payload.get("suspended")) + target = db.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="No such user") + if target.is_demo: + raise HTTPException(status_code=400, detail="The demo account can't be suspended.") + if target.id == admin.id: + raise HTTPException(status_code=400, detail="You can't suspend your own account.") + if suspended and target.role == "admin" and not target.is_suspended: + active_admins = db.scalar( + select(func.count()) + .select_from(User) + .where(User.role == "admin", User.is_suspended.is_(False)) + ) + if (active_admins or 0) <= 1: + raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") + was_suspended = target.is_suspended + target.is_suspended = suspended + db.commit() + if was_suspended and not suspended: + background.add_task( + email_mod.send_account_unsuspended, target.email, operator_contact() + ) + return _serialize_user(target) + + +@router.delete("/users/{user_id}") +def admin_delete_user( + user_id: int, + background: BackgroundTasks, + admin: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Admin-triggered account deletion — the same full erasure a user can perform on themselves + (cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards: + not the demo account, not yourself (use Settings → Account), and not the last admin.""" + target = db.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="No such user") + if target.is_demo: + raise HTTPException(status_code=400, detail="The demo account can't be deleted.") + if target.id == admin.id: + raise HTTPException( + status_code=400, detail="Delete your own account from Settings → Account." + ) + if target.role == "admin": + admin_count = db.scalar( + select(func.count()).select_from(User).where(User.role == "admin") + ) + if (admin_count or 0) <= 1: + raise HTTPException(status_code=400, detail="Can't delete the last admin.") + purge_user(db, target, background) + return {"deleted": user_id} + + # --- Demo account: email whitelist + state reset ------------------------------------- def _serialize_demo(row: DemoWhitelist) -> dict: @@ -270,16 +344,35 @@ def _seed_demo_playlists(db: Session, demo: User) -> int: return created +def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int: + """Seed the demo with a handful of catalog channels (the ones with the most videos) so its + Channel manager isn't empty — gives visitors something to explore and matches the landing-page + screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N.""" + db.execute(delete(Subscription).where(Subscription.user_id == demo.id)) + top = db.execute( + select(Video.channel_id) + .where(Video.channel_id.is_not(None)) + .group_by(Video.channel_id) + .order_by(func.count().desc()) + .limit(n) + ).scalars().all() + for channel_id in top: + db.add(Subscription(user_id=demo.id, channel_id=channel_id)) + return len(top) + + def reset_demo_state(db: Session, demo: User) -> int: """Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences) - back to a clean baseline and re-seed a few sample playlists. Returns the seeded count. - Shared by the admin's manual reset button and the scheduled demo reset (see scheduler).""" + back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns + the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo + reset (see scheduler).""" db.execute(delete(VideoState).where(VideoState.user_id == demo.id)) # Playlist items cascade on playlist delete (ondelete="CASCADE"). db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) demo.preferences = {"language": "en"} db.commit() seeded = _seed_demo_playlists(db, demo) + _seed_demo_subscriptions(db, demo) db.commit() return seeded diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d3172b1..20165b6 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -1,21 +1,31 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; +import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import { useConfirm } from "./ConfirmProvider"; +import Tabs, { usePersistedTab } from "./Tabs"; // Admin-only user & access management: roles, access requests (the Invite whitelist), and the -// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite -// and demo data are unchanged (same tables) — only the UI relocated. +// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab +// carries a badge with the count of pending requests so it's visible without switching to it. export default function AdminUsers({ me }: { me: Me }) { + const { t } = useTranslation(); + const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles"); + const tabs = [ + { id: "roles", label: t("users.tabs.roles") }, + { id: "access", label: t("users.tabs.access"), badge: me.pending_invites }, + { id: "demo", label: t("users.tabs.demo") }, + ]; + const active = tabs.some((x) => x.id === tab) ? tab : "roles"; return (
- - - + + {active === "roles" && } + {active === "access" && } + {active === "demo" && }
); } @@ -47,14 +57,37 @@ function UsersRoles({ me }: { me: Me }) { const confirm = useConfirm(); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); const setRole = useMutation({ - mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role), - onSuccess: () => { + mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => + api.setUserRole(id, role), + onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); - notify({ level: "success", message: t("users.roles.updated") }); + notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. }); + const suspend = useMutation({ + mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) => + api.setUserSuspended(id, suspended), + onSuccess: (_d, vars) => { + qc.invalidateQueries({ queryKey: ["admin-users"] }); + notify({ + level: "success", + message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", { + email: vars.email, + }), + }); + }, + // Guards (demo / self / last admin) surface via the global error dialog. + }); + const del = useMutation({ + mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), + onSuccess: (_d, vars) => { + qc.invalidateQueries({ queryKey: ["admin-users"] }); + notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); + }, + }); + const onToggle = async (u: AdminUserRow) => { const makeAdmin = u.role !== "admin"; const ok = await confirm({ @@ -65,7 +98,30 @@ function UsersRoles({ me }: { me: Me }) { confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"), danger: !makeAdmin, }); - if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user" }); + if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user", email: u.email }); + }; + + const onSuspend = async (u: AdminUserRow) => { + const willSuspend = !u.is_suspended; + const ok = await confirm({ + title: t(willSuspend ? "users.suspend.title" : "users.suspend.undoTitle"), + message: t(willSuspend ? "users.suspend.confirm" : "users.suspend.undoConfirm", { + email: u.email, + }), + confirmLabel: t(willSuspend ? "users.suspend.action" : "users.suspend.undoAction"), + danger: willSuspend, + }); + if (ok) suspend.mutate({ id: u.id, suspended: willSuspend, email: u.email }); + }; + + const onDelete = async (u: AdminUserRow) => { + const ok = await confirm({ + title: t("users.delete.title"), + message: t("users.delete.confirm", { email: u.email }), + confirmLabel: t("users.delete.action"), + danger: true, + }); + if (ok) del.mutate({ id: u.id, email: u.email }); }; const rows = q.data ?? []; @@ -90,8 +146,11 @@ function UsersRoles({ me }: { me: Me }) { {u.role === "admin" && {t("users.roles.admin")}} {u.is_demo && {t("users.roles.demo")}} + {u.is_suspended && {t("users.roles.suspended")}} {!u.is_active && {t("users.roles.pending")}} - {u.is_active && !u.email_verified && ( + {/* A Google sign-in proves the email, so "unverified" only applies to a + password-only account that hasn't clicked its verification link. */} + {u.is_active && !u.email_verified && !u.has_google && ( {t("users.roles.unverified")} )} + + + + + + ); })} @@ -135,10 +228,10 @@ function AdminInvites() { qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge }; const approve = useMutation({ - mutationFn: (id: number) => api.approveInvite(id), - onSuccess: () => { + mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id), + onSuccess: (_d, vars) => { refresh(); - notify({ level: "success", message: t("settings.invites.approved") }); + notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) }); }, onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }), }); @@ -171,7 +264,7 @@ function AdminInvites() { approve.mutate(i.id)} + onApprove={() => approve.mutate({ id: i.id, email: i.email })} onDeny={() => deny.mutate(i.id)} busy={approve.isPending || deny.isPending} /> diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 9141265..b614eec 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -5,6 +5,7 @@ import { Check, RotateCcw, Save, Send } from "lucide-react"; import { api, type ConfigItem } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; +import Tabs, { usePersistedTab } from "./Tabs"; // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // backend — adding a key there makes it appear here automatically). Edits are drafted and @@ -35,6 +36,9 @@ export default function ConfigPanel() { // clearing the field, since an empty secret field means "leave unchanged"). const [secretReset, setSecretReset] = useState>({}); const [saveState, setSaveState] = useState("idle"); + // One tab per config group (persisted). Called before the loading guard so hook order is + // stable; the active id is clamped to a real group at render time once data has loaded. + const [tab, setTab] = usePersistedTab("siftlode.configTab"); // Re-seed the draft whenever the server data changes (initial load + after a save/reset). useEffect(() => { @@ -94,18 +98,26 @@ export default function ConfigPanel() { const groupKeys = Object.keys(data.groups).sort( (a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99) ); + // Clamp the persisted tab to a real group (the registry can change between sessions). The + // tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is + // never silently lost behind the global Save bar. + const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; + const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean)); + const groupTabs = groupKeys.map((g) => ({ + id: g, + label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`, + })); return (

{t("config.intro")}

- {groupKeys.map((g) => ( -
-
- {t(`config.groups.${g}`, g)} -
+ + + {activeGroup && ( +
- {data.groups[g].map((item) => ( + {data.groups[activeGroup].map((item) => ( - {g === "email" && ( + {activeGroup === "email" && (
- ))} + )} {(dirty || saveState !== "idle") && (
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 0000000..7871c3d --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). +// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, +// per the app's "persist UI state across reload" convention. + +export interface TabDef { + id: string; + label: string; + badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined +} + +/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at + * render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */ +export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] { + const [tab, setTabState] = useState(() => localStorage.getItem(storageKey) ?? fallback); + const setTab = (id: string) => { + setTabState(id); + localStorage.setItem(storageKey, id); + }; + return [tab, setTab]; +} + +export default function Tabs({ + tabs, + active, + onChange, +}: { + tabs: TabDef[]; + active: string; + onChange: (id: string) => void; +}) { + return ( +
+ {tabs.map((tb) => { + const on = tb.id === active; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/i18n/locales/de/users.json b/frontend/src/i18n/locales/de/users.json index e115bb7..e45c288 100644 --- a/frontend/src/i18n/locales/de/users.json +++ b/frontend/src/i18n/locales/de/users.json @@ -1,4 +1,9 @@ { + "tabs": { + "roles": "Benutzer & Rollen", + "access": "Zugriffsanfragen", + "demo": "Demo" + }, "roles": { "title": "Benutzer & Rollen", "intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.", @@ -6,16 +11,36 @@ "you": "du", "admin": "Admin", "demo": "Demo", + "suspended": "Gesperrt", "pending": "Wartet auf Freigabe", "unverified": "E-Mail nicht bestätigt", "demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.", "selfLocked": "Du kannst deine eigene Rolle nicht ändern.", "makeAdmin": "Zum Admin machen", "makeUser": "Zum Benutzer machen", - "updated": "Rolle aktualisiert", + "updated": "Rolle aktualisiert für {{email}}", "promoteTitle": "Zum Admin machen?", "demoteTitle": "Admin entfernen?", "promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?", "demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer." + }, + "suspend": { + "action": "Sperren", + "undoAction": "Entsperren", + "title": "Konto sperren?", + "undoTitle": "Konto entsperren?", + "confirm": "{{email}} sperren? Eine Anmeldung (per Passwort oder Google) ist erst nach dem Entsperren wieder möglich, und jede aktive Sitzung endet.", + "undoConfirm": "{{email}} entsperren? Eine Anmeldung ist dann wieder möglich.", + "done": "{{email}} gesperrt", + "undone": "{{email}} entsperrt", + "demoLocked": "Das Demo-Konto kann nicht gesperrt werden.", + "selfLocked": "Du kannst dein eigenes Konto nicht sperren." + }, + "delete": { + "action": "Löschen", + "locked": "Dieses Konto kann hier nicht gelöscht werden.", + "title": "Konto löschen?", + "confirm": "{{email}} und alle zugehörigen Daten endgültig löschen — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen? Das kann nicht rückgängig gemacht werden.", + "done": "{{email}} gelöscht" } } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 47535f3..a5f955d 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -1,4 +1,9 @@ { + "tabs": { + "roles": "Users & roles", + "access": "Access requests", + "demo": "Demo" + }, "roles": { "title": "Users & roles", "intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.", @@ -6,16 +11,36 @@ "you": "you", "admin": "Admin", "demo": "Demo", + "suspended": "Suspended", "pending": "Pending approval", "unverified": "Unverified email", "demoLocked": "The demo account's role can't be changed.", "selfLocked": "You can't change your own role.", "makeAdmin": "Make admin", "makeUser": "Make user", - "updated": "Role updated", + "updated": "Role updated for {{email}}", "promoteTitle": "Make admin?", "demoteTitle": "Remove admin?", "promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?", "demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user." + }, + "suspend": { + "action": "Suspend", + "undoAction": "Unsuspend", + "title": "Suspend account?", + "undoTitle": "Unsuspend account?", + "confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends.", + "undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.", + "done": "{{email}} suspended", + "undone": "{{email}} unsuspended", + "demoLocked": "The demo account can't be suspended.", + "selfLocked": "You can't suspend your own account." + }, + "delete": { + "action": "Delete", + "locked": "This account can't be deleted here.", + "title": "Delete account?", + "confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.", + "done": "{{email}} deleted" } } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 2a8713a..d65554d 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -1,4 +1,9 @@ { + "tabs": { + "roles": "Felhasználók és szerepek", + "access": "Hozzáférési kérések", + "demo": "Demo" + }, "roles": { "title": "Felhasználók és szerepek", "intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.", @@ -6,16 +11,36 @@ "you": "te", "admin": "Admin", "demo": "Demo", + "suspended": "Felfüggesztve", "pending": "Jóváhagyásra vár", "unverified": "Nem igazolt e-mail", "demoLocked": "A demo fiók szerepe nem módosítható.", "selfLocked": "A saját szerepedet nem módosíthatod.", "makeAdmin": "Adminná tesz", "makeUser": "Felhasználóvá tesz", - "updated": "Szerep frissítve", + "updated": "Szerep frissítve: {{email}}", "promoteTitle": "Adminná teszed?", "demoteTitle": "Elveszed az admin jogot?", "promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?", "demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz." + }, + "suspend": { + "action": "Felfüggeszt", + "undoAction": "Feloldás", + "title": "Felfüggeszted a fiókot?", + "undoTitle": "Feloldod a felfüggesztést?", + "confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik.", + "undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.", + "done": "{{email}} felfüggesztve", + "undone": "{{email}} felfüggesztése feloldva", + "demoLocked": "A demo fiók nem függeszthető fel.", + "selfLocked": "A saját fiókodat nem függesztheted fel." + }, + "delete": { + "action": "Törlés", + "locked": "Ez a fiók itt nem törölhető.", + "title": "Törlöd a fiókot?", + "confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.", + "done": "{{email}} törölve" } }