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

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