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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
24
src/utils/permissions.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user