first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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>
);
}

View 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}
/>
</>
);
}