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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user