From dfd4de850529d9c137f0d51d1b1b8997b8f9ed7a Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:11:19 -0500 Subject: [PATCH 1/3] Make the admin org page say who is in the org and what they work on getAdminOrgDetails returned member and project counts and nothing else, so the page could tell you an org had nine members and name none of them. - Members and Projects panels sit directly under the header, both linking through to the user and project pages. Members are ordered owner first; a banned member is marked as such. - Rows are capped at 50 while the counts stay true, and the panel says so when it is showing a subset. - The counts move into the page description, so the two stat cards that only repeated the panel titles are gone. - Billing Reconciliation collapses to a single row. It is a diagnostic tool with four thresholds and a Stripe toggle, and it was running its stuck-state query on every visit to an org page; it now loads only when opened. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- .../admin/OrgBillingReconcilePanel.tsx | 39 +++++--- .../admin/orgs/OrgMembersSection.tsx | 91 +++++++++++++++++++ .../admin/orgs/OrgProjectsSection.tsx | 82 +++++++++++++++++ .../_app/_protected/admin/orgs.$orgId.tsx | 57 +++++++----- .../src/server/functions/admin-orgs.server.ts | 49 +++++++++- 5 files changed, 283 insertions(+), 35 deletions(-) create mode 100644 packages/web/src/components/admin/orgs/OrgMembersSection.tsx create mode 100644 packages/web/src/components/admin/orgs/OrgProjectsSection.tsx diff --git a/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx b/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx index deaffc04..c6d2df86 100644 --- a/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx +++ b/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx @@ -91,7 +91,13 @@ function ThresholdField({ ); } -export function OrgBillingReconcilePanel({ orgId }: { orgId: string }) { +export function OrgBillingReconcilePanel({ + orgId, + onHide, +}: { + orgId: string; + onHide?: () => void; +}) { const [incompleteThreshold, setIncompleteThreshold] = useState(30); const [checkoutNoSubThreshold, setCheckoutNoSubThreshold] = useState(15); const [processingLagThreshold, setProcessingLagThreshold] = useState(5); @@ -114,18 +120,25 @@ export function OrgBillingReconcilePanel({ orgId }: { orgId: string }) { title='Billing Reconciliation' padded action={ - + <> + {onHide && ( + + )} + + } >
diff --git a/packages/web/src/components/admin/orgs/OrgMembersSection.tsx b/packages/web/src/components/admin/orgs/OrgMembersSection.tsx new file mode 100644 index 00000000..2a042435 --- /dev/null +++ b/packages/web/src/components/admin/orgs/OrgMembersSection.tsx @@ -0,0 +1,91 @@ +import { Link } from '@tanstack/react-router'; +import { AdminEmpty, AdminPanel, ADMIN_TH, ADMIN_TD, ADMIN_TD_MUTED } from '@/components/admin/ui'; +import { UserAvatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/table'; +import { formatDate } from '@/lib/formatDate'; +import type { AdminOrgMember } from '@/server/functions/admin-orgs.server'; + +interface OrgMembersSectionProps { + members?: AdminOrgMember[]; + total: number; + isLoading?: boolean; +} + +export function OrgMembersSection({ members, total, isLoading }: OrgMembersSectionProps) { + const rows = members ?? []; + + return ( + + Showing the {rows.length} most recently joined. + + : undefined + } + > + {isLoading ? +
+ +
+ : rows.length === 0 ? + + : + + + User + Role + Joined + + + + {rows.map(member => ( + + +
+ +
+
+ } + className='text-foreground hover:text-primary font-medium transition-colors' + > + {member.userName || member.userEmail} + + {member.userBanned && Banned} +
+

{member.userEmail}

+
+
+
+ + + {member.role ?? 'member'} + + + + {formatDate(member.joinedAt)} + +
+ ))} +
+
+ } +
+ ); +} diff --git a/packages/web/src/components/admin/orgs/OrgProjectsSection.tsx b/packages/web/src/components/admin/orgs/OrgProjectsSection.tsx new file mode 100644 index 00000000..7b2d7ac1 --- /dev/null +++ b/packages/web/src/components/admin/orgs/OrgProjectsSection.tsx @@ -0,0 +1,82 @@ +import { Link } from '@tanstack/react-router'; +import { AdminEmpty, AdminPanel, ADMIN_TH, ADMIN_TD, ADMIN_TD_MUTED } from '@/components/admin/ui'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/table'; +import { formatDate } from '@/lib/formatDate'; +import type { AdminOrgProject } from '@/server/functions/admin-orgs.server'; + +interface OrgProjectsSectionProps { + projects?: AdminOrgProject[]; + total: number; + isLoading?: boolean; +} + +export function OrgProjectsSection({ projects, total, isLoading }: OrgProjectsSectionProps) { + const rows = projects ?? []; + + return ( + + Showing the {rows.length} most recently created. + + : undefined + } + > + {isLoading ? +
+ +
+ : rows.length === 0 ? + + : + + + Project + Created by + Created + + + + {rows.map(project => ( + + + } + className='text-foreground hover:text-primary font-medium transition-colors' + > + {project.name} + + + + {project.creatorName || project.creatorEmail ? + } + className='text-muted-foreground hover:text-primary transition-colors' + > + {project.creatorName || project.creatorEmail} + + : -} + + + {formatDate(project.createdAt)} + + + ))} + +
+ } +
+ ); +} diff --git a/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx b/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx index c264a8f5..cbf24daa 100644 --- a/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx @@ -23,7 +23,7 @@ import { AlertDialogAction, } from '@/components/ui/alert-dialog'; import { handleError } from '@/lib/error-utils'; -import { AdminError, AdminPage, AdminStat, AdminStatRow } from '@/components/admin/ui'; +import { AdminError, AdminPage } from '@/components/admin/ui'; import { formatDateInput } from '@/lib/formatDate'; import { OrgBillingSummary } from '@/components/admin/OrgBillingSummary'; import { OrgQuickActions } from '@/components/admin/OrgQuickActions'; @@ -32,6 +32,9 @@ import { SubscriptionDialog } from '@/components/admin/SubscriptionDialog'; import { GrantList } from '@/components/admin/GrantList'; import { GrantDialog } from '@/components/admin/GrantDialog'; import { OrgBillingReconcilePanel } from '@/components/admin/OrgBillingReconcilePanel'; +import { OrgMembersSection } from '@/components/admin/orgs/OrgMembersSection'; +import { OrgProjectsSection } from '@/components/admin/orgs/OrgProjectsSection'; +import type { AdminOrgDetails } from '@/server/functions/admin-orgs.server'; import { queryKeys } from '@/lib/queryKeys'; const BACK_TO_ORGS = { to: '/admin/orgs', label: 'Back to Organizations' }; @@ -40,11 +43,6 @@ export const Route = createFileRoute('/_app/_protected/admin/orgs/$orgId')({ component: OrgDetailPage, }); -interface OrgDetails { - org?: { name?: string; slug?: string }; - stats?: { memberCount?: number; projectCount?: number }; -} - interface SubscriptionRecord { id: string; plan: string; @@ -88,9 +86,13 @@ function OrgDetailPage() { const orgDetailsQuery = useAdminOrgDetails(orgId); const billingQuery = useAdminOrgBilling(orgId); - const orgDetails = orgDetailsQuery.data as OrgDetails | undefined; + const orgDetails = orgDetailsQuery.data as AdminOrgDetails | undefined; const billing = billingQuery.data as BillingData | undefined; + const memberCount = orgDetails?.stats?.memberCount ?? 0; + const projectCount = orgDetails?.stats?.projectCount ?? 0; + + const [reconcileOpen, setReconcileOpen] = useState(false); const [subscriptionDialogOpen, setSubscriptionDialogOpen] = useState(false); const [grantDialogOpen, setGrantDialogOpen] = useState(false); const [confirmDialog, setConfirmDialog] = useState<{ @@ -342,21 +344,24 @@ function OrgDetailPage() { - - - - + + + @@ -388,7 +393,17 @@ function OrgDetailPage() { } /> - + {reconcileOpen ? + setReconcileOpen(false)} /> + : + } {/* Subscription Dialog */} >; +export type AdminOrgMember = AdminOrgDetails['members'][number]; +export type AdminOrgProject = AdminOrgDetails['projects'][number]; + export async function getAdminOrgBilling(session: Session, db: Database, orgId: OrgId) { assertAdmin(session); From e09fc9d51c8e539d87dfe5158be7e55dbce3c42e Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:11:22 -0500 Subject: [PATCH 2/3] Derive the admin detail types from the server instead of restating them The admin detail pages typed their data with hand-written interfaces that had drifted from what the server actually returns, and nothing could catch it: the components cast the query result to the interface, so TypeScript only ever checked the interface against itself. Three bugs were hiding behind that: - Every `*DisplayName` field the UI read - userDisplayName, creatorDisplayName, uploaderDisplayName, inviterDisplayName - was declared in the interface and produced by no server code anywhere, so each one was permanently undefined and every render silently took the fallback branch. - Project invitations computed expiry as `expiresAt * 1000`, but expiresAt is a Drizzle timestamp and arrives as a Date, so the arithmetic gave NaN and every invitation rendered as expired. - A user's org access badge tested for 'limited', which is not in the union ('full' | 'readOnly' | 'free'), so read-only orgs never got the warning style and free orgs were styled as an error. The interfaces are replaced with types inferred from the server functions, so the compiler now checks the shape the server really returns. WorkspaceStats stays hand-written - it describes the sync engine's API, not ours - but moves next to the function that returns it, which removes an inline import('@/components/...') in server code. Left alone: the server still selects creatorGivenName, userGivenName, uploaderGivenName and inviterGivenName, which no caller reads. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- .../admin/projects/ProjectFilesSection.tsx | 6 +- .../admin/projects/ProjectInfoSection.tsx | 8 +- .../projects/ProjectInvitationsSection.tsx | 14 +-- .../admin/projects/ProjectMembersSection.tsx | 14 +-- .../projects/WorkspaceStorageSection.tsx | 2 +- .../src/components/admin/projects/types.ts | 80 ----------------- .../components/admin/users/UserActions.tsx | 4 +- .../admin/users/UserLinkedAccounts.tsx | 4 +- .../admin/users/UserOrganizations.tsx | 8 +- .../admin/users/UserProfileSection.tsx | 4 +- .../components/admin/users/UserProjects.tsx | 4 +- .../components/admin/users/UserSessions.tsx | 6 +- .../_protected/admin/projects.$projectId.tsx | 14 +-- .../_app/_protected/admin/users.$userId.tsx | 10 ++- .../server/functions/admin-projects.server.ts | 88 ++++++++++++++++++- .../server/functions/admin-users.server.ts | 6 ++ 16 files changed, 147 insertions(+), 125 deletions(-) delete mode 100644 packages/web/src/components/admin/projects/types.ts diff --git a/packages/web/src/components/admin/projects/ProjectFilesSection.tsx b/packages/web/src/components/admin/projects/ProjectFilesSection.tsx index eb64e6aa..536bdc29 100644 --- a/packages/web/src/components/admin/projects/ProjectFilesSection.tsx +++ b/packages/web/src/components/admin/projects/ProjectFilesSection.tsx @@ -10,9 +10,9 @@ import { } from '@/components/ui/table'; import { formatFileSize } from '@corates/shared'; import { formatDate } from '@/lib/formatDate'; -import type { ProjectFile } from './types'; +import type { AdminProjectFile } from '@/server/functions/admin-projects.server'; -export function ProjectFilesSection({ files }: { files?: ProjectFile[] }) { +export function ProjectFilesSection({ files }: { files?: AdminProjectFile[] }) { const rows = files ?? []; return ( @@ -46,7 +46,7 @@ export function ProjectFilesSection({ files }: { files?: ProjectFile[] }) { params={{ userId: file.uploadedBy } as Record} className='text-primary hover:text-primary/80' > - {file.uploaderDisplayName || file.uploaderName} + {file.uploaderName} : -} diff --git a/packages/web/src/components/admin/projects/ProjectInfoSection.tsx b/packages/web/src/components/admin/projects/ProjectInfoSection.tsx index 593c4f69..210e2e7f 100644 --- a/packages/web/src/components/admin/projects/ProjectInfoSection.tsx +++ b/packages/web/src/components/admin/projects/ProjectInfoSection.tsx @@ -2,11 +2,11 @@ import { Link } from '@tanstack/react-router'; import { AdminPanel, AdminField, AdminFieldGrid, CopyButton } from '@/components/admin/ui'; import { formatFileSize } from '@corates/shared'; import { formatDateTime } from '@/lib/formatDate'; -import type { ProjectData } from './types'; +import type { AdminProjectDetails } from '@/server/functions/admin-projects.server'; interface ProjectInfoSectionProps { - project: ProjectData['project']; - stats: ProjectData['stats']; + project: AdminProjectDetails['project']; + stats: AdminProjectDetails['stats']; } export function ProjectInfoSection({ project, stats }: ProjectInfoSectionProps) { @@ -33,7 +33,7 @@ export function ProjectInfoSection({ project, stats }: ProjectInfoSectionProps) params={{ userId: project.createdBy } as Record} className='text-primary hover:text-primary/80' > - {project.creatorDisplayName || project.creatorName || project.creatorEmail} + {project.creatorName || project.creatorEmail} {formatDateTime(project.createdAt)} diff --git a/packages/web/src/components/admin/projects/ProjectInvitationsSection.tsx b/packages/web/src/components/admin/projects/ProjectInvitationsSection.tsx index b8b9a778..55ac5aad 100644 --- a/packages/web/src/components/admin/projects/ProjectInvitationsSection.tsx +++ b/packages/web/src/components/admin/projects/ProjectInvitationsSection.tsx @@ -10,12 +10,12 @@ import { TableCell, } from '@/components/ui/table'; import { formatDate } from '@/lib/formatDate'; -import type { ProjectInvitation } from './types'; +import type { AdminProjectInvitation } from '@/server/functions/admin-projects.server'; -function invitationStatus(invitation: ProjectInvitation): 'accepted' | 'pending' | 'expired' { +function invitationStatus(invitation: AdminProjectInvitation): 'accepted' | 'pending' | 'expired' { if (invitation.acceptedAt) return 'accepted'; if (!invitation.expiresAt) return 'pending'; - return new Date(invitation.expiresAt * 1000) > new Date() ? 'pending' : 'expired'; + return invitation.expiresAt > new Date() ? 'pending' : 'expired'; } const STATUS_BADGE = { @@ -24,7 +24,11 @@ const STATUS_BADGE = { expired: { variant: 'destructive', label: 'Expired' }, } as const; -export function ProjectInvitationsSection({ invitations }: { invitations?: ProjectInvitation[] }) { +export function ProjectInvitationsSection({ + invitations, +}: { + invitations?: AdminProjectInvitation[]; +}) { const rows = invitations ?? []; return ( @@ -62,7 +66,7 @@ export function ProjectInvitationsSection({ invitations }: { invitations?: Proje params={{ userId: invitation.invitedBy } as Record} className='text-primary hover:text-primary/80' > - {invitation.inviterDisplayName || invitation.inviterName} + {invitation.inviterName} diff --git a/packages/web/src/components/admin/projects/ProjectMembersSection.tsx b/packages/web/src/components/admin/projects/ProjectMembersSection.tsx index 6cd8cf39..7320cec8 100644 --- a/packages/web/src/components/admin/projects/ProjectMembersSection.tsx +++ b/packages/web/src/components/admin/projects/ProjectMembersSection.tsx @@ -13,12 +13,12 @@ import { TableCell, } from '@/components/ui/table'; import { formatDate } from '@/lib/formatDate'; -import type { ProjectMember } from './types'; +import type { AdminProjectMember } from '@/server/functions/admin-projects.server'; interface ProjectMembersSectionProps { - members?: ProjectMember[]; + members?: AdminProjectMember[]; loading: boolean; - onRemove: (member: ProjectMember) => void; + onRemove: (member: AdminProjectMember) => void; } export function ProjectMembersSection({ members, loading, onRemove }: ProjectMembersSectionProps) { @@ -43,8 +43,8 @@ export function ProjectMembersSection({ members, loading, onRemove }: ProjectMem
@@ -53,7 +53,7 @@ export function ProjectMembersSection({ members, loading, onRemove }: ProjectMem params={{ userId: member.userId } as Record} className='text-foreground hover:text-primary font-medium transition-colors' > - {member.userDisplayName || member.userName} + {member.userName}

{member.userEmail}

@@ -74,7 +74,7 @@ export function ProjectMembersSection({ members, loading, onRemove }: ProjectMem className='text-muted-foreground/70 hover:text-destructive' onClick={() => onRemove(member)} disabled={loading} - aria-label={`Remove ${member.userDisplayName || member.userName}`} + aria-label={`Remove ${member.userName}`} > diff --git a/packages/web/src/components/admin/projects/WorkspaceStorageSection.tsx b/packages/web/src/components/admin/projects/WorkspaceStorageSection.tsx index b901b292..eda97255 100644 --- a/packages/web/src/components/admin/projects/WorkspaceStorageSection.tsx +++ b/packages/web/src/components/admin/projects/WorkspaceStorageSection.tsx @@ -2,7 +2,7 @@ import { RefreshCwIcon } from 'lucide-react'; import { AdminPanel, AdminField, AdminFieldGrid, AdminStat } from '@/components/admin/ui'; import { Button } from '@/components/ui/button'; import { formatFileSize } from '@corates/shared'; -import type { WorkspaceStats } from './types'; +import type { WorkspaceStats } from '@/server/functions/admin-projects.server'; interface WorkspaceStorageSectionProps { stats?: WorkspaceStats; diff --git a/packages/web/src/components/admin/projects/types.ts b/packages/web/src/components/admin/projects/types.ts deleted file mode 100644 index 995ca9f3..00000000 --- a/packages/web/src/components/admin/projects/types.ts +++ /dev/null @@ -1,80 +0,0 @@ -export interface ProjectMember { - id: string; - userId: string; - role: string; - userAvatar?: string; - userDisplayName?: string; - userName?: string; - userEmail?: string; - joinedAt?: string | number | Date; -} - -export interface ProjectFile { - id: string; - originalName?: string; - filename?: string; - fileType?: string; - fileSize?: number; - uploadedBy?: string; - uploaderDisplayName?: string; - uploaderName?: string; - createdAt?: string | number | Date; -} - -export interface ProjectInvitation { - id: string; - email: string; - role: string; - grantOrgMembership?: boolean; - acceptedAt?: string | number | Date | null; - expiresAt?: number; - invitedBy: string; - inviterDisplayName?: string; - inviterName?: string; - createdAt?: string | number | Date; -} - -export interface ProjectData { - project: { - id: string; - name: string; - orgId: string; - orgName: string; - orgSlug: string; - createdBy: string; - creatorDisplayName?: string; - creatorName?: string; - creatorEmail?: string; - createdAt?: string | number | Date; - updatedAt?: string | number | Date; - }; - stats: { - memberCount: number; - fileCount: number; - totalStorageBytes: number; - }; - members?: ProjectMember[]; - files?: ProjectFile[]; - invitations?: ProjectInvitation[]; -} - -/** The sync-engine workspace's admin stats (`workspaceAdmin(...).stats()`). */ -export interface WorkspaceStats { - workspaceId: string; - backendId: string; - schemaVersion: number; - currentVersion: number; - rows: { live: number; tombstones: number }; - mutationLogEntries: number; - knownClients: number; - databaseSizeBytes: number; - connections: { total: number; ready: number; presence: number }; - /** The Yjs fields add-on's stats, when mounted. */ - extension?: { - fields: number; - frozenFields: number; - fieldBytes: number; - pendingUpdates: number; - cachedDocs: number; - }; -} diff --git a/packages/web/src/components/admin/users/UserActions.tsx b/packages/web/src/components/admin/users/UserActions.tsx index a4293379..97a116b2 100644 --- a/packages/web/src/components/admin/users/UserActions.tsx +++ b/packages/web/src/components/admin/users/UserActions.tsx @@ -1,9 +1,9 @@ import { LogInIcon, UserCheckIcon, UserXIcon, Trash2Icon } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import type { UserData } from './types'; +import type { AdminUserDetails } from '@/server/functions/admin-users.server'; interface UserActionsProps { - user: UserData['user']; + user: AdminUserDetails['user']; loading: boolean; onImpersonate: () => void; onUnban: () => void; diff --git a/packages/web/src/components/admin/users/UserLinkedAccounts.tsx b/packages/web/src/components/admin/users/UserLinkedAccounts.tsx index f459d637..91260158 100644 --- a/packages/web/src/components/admin/users/UserLinkedAccounts.tsx +++ b/packages/web/src/components/admin/users/UserLinkedAccounts.tsx @@ -1,7 +1,7 @@ import { MailIcon } from 'lucide-react'; import { AdminEmpty, AdminPanel } from '@/components/admin/ui'; import { formatDate } from '@/lib/formatDate'; -import type { UserAccount } from './types'; +import type { AdminUserAccount } from '@/server/functions/admin-users.server'; const PROVIDER_LABEL: Record = { google: 'Google', @@ -14,7 +14,7 @@ const PROVIDER_LOGO: Record = { orcid: '/logos/orcid.svg', }; -export function UserLinkedAccounts({ accounts }: { accounts?: UserAccount[] }) { +export function UserLinkedAccounts({ accounts }: { accounts?: AdminUserAccount[] }) { const rows = accounts ?? []; return ( diff --git a/packages/web/src/components/admin/users/UserOrganizations.tsx b/packages/web/src/components/admin/users/UserOrganizations.tsx index 8e15c685..6f07c091 100644 --- a/packages/web/src/components/admin/users/UserOrganizations.tsx +++ b/packages/web/src/components/admin/users/UserOrganizations.tsx @@ -10,9 +10,9 @@ import { TableCell, } from '@/components/ui/table'; import { formatDate } from '@/lib/formatDate'; -import type { UserOrg } from './types'; +import type { AdminUserOrg } from '@/server/functions/admin-users.server'; -export function UserOrganizations({ orgs }: { orgs?: UserOrg[] }) { +export function UserOrganizations({ orgs }: { orgs?: AdminUserOrg[] }) { const rows = orgs ?? []; return ( @@ -50,9 +50,9 @@ export function UserOrganizations({ orgs }: { orgs?: UserOrg[] }) { {org.billing.accessMode} diff --git a/packages/web/src/components/admin/users/UserProfileSection.tsx b/packages/web/src/components/admin/users/UserProfileSection.tsx index 4c83ac7b..833651d0 100644 --- a/packages/web/src/components/admin/users/UserProfileSection.tsx +++ b/packages/web/src/components/admin/users/UserProfileSection.tsx @@ -1,9 +1,9 @@ import { ExternalLinkIcon } from 'lucide-react'; import { AdminPanel, AdminField, AdminFieldGrid, CopyButton } from '@/components/admin/ui'; import { formatDateTime } from '@/lib/formatDate'; -import type { UserData } from './types'; +import type { AdminUserDetails } from '@/server/functions/admin-users.server'; -export function UserProfileSection({ user }: { user: UserData['user'] }) { +export function UserProfileSection({ user }: { user: AdminUserDetails['user'] }) { return ( diff --git a/packages/web/src/components/admin/users/UserProjects.tsx b/packages/web/src/components/admin/users/UserProjects.tsx index e0a21a4a..1d436378 100644 --- a/packages/web/src/components/admin/users/UserProjects.tsx +++ b/packages/web/src/components/admin/users/UserProjects.tsx @@ -10,9 +10,9 @@ import { TableCell, } from '@/components/ui/table'; import { formatDate } from '@/lib/formatDate'; -import type { UserProject } from './types'; +import type { AdminUserProject } from '@/server/functions/admin-users.server'; -export function UserProjects({ projects }: { projects?: UserProject[] }) { +export function UserProjects({ projects }: { projects?: AdminUserProject[] }) { const rows = projects ?? []; return ( diff --git a/packages/web/src/components/admin/users/UserSessions.tsx b/packages/web/src/components/admin/users/UserSessions.tsx index 721d234e..1d9fabad 100644 --- a/packages/web/src/components/admin/users/UserSessions.tsx +++ b/packages/web/src/components/admin/users/UserSessions.tsx @@ -2,7 +2,7 @@ import { MonitorIcon, LogOutIcon } from 'lucide-react'; import { AdminEmpty, AdminPanel } from '@/components/admin/ui'; import { Button } from '@/components/ui/button'; import { formatDateTime } from '@/lib/formatDate'; -import type { UserSession } from './types'; +import type { AdminUserSession } from '@/server/functions/admin-users.server'; const parseUserAgent = (ua: string | undefined): { browser: string; os: string } => { if (!ua) return { browser: 'Unknown', os: 'Unknown' }; @@ -24,7 +24,7 @@ const parseUserAgent = (ua: string | undefined): { browser: string; os: string } }; interface UserSessionsProps { - sessions?: UserSession[]; + sessions?: AdminUserSession[]; loading: boolean; onRevoke: (sessionId: string) => void; onRevokeAll: () => void; @@ -55,7 +55,7 @@ export function UserSessions({ sessions, loading, onRevoke, onRevokeAll }: UserS {rows.length === 0 ? : rows.map(session => { - const { browser, os } = parseUserAgent(session.userAgent); + const { browser, os } = parseUserAgent(session.userAgent ?? undefined); return (
diff --git a/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx b/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx index 108cdae6..72225bf3 100644 --- a/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx @@ -10,7 +10,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { handleError } from '@/lib/error-utils'; import { queryKeys } from '@/lib/queryKeys'; import { AdminError, AdminPage, AdminPanel } from '@/components/admin/ui'; -import type { ProjectData, WorkspaceStats, ProjectMember } from '@/components/admin/projects/types'; +import type { + AdminProjectDetails, + AdminProjectMember, + WorkspaceStats, +} from '@/server/functions/admin-projects.server'; import { ProjectInfoSection } from '@/components/admin/projects/ProjectInfoSection'; import { WorkspaceStorageSection } from '@/components/admin/projects/WorkspaceStorageSection'; import { ProjectMembersSection } from '@/components/admin/projects/ProjectMembersSection'; @@ -33,7 +37,7 @@ function ProjectDetailPage() { const queryClient = useQueryClient(); const projectQuery = useAdminProjectDetails(projectId); - const projectData = projectQuery.data as ProjectData | undefined; + const projectData = projectQuery.data as AdminProjectDetails | undefined; // DO storage stats are fetched separately because they route through the // ProjectDoc DO and are slower than the D1 details query. Loading them as @@ -43,7 +47,7 @@ function ProjectDetailPage() { const [confirmDialog, setConfirmDialog] = useState<{ type: 'delete-project' | 'remove-member'; - member?: ProjectMember; + member?: AdminProjectMember; } | null>(null); const [loading, setLoading] = useState(false); @@ -156,9 +160,7 @@ function ProjectDetailPage() { open={confirmDialog?.type === 'remove-member'} onOpenChange={open => !open && setConfirmDialog(null)} memberName={ - confirmDialog?.member?.userDisplayName || - confirmDialog?.member?.userName || - confirmDialog?.member?.userEmail + confirmDialog?.member?.userName || confirmDialog?.member?.userEmail || undefined } onConfirm={() => { if (confirmDialog?.member?.id) { diff --git a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx index bea3002f..3165de2a 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx @@ -19,7 +19,7 @@ import { handleError } from '@/lib/error-utils'; import { queryKeys } from '@/lib/queryKeys'; import { AdminError, AdminPage, AdminPanel } from '@/components/admin/ui'; import { Skeleton } from '@/components/ui/skeleton'; -import type { UserData } from '@/components/admin/users/types'; +import type { AdminUserDetails } from '@/server/functions/admin-users.server'; import { UserActions } from '@/components/admin/users/UserActions'; import { UserProfileSection } from '@/components/admin/users/UserProfileSection'; import { UserLinkedAccounts } from '@/components/admin/users/UserLinkedAccounts'; @@ -76,7 +76,7 @@ function UserDetailContent() { const qc = useQueryClient(); const { data } = useSuspenseQuery(adminUserDetailsQueryOptions(userId)); - const userData = data as unknown as UserData; + const userData = data as unknown as AdminUserDetails; const user = userData.user; const [confirmDialog, setConfirmDialog] = useState<{ @@ -172,7 +172,11 @@ function UserDetailContent() { back={BACK_TO_USERS} title={ - + {user.name} } diff --git a/packages/web/src/server/functions/admin-projects.server.ts b/packages/web/src/server/functions/admin-projects.server.ts index bfdd300a..57ce3f01 100644 --- a/packages/web/src/server/functions/admin-projects.server.ts +++ b/packages/web/src/server/functions/admin-projects.server.ts @@ -220,6 +220,92 @@ export async function getAdminProjectDetails(session: Session, db: Database, pro }; } +export interface ProjectMember { + id: string; + userId: string; + role: string; + userAvatar?: string; + userDisplayName?: string; + userName?: string; + userEmail?: string; + joinedAt?: string | number | Date; +} + +export interface ProjectFile { + id: string; + originalName?: string; + filename?: string; + fileType?: string; + fileSize?: number; + uploadedBy?: string; + uploaderDisplayName?: string; + uploaderName?: string; + createdAt?: string | number | Date; +} + +export interface ProjectInvitation { + id: string; + email: string; + role: string; + grantOrgMembership?: boolean; + acceptedAt?: string | number | Date | null; + expiresAt?: number; + invitedBy: string; + inviterDisplayName?: string; + inviterName?: string; + createdAt?: string | number | Date; +} + +export interface ProjectData { + project: { + id: string; + name: string; + orgId: string; + orgName: string; + orgSlug: string; + createdBy: string; + creatorDisplayName?: string; + creatorName?: string; + creatorEmail?: string; + createdAt?: string | number | Date; + updatedAt?: string | number | Date; + }; + stats: { + memberCount: number; + fileCount: number; + totalStorageBytes: number; + }; + members?: ProjectMember[]; + files?: ProjectFile[]; + invitations?: ProjectInvitation[]; +} + +/** The sync-engine workspace's admin stats (`workspaceAdmin(...).stats()`). */ +export interface WorkspaceStats { + workspaceId: string; + backendId: string; + schemaVersion: number; + currentVersion: number; + rows: { live: number; tombstones: number }; + mutationLogEntries: number; + knownClients: number; + databaseSizeBytes: number; + connections: { total: number; ready: number; presence: number }; + /** The Yjs fields add-on's stats, when mounted. */ + extension?: { + fields: number; + frozenFields: number; + fieldBytes: number; + pendingUpdates: number; + cachedDocs: number; + }; +} + +export type AdminProjectDetails = Awaited>; +export type AdminProjectMember = AdminProjectDetails['members'][number]; +export type AdminProjectFile = AdminProjectDetails['files'][number]; +export type AdminProjectInvitation = AdminProjectDetails['invitations'][number]; + export async function getAdminWorkspaceStats(session: Session, db: Database, projectId: string) { assertAdmin(session); @@ -234,7 +320,7 @@ export async function getAdminWorkspaceStats(session: Session, db: Database, pro } const stats = await projectWorkspace(env, projectId).stats(); - return stats as import('@/components/admin/projects/types').WorkspaceStats; + return stats as WorkspaceStats; } export async function removeAdminProjectMember( diff --git a/packages/web/src/server/functions/admin-users.server.ts b/packages/web/src/server/functions/admin-users.server.ts index cb4ffa21..19ba0c4c 100644 --- a/packages/web/src/server/functions/admin-users.server.ts +++ b/packages/web/src/server/functions/admin-users.server.ts @@ -238,6 +238,12 @@ export async function getAdminUserDetails(session: Session, db: Database, userId }; } +export type AdminUserDetails = Awaited>; +export type AdminUserProject = AdminUserDetails['projects'][number]; +export type AdminUserSession = AdminUserDetails['sessions'][number]; +export type AdminUserAccount = AdminUserDetails['accounts'][number]; +export type AdminUserOrg = AdminUserDetails['orgs'][number]; + export async function deleteAdminUser(session: Session, db: Database, userId: string) { assertAdmin(session); From d8b5a8939e96c02ed2101555608ae7e42b681b57 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:15:01 -0500 Subject: [PATCH 3/3] Let the admin pages use the types the server already gives them Every admin page cast its query result to a hand-written interface. The hooks were already typed end to end - useAdminOrgDetails().data comes back as the server function's exact return type - so the cast added nothing and replaced a correct type with a drifting one. Dropping the casts lets inference reach the components, and the compiler immediately found more of the same drift: - OrgBillingSummary read billing.subscription.plan for its summary line, but the resolver's subscription carries no plan field; the line always rendered "Active subscription (undefined)". It now uses the effective plan's name. - Its accessMode type omitted 'free', so the union was wrong in the same way the org badge was. - The projects directory sorted its "Created by" column on creatorDisplayName, a field that does not exist, so the column never sorted. The row types the tables need are exported from the server functions and derived, not restated: AdminUserListItem, AdminOrgListItem, AdminProjectListItem, AdminOrgSubscription, AdminOrgGrant, AdminLedgerEntry, AdminTableColumn. One cast stays, in the database viewer: the table is chosen at runtime, so its rows really are Record, and the cast sits at that one line rather than over the whole query result. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- .../web/src/components/admin/GrantList.tsx | 12 +---- .../admin/OrgBillingReconcilePanel.tsx | 47 +++---------------- .../components/admin/OrgBillingSummary.tsx | 20 ++------ .../src/components/admin/SubscriptionList.tsx | 25 +++------- .../web/src/components/admin/UserTable.tsx | 23 ++------- .../_app/_protected/admin/billing.ledger.tsx | 31 +++--------- .../routes/_app/_protected/admin/database.tsx | 39 ++++----------- .../routes/_app/_protected/admin/index.tsx | 2 +- .../_app/_protected/admin/orgs.$orgId.tsx | 47 ++----------------- .../_app/_protected/admin/orgs.index.tsx | 23 ++------- .../_protected/admin/projects.$projectId.tsx | 10 ++-- .../_app/_protected/admin/projects.index.tsx | 38 +++------------ .../routes/_app/_protected/admin/storage.tsx | 18 +------ .../_app/_protected/admin/users.$userId.tsx | 3 +- .../_app/_protected/admin/users.index.tsx | 7 +-- .../server/functions/admin-billing.server.ts | 2 + .../server/functions/admin-database.server.ts | 2 + .../src/server/functions/admin-orgs.server.ts | 5 ++ .../server/functions/admin-projects.server.ts | 3 ++ .../server/functions/admin-users.server.ts | 1 + 20 files changed, 74 insertions(+), 284 deletions(-) diff --git a/packages/web/src/components/admin/GrantList.tsx b/packages/web/src/components/admin/GrantList.tsx index ea17abae..27278494 100644 --- a/packages/web/src/components/admin/GrantList.tsx +++ b/packages/web/src/components/admin/GrantList.tsx @@ -4,18 +4,10 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { formatDateTime } from '@/lib/formatDate'; - -interface Grant { - id: string; - type: string; - startsAt?: string | number | Date; - expiresAt?: string | number | Date; - createdAt?: string | number | Date; - revokedAt?: string | number | Date | null; -} +import type { AdminOrgGrant } from '@/server/functions/admin-orgs.server'; interface GrantListProps { - grants: Grant[]; + grants: AdminOrgGrant[]; loading: boolean; isLoading: boolean; onRevoke: (_grantId: string) => void; diff --git a/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx b/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx index c6d2df86..240cd87c 100644 --- a/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx +++ b/packages/web/src/components/admin/OrgBillingReconcilePanel.tsx @@ -10,40 +10,7 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; -interface StuckState { - type: string; - severity: string; - description: string; - ageMinutes?: number; - threshold?: number; - subscriptionId?: string; - stripeSubscriptionId?: string; - stripeEventId?: string; - localStatus?: string; - stripeStatus?: string; -} - -interface ReconcileSummary { - stuckStateCount?: number; - failedWebhooks?: number; - ignoredWebhooks?: number; -} - -interface StripeComparison { - error?: string; - noActiveSubscription?: boolean; - match?: boolean; - localStatus?: string; - stripeStatus?: string; -} - -interface ReconcileData { - stuckStates?: StuckState[]; - summary?: ReconcileSummary; - stripeComparison?: StripeComparison; -} - -const getSeverityIcon = (severity: string) => { +const getSeverityIcon = (severity: string | undefined) => { switch (severity) { case 'critical': return AlertTriangleIcon; @@ -55,7 +22,7 @@ const getSeverityIcon = (severity: string) => { } }; -const getSeverityVariant = (severity: string) => { +const getSeverityVariant = (severity: string | undefined) => { switch (severity) { case 'critical': return 'destructive' as const; @@ -110,9 +77,9 @@ export function OrgBillingReconcilePanel({ processingLagThreshold, }); - const reconcileData = reconcileQuery.data as ReconcileData | undefined; + const reconcileData = reconcileQuery.data; const stuckStates = reconcileData?.stuckStates ?? []; - const summary = reconcileData?.summary ?? {}; + const summary = reconcileData?.summary; const isLoading = reconcileQuery.isLoading; return ( @@ -176,7 +143,7 @@ export function OrgBillingReconcilePanel({
- + s.severity === 'critical').length} @@ -191,12 +158,12 @@ export function OrgBillingReconcilePanel({ />
diff --git a/packages/web/src/components/admin/OrgBillingSummary.tsx b/packages/web/src/components/admin/OrgBillingSummary.tsx index e4c7ef15..12a60080 100644 --- a/packages/web/src/components/admin/OrgBillingSummary.tsx +++ b/packages/web/src/components/admin/OrgBillingSummary.tsx @@ -1,24 +1,10 @@ import { AdminPanel, AdminField, AdminFieldGrid } from '@/components/admin/ui'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; - -interface BillingPlan { - name?: string; - entitlements?: Record; - quotas?: Record; -} - -interface BillingState { - plan?: BillingPlan; - effectivePlanId?: string; - accessMode?: 'full' | 'readOnly'; - source?: 'free' | 'subscription' | 'grant'; - subscription?: { plan?: string } | null; - grant?: { type?: string } | null; -} +import type { AdminOrgBillingState } from '@/server/functions/admin-orgs.server'; interface OrgBillingSummaryProps { - billing: BillingState | null | undefined; + billing: AdminOrgBillingState | null | undefined; isLoading?: boolean; } @@ -40,7 +26,7 @@ export function OrgBillingSummary({ billing, isLoading }: OrgBillingSummaryProps const sourceReason = billingSource === 'subscription' && billing.subscription ? - `Active subscription (${billing.subscription.plan})` + `Active subscription (${billing.plan?.name})` : billingSource === 'grant' && billing.grant ? `Active grant (${billing.grant.type})` : 'No active subscription or grant'; diff --git a/packages/web/src/components/admin/SubscriptionList.tsx b/packages/web/src/components/admin/SubscriptionList.tsx index 8711aeb3..03de2bb0 100644 --- a/packages/web/src/components/admin/SubscriptionList.tsx +++ b/packages/web/src/components/admin/SubscriptionList.tsx @@ -4,29 +4,15 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { formatDateTime } from '@/lib/formatDate'; - -interface Subscription { - id: string; - plan: string; - status: string; - periodStart?: string | number | Date; - periodEnd?: string | number | Date; - cancelAtPeriodEnd?: boolean; - createdAt?: string | number | Date; - updatedAt?: string | number | Date | null; - canceledAt?: string | number | Date | null; - endedAt?: string | number | Date | null; - stripeCustomerId?: string; - stripeSubscriptionId?: string; -} +import type { AdminOrgSubscription } from '@/server/functions/admin-orgs.server'; interface SubscriptionListProps { - subscriptions?: Subscription[]; + subscriptions?: AdminOrgSubscription[]; effectiveSubscriptionId?: string; loading?: boolean; isLoading?: boolean; onCancel: (_subscriptionId: string) => void; - onEdit: (_subscription: Subscription) => void; + onEdit: (_subscription: AdminOrgSubscription) => void; } function StripeId({ label, value }: { label: string; value: string }) { @@ -92,7 +78,10 @@ export function SubscriptionList({ )} {subscription.stripeSubscriptionId && ( - + )}
)} diff --git a/packages/web/src/components/admin/UserTable.tsx b/packages/web/src/components/admin/UserTable.tsx index 47bbd586..294c8740 100644 --- a/packages/web/src/components/admin/UserTable.tsx +++ b/packages/web/src/components/admin/UserTable.tsx @@ -6,20 +6,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import { AdminDataTable, type AdminColumnDef } from '@/components/admin/ui'; import { Badge } from '@/components/ui/badge'; import { formatDate } from '@/lib/formatDate'; - -interface UserRow { - id: string; - name?: string; - username?: string; - email?: string; - emailVerified?: boolean; - avatarUrl?: string; - image?: string; - providers?: string[]; - banned?: boolean; - stripeCustomerId?: string; - createdAt?: string | number; -} +import type { AdminUserListItem } from '@/server/functions/admin-users.server'; interface ProviderInfo { name: string; @@ -33,7 +20,7 @@ const PROVIDER_INFO: Record = { }; interface UserTableProps { - users: UserRow[]; + users: AdminUserListItem[]; loading?: boolean; refreshing?: boolean; skeletonRows?: number; @@ -51,7 +38,7 @@ export function UserTable({ }: UserTableProps) { const navigate = useNavigate(); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: 'name', @@ -61,7 +48,7 @@ export function UserTable({ return (
@@ -182,7 +169,7 @@ export function UserTable({ variant={variant} emptyState={emptyState ?? 'No users found'} enableSorting - onRowClick={(row: UserRow) => + onRowClick={(row: AdminUserListItem) => navigate({ to: '/admin/users/$userId' as string, params: { userId: row.id } as Record, diff --git a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx index 10297b56..91069609 100644 --- a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx +++ b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx @@ -24,26 +24,7 @@ import { SelectValue, } from '@/components/ui/select'; import { formatDateTime } from '@/lib/formatDate'; - -interface LedgerEntry { - receivedAt?: string | number; - processedAt?: string | number; - status: string; - type?: string; - stripeEventId?: string; - orgId?: string; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - stripeCheckoutSessionId?: string; - requestId?: string; - error?: string; - httpStatus?: number; -} - -interface LedgerStats { - total?: number; - byStatus?: Record; -} +import type { AdminLedgerEntry } from '@/server/functions/admin-billing.server'; const STATUS_OPTIONS = [ { value: '', label: 'All Statuses' }, @@ -110,11 +91,11 @@ function AdminBillingLedgerPage() { type: debouncedTypeFilter || undefined, }); - const data = ledgerQuery.data as { entries: LedgerEntry[]; stats: LedgerStats } | undefined; + const data = ledgerQuery.data; const entries = data?.entries || []; - const stats = data?.stats || {}; + const stats = data?.stats; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: 'receivedAt', @@ -318,12 +299,12 @@ function AdminBillingLedgerPage() { } > - + {STATUS_OPTIONS.slice(1, 5).map(option => ( ))} diff --git a/packages/web/src/routes/_app/_protected/admin/database.tsx b/packages/web/src/routes/_app/_protected/admin/database.tsx index 1168a595..989e341c 100644 --- a/packages/web/src/routes/_app/_protected/admin/database.tsx +++ b/packages/web/src/routes/_app/_protected/admin/database.tsx @@ -15,6 +15,7 @@ import { useAdminTableRows, useAdminTableSchema, } from '@/hooks/useAdminQueries'; +import type { AdminTableColumn } from '@/server/functions/admin-database.server'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -43,28 +44,6 @@ export const Route = createFileRoute('/_app/_protected/admin/database')({ const LIMIT_OPTIONS = [25, 50, 100]; -interface TableInfo { - name: string; - rowCount?: number; -} - -interface ColumnSchema { - name: string; - type?: string; - primaryKey?: boolean; - foreignKey?: { table: string; column: string } | null; -} - -interface TableRowsData { - rows: Array>; - pagination: { - page: number; - limit: number; - totalRows: number; - totalPages: number; - }; -} - const formatCellValue = (value: unknown): string => { if (value === null || value === undefined) return '-'; if (typeof value === 'boolean') return value ? 'true' : 'false'; @@ -83,14 +62,10 @@ function DatabaseViewerPage() { const [filterValue, setFilterValue] = useState(null); const tablesQuery = useAdminDatabaseTables(); - const tables = ((tablesQuery.data as { tables: TableInfo[] } | undefined)?.tables ?? - []) as TableInfo[]; + const tables = tablesQuery.data?.tables ?? []; const schemaQuery = useAdminTableSchema(selectedTable); - const schemaColumns = useMemo( - () => (schemaQuery.data as { columns: ColumnSchema[] } | undefined)?.columns ?? [], - [schemaQuery.data], - ); + const schemaColumns = useMemo(() => schemaQuery.data?.columns ?? [], [schemaQuery.data]); const rowsQuery = useAdminTableRows({ tableName: selectedTable ?? undefined, @@ -102,8 +77,10 @@ function DatabaseViewerPage() { filterValue, }); - const rowsData = rowsQuery.data as TableRowsData | undefined; - const rows = useMemo(() => rowsData?.rows ?? [], [rowsData]); + const rowsData = rowsQuery.data; + // The viewer renders whatever table was picked at runtime, so the rows are + // read by column name rather than as one known shape. + const rows = useMemo(() => (rowsData?.rows ?? []) as Record[], [rowsData]); const pagination = rowsData?.pagination ?? { page: 1, limit, totalRows: 0, totalPages: 0 }; const columns = useMemo(() => { @@ -112,7 +89,7 @@ function DatabaseViewerPage() { }, [rows]); const columnSchemaMap = useMemo(() => { - const map: Record = {}; + const map: Record = {}; for (const col of schemaColumns) { map[col.name] = col; } diff --git a/packages/web/src/routes/_app/_protected/admin/index.tsx b/packages/web/src/routes/_app/_protected/admin/index.tsx index deabbedc..86b52743 100644 --- a/packages/web/src/routes/_app/_protected/admin/index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/index.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute('/_app/_protected/admin/')({ function AdminDashboard() { const statsQuery = useAdminStats(); - const stats = statsQuery.data as Record | undefined; + const stats = statsQuery.data; return ( diff --git a/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx b/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx index cbf24daa..86e877a3 100644 --- a/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/orgs.$orgId.tsx @@ -34,7 +34,7 @@ import { GrantDialog } from '@/components/admin/GrantDialog'; import { OrgBillingReconcilePanel } from '@/components/admin/OrgBillingReconcilePanel'; import { OrgMembersSection } from '@/components/admin/orgs/OrgMembersSection'; import { OrgProjectsSection } from '@/components/admin/orgs/OrgProjectsSection'; -import type { AdminOrgDetails } from '@/server/functions/admin-orgs.server'; +import type { AdminOrgSubscription } from '@/server/functions/admin-orgs.server'; import { queryKeys } from '@/lib/queryKeys'; const BACK_TO_ORGS = { to: '/admin/orgs', label: 'Back to Organizations' }; @@ -43,51 +43,14 @@ export const Route = createFileRoute('/_app/_protected/admin/orgs/$orgId')({ component: OrgDetailPage, }); -interface SubscriptionRecord { - id: string; - plan: string; - status: string; - periodStart?: string | number | Date; - periodEnd?: string | number | Date; - cancelAtPeriodEnd?: boolean; - canceledAt?: string | number | Date | null; - endedAt?: string | number | Date | null; - stripeCustomerId?: string; - stripeSubscriptionId?: string; -} - -interface BillingData { - billing?: { - plan?: { - name?: string; - entitlements?: Record; - quotas?: Record; - }; - effectivePlanId?: string; - accessMode?: 'full' | 'readOnly'; - source?: 'free' | 'subscription' | 'grant'; - subscription?: { id?: string; plan?: string } | null; - grant?: { type?: string } | null; - }; - subscriptions?: SubscriptionRecord[]; - grants?: Array<{ - id: string; - type: string; - startsAt?: string | number | Date; - expiresAt?: string | number | Date; - createdAt?: string | number | Date; - revokedAt?: string | number | Date | null; - }>; -} - function OrgDetailPage() { const { orgId } = Route.useParams(); const queryClient = useQueryClient(); const orgDetailsQuery = useAdminOrgDetails(orgId); const billingQuery = useAdminOrgBilling(orgId); - const orgDetails = orgDetailsQuery.data as AdminOrgDetails | undefined; - const billing = billingQuery.data as BillingData | undefined; + const orgDetails = orgDetailsQuery.data; + const billing = billingQuery.data; const memberCount = orgDetails?.stats?.memberCount ?? 0; const projectCount = orgDetails?.stats?.projectCount ?? 0; @@ -101,7 +64,7 @@ function OrgDetailPage() { grantId?: string; } | null>(null); const [loading, setLoading] = useState(false); - const [editingSubscription, setEditingSubscription] = useState(null); + const [editingSubscription, setEditingSubscription] = useState(null); // Subscription form state const [subPlan, setSubPlan] = useState('team'); @@ -237,7 +200,7 @@ function OrgDetailPage() { } }; - const handleEditSubscription = (subscription: SubscriptionRecord) => { + const handleEditSubscription = (subscription: AdminOrgSubscription) => { setEditingSubscription(subscription); setSubPlan(subscription.plan); setSubStatus(subscription.status); diff --git a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx index 6d33fd0f..43484581 100644 --- a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx @@ -4,6 +4,7 @@ import { BuildingIcon } from 'lucide-react'; import { useAdminOrgs } from '@/hooks/useAdminQueries'; import { formatDate } from '@/lib/formatDate'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { AdminOrgListItem } from '@/server/functions/admin-orgs.server'; import { AdminDataTable, AdminEmpty, @@ -13,17 +14,6 @@ import { type AdminColumnDef, } from '@/components/admin/ui'; -interface OrgRow { - id: string; - name: string; - slug: string; - stats?: { - memberCount?: number; - projectCount?: number; - }; - createdAt?: string | number; -} - const PAGE_SIZE = 25; export const Route = createFileRoute('/_app/_protected/admin/orgs/')({ @@ -41,19 +31,14 @@ function AdminOrgList() { limit: PAGE_SIZE, search: debouncedSearch, }); - const orgsData = orgsDataQuery.data as - | { - orgs: OrgRow[]; - pagination: { limit: number; total: number; totalPages: number }; - } - | undefined; + const orgsData = orgsDataQuery.data; const handleSearchChange = (value: string) => { setSearch(value); setPage(1); }; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: 'name', @@ -143,7 +128,7 @@ function AdminOrgList() { /> } enableSorting - onRowClick={(row: OrgRow) => + onRowClick={(row: AdminOrgListItem) => navigate({ to: '/admin/orgs/$orgId' as string, params: { orgId: row.id } as Record, diff --git a/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx b/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx index 72225bf3..c5d5cf8d 100644 --- a/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/projects.$projectId.tsx @@ -10,11 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { handleError } from '@/lib/error-utils'; import { queryKeys } from '@/lib/queryKeys'; import { AdminError, AdminPage, AdminPanel } from '@/components/admin/ui'; -import type { - AdminProjectDetails, - AdminProjectMember, - WorkspaceStats, -} from '@/server/functions/admin-projects.server'; +import type { AdminProjectMember } from '@/server/functions/admin-projects.server'; import { ProjectInfoSection } from '@/components/admin/projects/ProjectInfoSection'; import { WorkspaceStorageSection } from '@/components/admin/projects/WorkspaceStorageSection'; import { ProjectMembersSection } from '@/components/admin/projects/ProjectMembersSection'; @@ -37,13 +33,13 @@ function ProjectDetailPage() { const queryClient = useQueryClient(); const projectQuery = useAdminProjectDetails(projectId); - const projectData = projectQuery.data as AdminProjectDetails | undefined; + const projectData = projectQuery.data; // DO storage stats are fetched separately because they route through the // ProjectDoc DO and are slower than the D1 details query. Loading them as // a sibling query lets the rest of the page render immediately. const statsQuery = useAdminWorkspaceStats(projectId); - const workspaceStats = statsQuery.data as WorkspaceStats | undefined; + const workspaceStats = statsQuery.data; const [confirmDialog, setConfirmDialog] = useState<{ type: 'delete-project' | 'remove-member'; diff --git a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx index 6f8b4022..9a45f7de 100644 --- a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; import { FolderIcon } from 'lucide-react'; import { useAdminProjects, useAdminOrgs } from '@/hooks/useAdminQueries'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { AdminProjectListItem } from '@/server/functions/admin-projects.server'; import { formatDate } from '@/lib/formatDate'; import { AdminDataTable, @@ -23,26 +24,6 @@ import { SelectValue, } from '@/components/ui/select'; -interface ProjectRow { - id: string; - name: string; - orgId: string; - orgName: string; - orgSlug: string; - createdBy: string; - creatorDisplayName?: string; - creatorName?: string; - creatorEmail?: string; - memberCount: number; - fileCount: number; - createdAt?: string | number; -} - -interface OrgOption { - id: string; - name: string; -} - const PAGE_SIZE = 25; const ALL_ORGS_VALUE = 'all'; @@ -63,15 +44,10 @@ function AdminProjectList() { search: debouncedSearch, orgId: selectedOrgId, }); - const projectsData = projectsQuery.data as - | { - projects: ProjectRow[]; - pagination: { page: number; total: number; totalPages: number }; - } - | undefined; + const projectsData = projectsQuery.data; const orgsQuery = useAdminOrgs({ page: 1, limit: 100, search: '' }); - const orgsData = orgsQuery.data as { orgs: OrgOption[] } | undefined; + const orgsData = orgsQuery.data; const projects = projectsData?.projects || []; const pagination = projectsData?.pagination; @@ -87,7 +63,7 @@ function AdminProjectList() { setPage(1); }; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: 'name', @@ -112,11 +88,11 @@ function AdminProjectList() { }, }, { - accessorKey: 'creatorDisplayName', + accessorKey: 'creatorName', header: 'Created by', cell: info => { const project = info.row.original; - const name = project.creatorDisplayName || project.creatorName; + const name = project.creatorName; if (!name && !project.creatorEmail) { return -; } @@ -244,7 +220,7 @@ function AdminProjectList() { /> } enableSorting - onRowClick={(row: ProjectRow) => + onRowClick={(row: AdminProjectListItem) => navigate({ to: '/admin/projects/$projectId' as string, params: { projectId: row.id } as Record, diff --git a/packages/web/src/routes/_app/_protected/admin/storage.tsx b/packages/web/src/routes/_app/_protected/admin/storage.tsx index 490ddb0d..664ca4a6 100644 --- a/packages/web/src/routes/_app/_protected/admin/storage.tsx +++ b/packages/web/src/routes/_app/_protected/admin/storage.tsx @@ -44,22 +44,6 @@ export const Route = createFileRoute('/_app/_protected/admin/storage')({ component: StorageManagementPage, }); -interface StorageDocument { - key: string; - fileName: string; - size?: number; - projectId?: string; - studyId?: string; - uploaded?: string; - orphaned?: boolean; -} - -interface StorageDocumentsData { - documents: StorageDocument[]; - nextCursor?: string | null; - truncated?: boolean; -} - const PAGE_SIZE = 50; function StorageManagementPage() { @@ -78,7 +62,7 @@ function StorageManagementPage() { prefix, search: debouncedSearch, }); - const documentsData = documentsDataQuery.data as StorageDocumentsData | undefined; + const documentsData = documentsDataQuery.data; const documents = documentsData?.documents ?? []; const resetPaging = () => { diff --git a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx index 3165de2a..6a19df44 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx @@ -19,7 +19,6 @@ import { handleError } from '@/lib/error-utils'; import { queryKeys } from '@/lib/queryKeys'; import { AdminError, AdminPage, AdminPanel } from '@/components/admin/ui'; import { Skeleton } from '@/components/ui/skeleton'; -import type { AdminUserDetails } from '@/server/functions/admin-users.server'; import { UserActions } from '@/components/admin/users/UserActions'; import { UserProfileSection } from '@/components/admin/users/UserProfileSection'; import { UserLinkedAccounts } from '@/components/admin/users/UserLinkedAccounts'; @@ -76,7 +75,7 @@ function UserDetailContent() { const qc = useQueryClient(); const { data } = useSuspenseQuery(adminUserDetailsQueryOptions(userId)); - const userData = data as unknown as AdminUserDetails; + const userData = data; const user = userData.user; const [confirmDialog, setConfirmDialog] = useState<{ diff --git a/packages/web/src/routes/_app/_protected/admin/users.index.tsx b/packages/web/src/routes/_app/_protected/admin/users.index.tsx index f426cfcd..05feff8f 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.index.tsx @@ -22,12 +22,7 @@ function AdminUserList() { limit: PAGE_SIZE, search: debouncedSearch, }); - const usersData = usersDataQuery.data as - | { - users: Array<{ id: string; [key: string]: unknown }>; - pagination: { limit: number; total: number; totalPages: number }; - } - | undefined; + const usersData = usersDataQuery.data; const handleSearchChange = (value: string) => { setSearch(value); diff --git a/packages/web/src/server/functions/admin-billing.server.ts b/packages/web/src/server/functions/admin-billing.server.ts index 8d529627..2e31cc2d 100644 --- a/packages/web/src/server/functions/admin-billing.server.ts +++ b/packages/web/src/server/functions/admin-billing.server.ts @@ -215,3 +215,5 @@ export async function getAdminBillingStuckStates( stuckOrgs, }; } + +export type AdminLedgerEntry = Awaited>['entries'][number]; diff --git a/packages/web/src/server/functions/admin-database.server.ts b/packages/web/src/server/functions/admin-database.server.ts index 4d975c13..5dd3b0e1 100644 --- a/packages/web/src/server/functions/admin-database.server.ts +++ b/packages/web/src/server/functions/admin-database.server.ts @@ -307,3 +307,5 @@ export async function getAdminTableRows( }, }; } + +export type AdminTableColumn = Awaited>['columns'][number]; diff --git a/packages/web/src/server/functions/admin-orgs.server.ts b/packages/web/src/server/functions/admin-orgs.server.ts index 230c0497..1251a811 100644 --- a/packages/web/src/server/functions/admin-orgs.server.ts +++ b/packages/web/src/server/functions/admin-orgs.server.ts @@ -264,6 +264,11 @@ export async function getAdminOrgDetails(session: Session, db: Database, orgId: }; } +export type AdminOrgBilling = Awaited>; +export type AdminOrgBillingState = AdminOrgBilling['billing']; +export type AdminOrgSubscription = AdminOrgBilling['subscriptions'][number]; +export type AdminOrgGrant = AdminOrgBilling['grants'][number]; +export type AdminOrgListItem = Awaited>['orgs'][number]; export type AdminOrgDetails = Awaited>; export type AdminOrgMember = AdminOrgDetails['members'][number]; export type AdminOrgProject = AdminOrgDetails['projects'][number]; diff --git a/packages/web/src/server/functions/admin-projects.server.ts b/packages/web/src/server/functions/admin-projects.server.ts index 57ce3f01..c67cd6b9 100644 --- a/packages/web/src/server/functions/admin-projects.server.ts +++ b/packages/web/src/server/functions/admin-projects.server.ts @@ -301,6 +301,9 @@ export interface WorkspaceStats { }; } +export type AdminProjectListItem = Awaited< + ReturnType +>['projects'][number]; export type AdminProjectDetails = Awaited>; export type AdminProjectMember = AdminProjectDetails['members'][number]; export type AdminProjectFile = AdminProjectDetails['files'][number]; diff --git a/packages/web/src/server/functions/admin-users.server.ts b/packages/web/src/server/functions/admin-users.server.ts index 19ba0c4c..ef11c185 100644 --- a/packages/web/src/server/functions/admin-users.server.ts +++ b/packages/web/src/server/functions/admin-users.server.ts @@ -238,6 +238,7 @@ export async function getAdminUserDetails(session: Session, db: Database, userId }; } +export type AdminUserListItem = Awaited>['users'][number]; export type AdminUserDetails = Awaited>; export type AdminUserProject = AdminUserDetails['projects'][number]; export type AdminUserSession = AdminUserDetails['sessions'][number];