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:
parent
2aa13a6433
commit
4571800991
10 changed files with 461 additions and 32 deletions
30
backend/alembic/versions/0023_user_suspension.py
Normal file
30
backend/alembic/versions/0023_user_suspension.py
Normal 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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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))]">
|
||||
|
|
|
|||
62
frontend/src/components/Tabs.tsx
Normal file
62
frontend/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue