first commit
This commit is contained in:
345
packages/admin/src/routes/bylines.tsx
Normal file
345
packages/admin/src/routes/bylines.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Button, Input, InputArea, Loader, Switch } from "@cloudflare/kumo";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.js";
|
||||
import { DialogError, getMutationError } from "../components/DialogError.js";
|
||||
import {
|
||||
createByline,
|
||||
deleteByline,
|
||||
fetchBylines,
|
||||
fetchUsers,
|
||||
updateByline,
|
||||
type BylineSummary,
|
||||
type UserListItem,
|
||||
} from "../lib/api";
|
||||
|
||||
interface BylineFormState {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio: string;
|
||||
websiteUrl: string;
|
||||
userId: string | null;
|
||||
isGuest: boolean;
|
||||
}
|
||||
|
||||
function toFormState(byline?: BylineSummary | null): BylineFormState {
|
||||
if (!byline) {
|
||||
return {
|
||||
slug: "",
|
||||
displayName: "",
|
||||
bio: "",
|
||||
websiteUrl: "",
|
||||
userId: null,
|
||||
isGuest: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
slug: byline.slug,
|
||||
displayName: byline.displayName,
|
||||
bio: byline.bio ?? "",
|
||||
websiteUrl: byline.websiteUrl ?? "",
|
||||
userId: byline.userId,
|
||||
isGuest: byline.isGuest,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserLabel(user: UserListItem): string {
|
||||
if (user.name) return `${user.name} (${user.email})`;
|
||||
return user.email;
|
||||
}
|
||||
|
||||
export function BylinesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [guestFilter, setGuestFilter] = React.useState<"all" | "guest" | "linked">("all");
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
||||
const [allItems, setAllItems] = React.useState<BylineSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["bylines", search, guestFilter],
|
||||
queryFn: () =>
|
||||
fetchBylines({
|
||||
search: search || undefined,
|
||||
isGuest: guestFilter === "all" ? undefined : guestFilter === "guest",
|
||||
limit: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
// Reset accumulated items when filters change
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
setAllItems(data.items);
|
||||
setNextCursor(data.nextCursor);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ["users", "byline-linking"],
|
||||
queryFn: () => fetchUsers({ limit: 100 }),
|
||||
});
|
||||
|
||||
const users = usersData?.items ?? [];
|
||||
|
||||
const loadMoreMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!nextCursor) return null;
|
||||
return fetchBylines({
|
||||
search: search || undefined,
|
||||
isGuest: guestFilter === "all" ? undefined : guestFilter === "guest",
|
||||
limit: 50,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result) {
|
||||
setAllItems((prev) => [...prev, ...result.items]);
|
||||
setNextCursor(result.nextCursor);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const items = allItems;
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
|
||||
const [form, setForm] = React.useState<BylineFormState>(() => toFormState(null));
|
||||
|
||||
React.useEffect(() => {
|
||||
setForm(toFormState(selected));
|
||||
}, [selectedId, selected]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createByline({
|
||||
slug: form.slug,
|
||||
displayName: form.displayName,
|
||||
bio: form.bio || null,
|
||||
websiteUrl: form.websiteUrl || null,
|
||||
userId: form.userId,
|
||||
isGuest: form.isGuest,
|
||||
}),
|
||||
onSuccess: (created) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bylines"] });
|
||||
setSelectedId(created.id);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedId) throw new Error("No byline selected");
|
||||
return updateByline(selectedId, {
|
||||
slug: form.slug,
|
||||
displayName: form.displayName,
|
||||
bio: form.bio || null,
|
||||
websiteUrl: form.websiteUrl || null,
|
||||
userId: form.userId,
|
||||
isGuest: form.isGuest,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bylines"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedId) throw new Error("No byline selected");
|
||||
return deleteByline(selectedId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bylines"] });
|
||||
setSelectedId(null);
|
||||
setShowDeleteConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[30vh]">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-kumo-danger">Failed to load bylines: {error.message}</div>;
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const mutationError = createMutation.error || updateMutation.error || deleteMutation.error;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-4 space-y-2">
|
||||
<Input
|
||||
placeholder="Search bylines"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label="Filter byline type"
|
||||
value={guestFilter}
|
||||
onChange={(e) => setGuestFilter(e.target.value as "all" | "guest" | "linked")}
|
||||
className="w-full rounded border bg-kumo-base px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All bylines</option>
|
||||
<option value="guest">Guest only</option>
|
||||
<option value="linked">Linked only</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setForm(toFormState(null));
|
||||
}}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[70vh] overflow-auto">
|
||||
{items.map((item) => {
|
||||
const active = item.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
className={`w-full rounded border p-3 text-left ${
|
||||
active ? "border-kumo-brand bg-kumo-brand/10" : "border-kumo-line"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">{item.displayName}</p>
|
||||
<p className="text-xs text-kumo-subtle">
|
||||
{item.slug}
|
||||
{item.isGuest ? " - Guest" : item.userId ? " - Linked" : ""}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && <p className="text-sm text-kumo-subtle">No bylines found</p>}
|
||||
{nextCursor && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full mt-2"
|
||||
onClick={() => loadMoreMutation.mutate()}
|
||||
disabled={loadMoreMutation.isPending}
|
||||
>
|
||||
{loadMoreMutation.isPending ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{selected ? `Edit ${selected.displayName}` : "Create byline"}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Display name"
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, displayName: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Website URL"
|
||||
value={form.websiteUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))}
|
||||
/>
|
||||
<InputArea
|
||||
label="Bio"
|
||||
value={form.bio}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, bio: e.target.value }))}
|
||||
rows={5}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Linked user</label>
|
||||
<select
|
||||
value={form.userId ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
userId: e.target.value || null,
|
||||
isGuest: e.target.value ? false : prev.isGuest,
|
||||
}))
|
||||
}
|
||||
className="mt-1 w-full rounded border bg-kumo-base px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">No linked user</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{getUserLabel(user)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Switch
|
||||
label="Guest byline"
|
||||
checked={form.isGuest}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
isGuest: checked,
|
||||
userId: checked ? null : prev.userId,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<DialogError message={getMutationError(mutationError)} />
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
updateMutation.mutate();
|
||||
} else {
|
||||
createMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={!form.displayName || !form.slug || isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : selected ? "Save" : "Create"}
|
||||
</Button>
|
||||
|
||||
{selected && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteMutation.reset();
|
||||
}}
|
||||
title="Delete Byline?"
|
||||
description="This removes the byline profile. Content byline links are removed and lead pointers are cleared."
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting..."
|
||||
isPending={deleteMutation.isPending}
|
||||
error={deleteMutation.error}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
packages/admin/src/routes/users.tsx
Normal file
307
packages/admin/src/routes/users.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Users management page
|
||||
*
|
||||
* Admin-only route for managing users, roles, and invites.
|
||||
*/
|
||||
|
||||
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog.js";
|
||||
import {
|
||||
UserList,
|
||||
UserListSkeleton,
|
||||
UserDetail,
|
||||
InviteUserModal,
|
||||
getRoleLabel,
|
||||
} from "../components/users";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
sendRecoveryLink,
|
||||
disableUser,
|
||||
enableUser,
|
||||
inviteUser,
|
||||
type UpdateUserInput,
|
||||
} from "../lib/api";
|
||||
|
||||
/**
|
||||
* Debounce hook for search input
|
||||
*/
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(setDebouncedValue, delay, value);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [roleFilter, setRoleFilter] = React.useState<number | undefined>();
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = React.useState(false);
|
||||
const [isInviteOpen, setIsInviteOpen] = React.useState(false);
|
||||
const [showDisableConfirm, setShowDisableConfirm] = React.useState(false);
|
||||
const [showDemoteConfirm, setShowDemoteConfirm] = React.useState(false);
|
||||
const [pendingSaveData, setPendingSaveData] = React.useState<UpdateUserInput | null>(null);
|
||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = React.useState<string | null>(null);
|
||||
|
||||
// Debounced search
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
// Queries
|
||||
const usersQuery = useInfiniteQuery({
|
||||
queryKey: ["users", debouncedSearch, roleFilter],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchUsers({
|
||||
search: debouncedSearch || undefined,
|
||||
role: roleFilter,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const userDetailQuery = useQuery({
|
||||
queryKey: ["users", selectedUserId],
|
||||
queryFn: () => fetchUser(selectedUserId!),
|
||||
enabled: !!selectedUserId,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) => updateUser(id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setShowDemoteConfirm(false);
|
||||
setPendingSaveData(null);
|
||||
},
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (id: string) => disableUser(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setShowDisableConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (id: string) => enableUser(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
|
||||
const recoveryMutation = useMutation({
|
||||
mutationFn: (id: string) => sendRecoveryLink(id),
|
||||
onSuccess: () => {
|
||||
// Auto-clear success status after a few seconds
|
||||
setTimeout(() => recoveryMutation.reset(), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: ({ email, role }: { email: string; role: number }) => inviteUser(email, role),
|
||||
onSuccess: (result) => {
|
||||
setInviteError(null);
|
||||
if (result.inviteUrl) {
|
||||
// No email provider — show copy-link view in the modal
|
||||
setInviteUrl(result.inviteUrl);
|
||||
} else {
|
||||
// Email sent — close modal
|
||||
setIsInviteOpen(false);
|
||||
}
|
||||
// Refresh user list (invite token was created either way)
|
||||
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setInviteError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleSelectUser = (id: string) => {
|
||||
setSelectedUserId(id);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setIsDetailOpen(false);
|
||||
// Keep selectedUserId for a moment to prevent flicker
|
||||
setTimeout(setSelectedUserId, 200, null);
|
||||
};
|
||||
|
||||
const handleSave = (data: UpdateUserInput) => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
// Check for role demotion — require confirmation.
|
||||
// Guard: only check when user data is loaded (currentRole defined).
|
||||
const currentRole = userDetailQuery.data?.role;
|
||||
if (data.role !== undefined && currentRole !== undefined && data.role < currentRole) {
|
||||
setPendingSaveData(data);
|
||||
setShowDemoteConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
updateUserMutation.mutate({ id: selectedUserId, data });
|
||||
};
|
||||
|
||||
const handleConfirmDemote = () => {
|
||||
if (selectedUserId && pendingSaveData) {
|
||||
updateUserMutation.mutate({ id: selectedUserId, data: pendingSaveData });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
setShowDisableConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDisable = () => {
|
||||
if (selectedUserId) {
|
||||
disableMutation.mutate(selectedUserId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
if (selectedUserId) {
|
||||
enableMutation.mutate(selectedUserId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendRecovery = () => {
|
||||
if (selectedUserId) {
|
||||
recoveryMutation.mutate(selectedUserId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = (email: string, role: number) => {
|
||||
setInviteError(null);
|
||||
inviteMutation.mutate({ email, role });
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (usersQuery.isLoading && !usersQuery.data) {
|
||||
return <UserListSkeleton />;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (usersQuery.error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-6 text-center">
|
||||
<p className="text-kumo-danger">Failed to load users: {usersQuery.error.message}</p>
|
||||
<button
|
||||
onClick={() => usersQuery.refetch()}
|
||||
className="mt-4 text-sm text-kumo-brand underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const users = usersQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const selectedUser = userDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserList
|
||||
users={users}
|
||||
isLoading={usersQuery.isFetching}
|
||||
hasMore={!!usersQuery.hasNextPage}
|
||||
searchQuery={searchQuery}
|
||||
roleFilter={roleFilter}
|
||||
onSearchChange={setSearchQuery}
|
||||
onRoleFilterChange={setRoleFilter}
|
||||
onSelectUser={handleSelectUser}
|
||||
onInviteUser={() => setIsInviteOpen(true)}
|
||||
onLoadMore={() => void usersQuery.fetchNextPage()}
|
||||
/>
|
||||
|
||||
<UserDetail
|
||||
user={selectedUser}
|
||||
isLoading={userDetailQuery.isLoading}
|
||||
isOpen={isDetailOpen}
|
||||
isSaving={updateUserMutation.isPending}
|
||||
isSendingRecovery={recoveryMutation.isPending}
|
||||
recoverySent={recoveryMutation.isSuccess}
|
||||
recoveryError={recoveryMutation.error?.message ?? null}
|
||||
currentUserId={undefined} // Would come from session
|
||||
onClose={handleCloseDetail}
|
||||
onSave={handleSave}
|
||||
onDisable={handleDisable}
|
||||
onEnable={handleEnable}
|
||||
onSendRecovery={handleSendRecovery}
|
||||
/>
|
||||
|
||||
<InviteUserModal
|
||||
open={isInviteOpen}
|
||||
isSending={inviteMutation.isPending}
|
||||
error={inviteError}
|
||||
inviteUrl={inviteUrl}
|
||||
onOpenChange={(open) => {
|
||||
setIsInviteOpen(open);
|
||||
if (!open) {
|
||||
setInviteError(null);
|
||||
setInviteUrl(null);
|
||||
}
|
||||
}}
|
||||
onInvite={handleInvite}
|
||||
/>
|
||||
|
||||
{/* Disable confirmation */}
|
||||
<ConfirmDialog
|
||||
open={showDisableConfirm}
|
||||
onClose={() => {
|
||||
setShowDisableConfirm(false);
|
||||
disableMutation.reset();
|
||||
}}
|
||||
title="Disable User?"
|
||||
description={
|
||||
<>
|
||||
Disabling <strong>{selectedUser?.name || selectedUser?.email}</strong> will prevent them
|
||||
from logging in until re-enabled. Their content will be preserved.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Disable User"
|
||||
pendingLabel="Disabling..."
|
||||
isPending={disableMutation.isPending}
|
||||
error={disableMutation.error}
|
||||
onConfirm={handleConfirmDisable}
|
||||
/>
|
||||
|
||||
{/* Role demotion confirmation */}
|
||||
<ConfirmDialog
|
||||
open={showDemoteConfirm}
|
||||
onClose={() => {
|
||||
setShowDemoteConfirm(false);
|
||||
setPendingSaveData(null);
|
||||
updateUserMutation.reset();
|
||||
}}
|
||||
title="Demote User?"
|
||||
description={
|
||||
<>
|
||||
Change <strong>{selectedUser?.name || selectedUser?.email}</strong> from{" "}
|
||||
<strong>{getRoleLabel(selectedUser?.role ?? 0)}</strong> to{" "}
|
||||
<strong>{getRoleLabel(pendingSaveData?.role ?? 0)}</strong>? They will lose access to
|
||||
higher-level features.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Demote User"
|
||||
pendingLabel="Demoting..."
|
||||
isPending={updateUserMutation.isPending}
|
||||
error={updateUserMutation.error}
|
||||
onConfirm={handleConfirmDemote}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user