v3: added a roles/permissions system, added user management tab on user profile
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

This commit is contained in:
khannurien
2026-06-28 06:21:23 +00:00
parent b567e390d9
commit 810faaf21a
22 changed files with 341 additions and 60 deletions

View File

@@ -4,6 +4,7 @@ import { Controller } from "react-hook-form";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { can } from "../utils/permissions.ts";
import type {
Comment,
CreateCommentRequest,
@@ -234,10 +235,11 @@ function CommentNode({
setEditOpen(false);
}
const canModerate = can(currentUser, "comment:moderate");
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
(currentUser.id === comment.userId || canModerate);
const canEdit = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
(currentUser.id === comment.userId || canModerate);
if (comment.deleted) {
return (

View File

@@ -30,6 +30,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem("authResponse");
return null;
}
// Sessions persisted before roles existed have no `role`; the server also
// rejects their tokens, but discard them here so the UI never renders with
// a roleless user.
if (!parsed.user.role) {
localStorage.removeItem("authResponse");
return null;
}
return parsed;
});

View File

@@ -84,10 +84,12 @@ export function hydrateDump(raw: unknown): Dump {
* Users
*/
export type Role = "user" | "moderator" | "admin";
export interface PublicUser {
id: string;
username: string;
isAdmin: boolean;
role: Role;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;

View File

@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL, VALIDATION } from "../config/api.ts";
@@ -255,7 +256,8 @@ export function Dump() {
}
const { dump } = dumpState;
const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true);
const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate"));
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();

View File

@@ -16,6 +16,7 @@ import type {
InviteTreeEntry,
PaginatedData,
PublicUser,
Role,
} from "../model.ts";
import {
deserializeAuthResponse,
@@ -51,6 +52,7 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { can } from "../utils/permissions.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
@@ -162,6 +164,7 @@ const PROFILE_TABS = [
"followed",
"invitees",
"settings",
"manage",
] as const;
type ProfileTab = (typeof PROFILE_TABS)[number];
@@ -212,10 +215,14 @@ export function UserPublicProfile() {
const isOwnProfile = me?.id === profileUserId;
// Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe).
// `settings` is only valid on your own profile; otherwise it falls back to "dumps".
const availableTabs: ProfileTab[] = isOwnProfile
? [...PROFILE_TABS]
: PROFILE_TABS.filter((t) => t !== "settings");
// `settings` is own-profile only; `manage` requires the user:manage permission
// on someone else's profile. Invalid tabs fall back to "dumps".
const canManageUsers = can(me, "user:manage");
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
if (t === "settings") return isOwnProfile;
if (t === "manage") return !isOwnProfile && canManageUsers;
return true;
});
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
@@ -695,6 +702,12 @@ export function UserPublicProfile() {
setDescEditing(false);
};
const handleRoleSaved = (role: Role) => {
setState((s) =>
s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s
);
};
if (state.status === "loading") {
return (
<PageShell>
@@ -871,6 +884,9 @@ export function UserPublicProfile() {
...(isOwnProfile
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
: []),
...(!isOwnProfile && canManageUsers
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
: []),
]}
activeTab={tab}
onChange={(key) => setTab(key)}
@@ -1223,6 +1239,21 @@ export function UserPublicProfile() {
</section>
</>
)}
{tab === "manage" && !isOwnProfile && canManageUsers && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>User management</Trans>
</h2>
<div className="profile-appearance-grid">
<RoleSwitcher
userId={profileUser.id}
currentRole={profileUser.role}
onSaved={handleRoleSaved}
/>
</div>
</section>
)}
</PageShell>
);
}
@@ -1483,6 +1514,72 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
);
}
const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
{ value: "user", label: <Trans>User</Trans> },
{ value: "moderator", label: <Trans>Moderator</Trans> },
{ value: "admin", label: <Trans>Admin</Trans> },
];
interface RoleSwitcherProps {
userId: string;
currentRole: Role;
onSaved: (role: Role) => void;
}
// Segmented role control (matches the dump-visibility toggle style), gated
// behind the user:manage permission by its caller.
function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
const { authFetch } = useAuth();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function changeRole(role: Role) {
if (role === currentRole || saving) return;
setSaving(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setError(body.error.message);
return;
}
onSaved(deserializePublicUser(body.data).role);
} catch (err) {
setError(friendlyFetchError(err));
} finally {
setSaving(false);
}
}
return (
<>
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Role</Trans>
</span>
<div className="visibility-toggle">
{ROLE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={currentRole === opt.value ? "active" : ""}
disabled={saving}
onClick={() => changeRole(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
</>
);
}
interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;

24
src/utils/permissions.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { PublicUser, Role } from "../model.ts";
// Mirror of api/lib/permissions.ts. Keep the two in sync.
export type Permission = "dump:moderate" | "comment:moderate" | "user:manage";
const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
"user:manage",
];
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
moderator: ["dump:moderate", "comment:moderate"],
user: [],
};
export function can(
user: Pick<PublicUser, "role"> | null | undefined,
permission: Permission,
): boolean {
if (!user) return false;
return ROLE_PERMISSIONS[user.role].includes(permission);
}