) {
return (
-
+
{children}
);
@@ -37,6 +42,7 @@ export function EmptyStateDescription({
}: React.ComponentProps<"p">) {
return (
diff --git a/apps/dashboard/src/components/content/section.tsx b/apps/dashboard/src/components/content/section.tsx
index 3c3b068a5..0684a4ce0 100644
--- a/apps/dashboard/src/components/content/section.tsx
+++ b/apps/dashboard/src/components/content/section.tsx
@@ -6,7 +6,11 @@ export function Section({
...props
}: React.ComponentProps<"section">) {
return (
-
+
);
@@ -18,7 +22,11 @@ export function SectionHeader({
...props
}: React.ComponentProps<"div">) {
return (
-
+
{children}
);
@@ -31,6 +39,7 @@ export function SectionHeaderRow({
}: React.ComponentProps<"div">) {
return (
) {
return (
) {
return (
-
+
{children}
);
@@ -79,6 +93,7 @@ export function SectionGroup({
}: React.ComponentProps<"div">) {
return (
@@ -93,7 +108,11 @@ export function SectionGroupHeader({
...props
}: React.ComponentProps<"div">) {
return (
-
+
{children}
);
@@ -105,7 +124,11 @@ export function SectionGroupTitle({
...props
}: React.ComponentProps<"p">) {
return (
-
+
{children}
);
diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx
index 69c90e3fa..064170b8d 100644
--- a/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx
+++ b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx
@@ -29,7 +29,7 @@ export function MonitorDataTableToolbar({
placeholder="Filter by name, url, type..."
value={(table.getState().globalFilter as string) ?? ""}
onChange={(event) => table.setGlobalFilter(event.target.value)}
- className="h-8 w-[150px] lg:w-[250px]"
+ className="h-8 w-[150px] shadow-none lg:w-[250px]"
/>
{table.getColumn("tags") && (
*:first-child]:pl-0 [&_tr>*:last-child]:pr-0",
// the plot hover area bleeds past the last row; clip it instead of scrolling
"[&_[data-slot=table-container]]:overflow-y-hidden",
"[&_tbody_tr]:transition-[opacity,background-color] [&_tbody_tr]:duration-150 [&_tbody_tr]:ease-out",
@@ -70,9 +69,10 @@ export function RegionsDataTable({
)}
>
diff --git a/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx
new file mode 100644
index 000000000..18ff576e4
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { Badge } from "@openstatus/ui/components/ui/badge";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { formatDate } from "@/lib/formatter";
+
+import { TableCellEmpty } from "../../table-cell-empty";
+import { DataTableRowActions } from "./data-table-row-actions";
+
+type ApiKey = RouterOutputs["apiKey"]["list"][number];
+
+/**
+ * Map a key's `scopes` array to a single human label. v1 keys carry one
+ * value (`['read']` or `['write']`); legacy rows backfilled to
+ * `['write']` show as "Read & write." `'*'` is super-admin (synthesized
+ * by middleware, never settable through any public API). Seeing it on
+ * a row indicates someone hand-edited the DB or a bug bypassed the
+ * input enum — surface drift instead of silently mislabeling it.
+ */
+function scopeLabel(scopes: ApiKey["scopes"]): string {
+ if (scopes.includes("*")) {
+ if (typeof console !== "undefined") {
+ console.warn(
+ "[api-key table] row carries '*' scope — should never happen via the public API",
+ );
+ }
+ return "Admin";
+ }
+ if (scopes.includes("write")) return "Read & write";
+ if (scopes.includes("read")) return "Read-only";
+ return "Unknown";
+}
+
+export const columns: ColumnDef
[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ meta: { cellClassName: "font-medium" },
+ },
+ {
+ accessorKey: "description",
+ header: "Description",
+ meta: { cellClassName: "text-muted-foreground max-w-[200px] truncate" },
+ cell: ({ row }) =>
+ row.original.description ? (
+ row.original.description
+ ) : (
+ No description
+ ),
+ },
+ {
+ accessorKey: "prefix",
+ header: "Prefix",
+ cell: ({ row }) => (
+ {row.original.prefix}...
+ ),
+ },
+ {
+ accessorKey: "scopes",
+ header: "Access",
+ cell: ({ row }) => (
+ {scopeLabel(row.original.scopes)}
+ ),
+ },
+ {
+ accessorKey: "expiresAt",
+ header: "Expires",
+ meta: { cellClassName: "text-sm" },
+ cell: ({ row }) =>
+ row.original.expiresAt ? (
+ formatDate(row.original.expiresAt)
+ ) : (
+ Never
+ ),
+ },
+ {
+ id: "actions",
+ header: () => Actions,
+ cell: ({ row }) => ,
+ },
+];
diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx
new file mode 100644
index 000000000..7ed6f926d
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table-row-actions.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { Row } from "@tanstack/react-table";
+
+import { QuickActions } from "@/components/dropdowns/quick-actions";
+import { useTRPC } from "@/lib/trpc/client";
+
+type ApiKey = RouterOutputs["apiKey"]["list"][number];
+
+interface DataTableRowActionsProps {
+ row: Row;
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const revokeApiKeyMutation = useMutation(
+ trpc.apiKey.revoke.mutationOptions({
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: trpc.apiKey.list.queryKey(),
+ }),
+ }),
+ );
+
+ return (
+
+ {
+ await revokeApiKeyMutation.mutateAsync({ keyId: row.original.id });
+ },
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
index 6dbbe597d..568e07f77 100644
--- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
@@ -1,115 +1,18 @@
import type { RouterOutputs } from "@openstatus/api";
-import { Badge } from "@openstatus/ui/components/ui/badge";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@openstatus/ui/components/ui/table";
-import { useMutation } from "@tanstack/react-query";
-import { QuickActions } from "@/components/dropdowns/quick-actions";
-import { formatDate } from "@/lib/formatter";
-import { useTRPC } from "@/lib/trpc/client";
+import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table";
+import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination";
-import { TableCellEmpty } from "../../table-cell-empty";
+import { columns } from "./columns";
type ApiKey = RouterOutputs["apiKey"]["list"][number];
-/**
- * Map a key's `scopes` array to a single human label. v1 keys carry one
- * value (`['read']` or `['write']`); legacy rows backfilled to
- * `['write']` show as "Read & write." `'*'` is super-admin (synthesized
- * by middleware, never settable through any public API). Seeing it on
- * a row indicates someone hand-edited the DB or a bug bypassed the
- * input enum — surface drift instead of silently mislabeling it.
- */
-function scopeLabel(scopes: ApiKey["scopes"]): string {
- if (scopes.includes("*")) {
- if (typeof console !== "undefined") {
- console.warn(
- "[api-key table] row carries '*' scope — should never happen via the public API",
- );
- }
- return "Admin";
- }
- if (scopes.includes("write")) return "Read & write";
- if (scopes.includes("read")) return "Read-only";
- return "Unknown";
-}
-
-export function DataTable({
- apiKeys,
- refetch,
-}: {
- apiKeys: ApiKey[];
- refetch: () => void;
-}) {
- const trpc = useTRPC();
- const revokeApiKeyMutation = useMutation(
- trpc.apiKey.revoke.mutationOptions({
- onSuccess: () => refetch(),
- }),
- );
-
+export function DataTable({ apiKeys }: { apiKeys: ApiKey[] }) {
return (
-
-
-
-
- Name
- Description
- Prefix
- Access
- Expires
-
- Actions
-
-
-
-
- {apiKeys.map((apiKey) => (
-
- {apiKey.name}
-
- {apiKey.description ? (
- apiKey.description
- ) : (
- No description
- )}
-
-
- {apiKey.prefix}...
-
-
- {scopeLabel(apiKey.scopes)}
-
-
- {apiKey.expiresAt ? (
- formatDate(apiKey.expiresAt)
- ) : (
- Never
- )}
-
-
-
-
- await revokeApiKeyMutation.mutateAsync({
- keyId: apiKey.id,
- }),
- }}
- />
-
-
-
- ))}
-
-
-
+
);
}
diff --git a/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx
new file mode 100644
index 000000000..849232277
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { Badge } from "@openstatus/ui/components/ui/badge";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { formatDate } from "@/lib/formatter";
+
+import { TableCellEmpty } from "../../table-cell-empty";
+import { DataTableRowActions } from "./data-table-row-actions";
+
+type Grant = RouterOutputs["oauth"]["listGrants"][number];
+
+function scopeLabel(scope: Grant["scope"]): string {
+ return scope.includes("write") ? "Read & write" : "Read-only";
+}
+
+function userLabel(user: Grant["user"]): string {
+ if (!user) return "Removed user";
+ const name = [user.firstName, user.lastName].filter(Boolean).join(" ");
+ return name || user.email || `User ${user.id}`;
+}
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: "clientName",
+ header: "App",
+ meta: { cellClassName: "font-medium" },
+ },
+ {
+ accessorKey: "scope",
+ header: "Access",
+ cell: ({ row }) => (
+ {scopeLabel(row.original.scope)}
+ ),
+ },
+ {
+ id: "user",
+ header: "Connected by",
+ meta: { cellClassName: "text-muted-foreground max-w-[200px] truncate" },
+ cell: ({ row }) => userLabel(row.original.user),
+ },
+ {
+ accessorKey: "createdAt",
+ header: "Connected",
+ meta: { cellClassName: "text-sm" },
+ cell: ({ row }) =>
+ row.original.createdAt ? (
+ formatDate(row.original.createdAt)
+ ) : (
+ Unknown
+ ),
+ },
+ {
+ accessorKey: "lastUsedAt",
+ header: "Last used",
+ meta: { cellClassName: "text-sm" },
+ cell: ({ row }) =>
+ row.original.lastUsedAt ? (
+ formatDate(row.original.lastUsedAt)
+ ) : (
+ Never
+ ),
+ },
+ {
+ id: "actions",
+ header: () => Actions,
+ cell: ({ row }) => ,
+ },
+];
diff --git a/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx
new file mode 100644
index 000000000..b7e65f176
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/connected-apps/data-table-row-actions.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { Row } from "@tanstack/react-table";
+
+import { QuickActions } from "@/components/dropdowns/quick-actions";
+import { useTRPC } from "@/lib/trpc/client";
+
+type Grant = RouterOutputs["oauth"]["listGrants"][number];
+
+interface DataTableRowActionsProps {
+ row: Row;
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const revokeGrantMutation = useMutation(
+ trpc.oauth.revokeGrant.mutationOptions({
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: trpc.oauth.listGrants.queryKey(),
+ }),
+ }),
+ );
+
+ return (
+
+ {
+ await revokeGrantMutation.mutateAsync({ grantId: row.original.id });
+ },
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx
new file mode 100644
index 000000000..793385d93
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { formatDate } from "@/lib/formatter";
+
+import { TableCellEmpty } from "../../table-cell-empty";
+import { DataTableRowActions } from "./data-table-row-actions";
+
+type Invitation = RouterOutputs["invitation"]["list"][number];
+
+export const columns: ColumnDef[] = [
+ { accessorKey: "email", header: "Email" },
+ { accessorKey: "role", header: "Role" },
+ {
+ accessorKey: "createdAt",
+ header: "Created At",
+ cell: ({ row }) =>
+ row.original.createdAt ? (
+ formatDate(row.original.createdAt)
+ ) : (
+ Unknown
+ ),
+ },
+ {
+ accessorKey: "expiresAt",
+ header: "Expires At",
+ cell: ({ row }) => formatDate(row.original.expiresAt),
+ },
+ {
+ id: "actions",
+ header: () => Actions,
+ cell: ({ row }) => ,
+ },
+];
diff --git a/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx
new file mode 100644
index 000000000..e8f0ddd18
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table-row-actions.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { Row } from "@tanstack/react-table";
+
+import { QuickActions } from "@/components/dropdowns/quick-actions";
+import { useTRPC } from "@/lib/trpc/client";
+
+type Invitation = RouterOutputs["invitation"]["list"][number];
+
+interface DataTableRowActionsProps {
+ row: Row;
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const deleteInvitationMutation = useMutation(
+ trpc.invitation.delete.mutationOptions({
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: trpc.invitation.list.queryKey(),
+ }),
+ }),
+ );
+
+ return (
+
+ {
+ await deleteInvitationMutation.mutateAsync({ id: row.original.id });
+ },
+ }}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx
index 769b40e7e..1d25d173a 100644
--- a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx
@@ -1,34 +1,19 @@
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@openstatus/ui/components/ui/table";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import {
EmptyStateContainer,
EmptyStateDescription,
EmptyStateTitle,
} from "@/components/content/empty-state";
-import { QuickActions } from "@/components/dropdowns/quick-actions";
-import { formatDate } from "@/lib/formatter";
+import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table";
+import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination";
import { useTRPC } from "@/lib/trpc/client";
-import { TableCellEmpty } from "../../table-cell-empty";
+import { columns } from "./columns";
export function DataTable() {
const trpc = useTRPC();
- const { data: invitations, refetch } = useQuery(
- trpc.invitation.list.queryOptions(),
- );
- const deleteInvitationMutation = useMutation(
- trpc.invitation.delete.mutationOptions({
- onSuccess: () => refetch(),
- }),
- );
+ const { data: invitations } = useQuery(trpc.invitation.list.queryOptions());
if (!invitations) return null;
@@ -44,53 +29,10 @@ export function DataTable() {
}
return (
-
-
-
- Email
- Role
- Created At
- Expires At
- Accepted At
-
- Actions
-
-
-
-
- {invitations.map((item) => (
-
- {item.email}
- {item.role}
-
- {item.createdAt ? (
- formatDate(item.createdAt)
- ) : (
- Unknown
- )}
-
- {formatDate(item.expiresAt)}
-
- {item.acceptedAt ? (
- formatDate(item.acceptedAt)
- ) : (
- Pending
- )}
-
-
-
-
- deleteInvitationMutation.mutateAsync({ id: item.id }),
- }}
- />
-
-
-
- ))}
-
-
+
);
}
diff --git a/apps/dashboard/src/components/data-table/settings/members/columns.tsx b/apps/dashboard/src/components/data-table/settings/members/columns.tsx
new file mode 100644
index 000000000..158628ad4
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/members/columns.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { Badge } from "@openstatus/ui/components/ui/badge";
+import { useQuery } from "@tanstack/react-query";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { formatDate } from "@/lib/formatter";
+import { useTRPC } from "@/lib/trpc/client";
+
+import { TableCellEmpty } from "../../table-cell-empty";
+import { DataTableRowActions } from "./data-table-row-actions";
+
+type Member = RouterOutputs["member"]["list"][number];
+
+export const columns: ColumnDef[] = [
+ {
+ id: "name",
+ header: "Name",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "email",
+ header: "Email",
+ cell: ({ row }) => row.original.user.email,
+ },
+ {
+ accessorKey: "role",
+ header: "Role",
+ cell: ({ row }) => (
+
+ {row.original.role}
+
+ ),
+ },
+ {
+ id: "createdAt",
+ header: "Created",
+ cell: ({ row }) =>
+ formatDate(row.original.user.createdAt ?? row.original.createdAt),
+ },
+ {
+ id: "actions",
+ header: () => Actions,
+ cell: ({ row }) => ,
+ },
+];
+
+function MemberName({ member }: { member: Member }) {
+ const trpc = useTRPC();
+ const { data: user } = useQuery(trpc.user.get.queryOptions());
+ const isMe = Boolean(user?.id && member.user.id === user.id);
+
+ return (
+
+ {member.user.name ||
No name}
+ {isMe ?
You : null}
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx
new file mode 100644
index 000000000..b1fa7b39a
--- /dev/null
+++ b/apps/dashboard/src/components/data-table/settings/members/data-table-row-actions.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { ShieldUser } from "@openstatus/icons";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { Row } from "@tanstack/react-table";
+import { useState } from "react";
+
+import { QuickActions } from "@/components/dropdowns/quick-actions";
+import { useTRPC } from "@/lib/trpc/client";
+
+import { TransferOwnershipDialog } from "./transfer-ownership-dialog";
+
+type Member = RouterOutputs["member"]["list"][number];
+
+interface DataTableRowActionsProps {
+ row: Row;
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const { data: members } = useQuery(trpc.member.list.queryOptions());
+ const { data: currentUser } = useQuery(trpc.user.get.queryOptions());
+ const { data: workspace } = useQuery(trpc.workspace.get.queryOptions());
+ const deleteMemberMutation = useMutation(
+ trpc.member.delete.mutationOptions({
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: trpc.member.list.queryKey(),
+ }),
+ }),
+ );
+ const [transferOpen, setTransferOpen] = useState(false);
+
+ const member = row.original;
+ const { user } = member;
+ const isOwner =
+ members?.find((m) => m.user.id === currentUser?.id)?.role === "owner";
+ const isMe = Boolean(currentUser?.id && user.id === currentUser.id);
+ const canTransfer =
+ member.role !== "owner" && !user.deletedAt && !!user.email;
+
+ // The server rejects non-owners; don't offer what will fail.
+ if (!isOwner || isMe) return null;
+
+ return (
+
+ setTransferOpen(true),
+ },
+ ]
+ : undefined
+ }
+ deleteAction={{
+ confirmationValue: user.email ?? "user",
+ description: workspace?.ssoEnabled
+ ? "This workspace uses SSO. They can sign in again and rejoin automatically unless you also remove them from your identity provider."
+ : undefined,
+ submitAction: async () => {
+ await deleteMemberMutation.mutateAsync({ id: user.id });
+ },
+ }}
+ />
+ {/* Outside `QuickActions`: its dropdown item unmounts with the menu. */}
+ {canTransfer && user.email ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx
index 6fa1ebc45..9b3a58692 100644
--- a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx
@@ -1,154 +1,22 @@
-import { ShieldUser } from "@openstatus/icons";
-import { Badge } from "@openstatus/ui/components/ui/badge";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@openstatus/ui/components/ui/table";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
-import { QuickActions } from "@/components/dropdowns/quick-actions";
-import { formatDate } from "@/lib/formatter";
+import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table";
+import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination";
import { useTRPC } from "@/lib/trpc/client";
-import { TableCellEmpty } from "../../table-cell-empty";
-import { TransferOwnershipDialog } from "./transfer-ownership-dialog";
+import { columns } from "./columns";
export function DataTable() {
const trpc = useTRPC();
- const { data: members, refetch } = useQuery(trpc.member.list.queryOptions());
- const { data: user } = useQuery(trpc.user.get.queryOptions());
- const { data: workspace } = useQuery(trpc.workspace.get.queryOptions());
- const deleteMemberMutation = useMutation(
- trpc.member.delete.mutationOptions({
- onSuccess: () => refetch(),
- }),
- );
+ const { data: members } = useQuery(trpc.member.list.queryOptions());
if (!members) return null;
- const currentUserId = user?.id;
- const isOwner =
- members.find((m) => m.user.id === currentUserId)?.role === "owner";
-
- return (
-
-
-
- Name
- Email
- Role
- Created
-
- Actions
-
-
-
-
- {members.map((item) => {
- const isMe = Boolean(currentUserId && item.user.id === currentUserId);
- // The server rejects non-owners; don't offer what will fail.
- const canDelete = isOwner && !isMe;
-
- return (
-
-
-
- {item.user.name ??
No name}
- {isMe ?
You : null}
-
-
- {item.user.email}
-
-
- {item.role}
-
-
-
- {formatDate(item.user.createdAt ?? item.createdAt)}
-
-
- {canDelete ? (
-
- await deleteMemberMutation.mutateAsync({
- id: item.user.id,
- })
- }
- />
- ) : null}
-
-
- );
- })}
-
-
- );
-}
-
-function MemberRowActions({
- member,
- ssoEnabled,
- onDelete,
-}: {
- member: {
- role: string;
- user: {
- id: number;
- name: string | null;
- email: string | null;
- deletedAt: Date | null;
- };
- };
- ssoEnabled: boolean;
- onDelete: () => Promise;
-}) {
- const [transferOpen, setTransferOpen] = useState(false);
- const { user } = member;
- const canTransfer =
- member.role !== "owner" && !user.deletedAt && !!user.email;
-
return (
-
- setTransferOpen(true),
- },
- ]
- : undefined
- }
- deleteAction={{
- confirmationValue: user.email ?? "user",
- description: ssoEnabled
- ? "This workspace uses SSO. They can sign in again and rejoin automatically unless you also remove them from your identity provider."
- : undefined,
- submitAction: onDelete,
- }}
- />
- {/* Outside `QuickActions`: its dropdown item unmounts with the menu. */}
- {canTransfer && user.email ? (
-
- ) : null}
-
+
);
}
diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx
index 3d002a0ab..ee60e6817 100644
--- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx
+++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx
@@ -26,9 +26,7 @@ import { useTRPC } from "@/lib/trpc/client";
export function FormApiKey() {
const trpc = useTRPC();
- const { data: apiKeys = [], refetch } = useQuery(
- trpc.apiKey.list.queryOptions(),
- );
+ const { data: apiKeys = [] } = useQuery(trpc.apiKey.list.queryOptions());
return (
@@ -47,7 +45,7 @@ export function FormApiKey() {
) : (
-
+
)}
diff --git a/apps/dashboard/src/components/onboarding/checks-table.tsx b/apps/dashboard/src/components/onboarding/checks-table.tsx
index 14f825ab4..97267e47a 100644
--- a/apps/dashboard/src/components/onboarding/checks-table.tsx
+++ b/apps/dashboard/src/components/onboarding/checks-table.tsx
@@ -92,10 +92,7 @@ export function OnboardingChecksTable({
if (rows.length === 0 && isStreaming) {
return (
diff --git a/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx b/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx
index 8c4863d3c..b9bceb830 100644
--- a/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx
+++ b/apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx
@@ -48,7 +48,7 @@ export function DataTableFacetedFilter
({
variant="outline"
size="sm"
className={cn(
- "h-8 border-dashed",
+ "h-8 border-dashed shadow-none",
selectedValues?.size > 0 && "border-solid",
)}
>
diff --git a/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx b/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx
index fded59948..33eaafca4 100644
--- a/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx
+++ b/apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx
@@ -95,53 +95,121 @@ export function DataTablePagination({
);
}
+/** First, last and the current page's neighbours; "…" never hides a single page. */
+function getPageItems(current: number, count: number): (number | "ellipsis")[] {
+ if (count <= 5) return Array.from({ length: count }, (_, i) => i);
+ const last = count - 1;
+ const pages = new Set([0, last, current - 1, current, current + 1]);
+ if (current <= 2) [1, 2, 3].forEach((p) => pages.add(p));
+ if (current >= last - 2)
+ [last - 3, last - 2, last - 1].forEach((p) => pages.add(p));
+ const sorted = [...pages]
+ .filter((p) => p >= 0 && p <= last)
+ .sort((x, y) => x - y);
+
+ const items: (number | "ellipsis")[] = [];
+ for (const page of sorted) {
+ const prev = items.at(-1);
+ if (typeof prev === "number" && page - prev === 2) items.push(prev + 1);
+ else if (typeof prev === "number" && page - prev > 2)
+ items.push("ellipsis");
+ items.push(page);
+ }
+ return items;
+}
+
+const PAGE_SIZES = [10, 20, 50, 100];
+
export function DataTablePaginationSimple({
table,
}: DataTablePaginationProps) {
+ const { pageIndex, pageSize } = table.getState().pagination;
+ const total = table.getFilteredRowModel().rows.length;
+ const pageCount = table.getPageCount();
+ const from = pageIndex * pageSize + 1;
+ const to = Math.min(total, (pageIndex + 1) * pageSize);
+
+ if (total <= table.initialState.pagination.pageSize) return null;
+
+ const pageSizes = [...new Set([...PAGE_SIZES, pageSize])].sort(
+ (a, b) => a - b,
+ );
+
return (
-
-
- {table.getFilteredRowModel().rows.length} of{" "}
- {table.getPreFilteredRowModel().rows.length} row(s) filtered.
-
-
-
-
-
-
);
}
diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx
index 06d616275..715a365b5 100644
--- a/apps/dashboard/src/components/ui/data-table/data-table.tsx
+++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx
@@ -68,7 +68,7 @@ export function DataTable
({
defaultSorting = [],
defaultColumnVisibility = {},
defaultColumnFilters = [],
- defaultPagination = { pageIndex: 0, pageSize: 20 },
+ defaultPagination = { pageIndex: 0, pageSize: 10 },
autoResetPageIndex = true,
columnFilters,
setColumnFilters,
@@ -105,6 +105,7 @@ export function DataTable({
const table = useReactTable({
data,
columns,
+ initialState: { pagination: defaultPagination },
state: {
sorting: sortingState,
columnVisibility: columnVisibilityState,
@@ -137,83 +138,88 @@ export function DataTable({
{toolbarComponent
? React.createElement(toolbarComponent, { table })
: null}
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => {
- return (
-
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {
+ // nested links/buttons own their click
+ if ((e.target as HTMLElement).closest("a,button")) return;
+ onRowClick?.(row);
+ }}
+ className="data-[state=selected]:bg-muted/50"
>
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
)}
-
- );
- })}
-
- ))}
-
-
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => (
-
- {
- // nested links/buttons own their click
- if ((e.target as HTMLElement).closest("a,button")) return;
- onRowClick?.(row);
- }}
- className="data-[state=selected]:bg-muted/50"
- >
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
-
- ))}
-
- {row.getIsExpanded() && (
-
-
- {rowComponent
- ? React.createElement(rowComponent, { row })
- : null}
-
+
+ ))}
- )}
-
- ))
- ) : (
-
-
- No results.
-
-
- )}
-
- {actionBar ? React.createElement(actionBar, { table }) : null}
-
- {paginationComponent
- ? React.createElement(paginationComponent, { table })
- : null}
+ {row.getIsExpanded() && (
+
+
+ {rowComponent
+ ? React.createElement(rowComponent, { row })
+ : null}
+
+
+ )}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+ {actionBar ? React.createElement(actionBar, { table }) : null}
+
+ {paginationComponent
+ ? React.createElement(paginationComponent, { table })
+ : null}
+
);
}
--
2.54.0