feat(admin): user management — roles, suspend, delete + lifecycle emails

- 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).
This commit is contained in:
npeter83 2026-06-19 19:52:12 +02:00
parent 8c727dd99e
commit 3f9c395b17
10 changed files with 461 additions and 32 deletions

View file

@ -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")

View file

@ -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"

View file

@ -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")

View file

@ -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

View file

@ -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 (
<div className="p-4 max-w-3xl w-full mx-auto">
<UsersRoles me={me} />
<AdminInvites />
<AdminDemo />
<Tabs tabs={tabs} active={active} onChange={setTab} />
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
);
}
@ -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 }) {
</div>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{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 && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
@ -114,6 +173,40 @@ function UsersRoles({ me }: { me: Me }) {
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button>
</Tooltip>
<Tooltip
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
: "border-border text-muted hover:text-amber-500"
}`}
>
{u.is_suspended ? <RotateCcw className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
</button>
</Tooltip>
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
})}
@ -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() {
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate(i.id)}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>

View file

@ -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<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("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 (
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
{groupKeys.map((g) => (
<div key={g} className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">
{t(`config.groups.${g}`, g)}
</div>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
{data.groups[g].map((item) => (
{data.groups[activeGroup].map((item) => (
<Field
key={item.key}
item={item}
@ -121,7 +133,7 @@ export default function ConfigPanel() {
))}
</div>
{g === "email" && (
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}>
<button
@ -136,7 +148,7 @@ export default function ConfigPanel() {
</div>
)}
</div>
))}
)}
{(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">

View file

@ -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<string>(() => 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 (
<div className="flex flex-wrap gap-1 mb-4">
{tabs.map((tb) => {
const on = tb.id === active;
return (
<button
key={tb.id}
onClick={() => onChange(tb.id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
on
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
{tb.label}
{tb.badge != null && tb.badge > 0 && (
<span
className={`text-[11px] leading-none px-1.5 py-0.5 rounded-full ${
on ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{tb.badge}
</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}