diff --git a/src/__test__/unit/formatting.test.ts b/src/__test__/unit/formatting.test.ts
index 70d7c77eb..60f092fdd 100644
--- a/src/__test__/unit/formatting.test.ts
+++ b/src/__test__/unit/formatting.test.ts
@@ -5,12 +5,14 @@ import {
formatChartTimestampUTC,
formatCompactDate,
formatCPUCores,
+ formatDate,
formatDecimal,
formatDuration,
formatMemory,
formatNumber,
formatTimeAxisLabel,
parseUTCDateComponents,
+ pluralize,
} from '@/lib/utils/formatting'
describe('Date & Time Formatting', () => {
@@ -75,6 +77,22 @@ describe('Date & Time Formatting', () => {
})
})
+ describe('formatDate', () => {
+ it('formats a date with the requested structure', () => {
+ const date = new Date('2024-01-05T14:30:00Z')
+ expect(formatDate(date, 'MMM d')).toBe('Jan 5')
+ })
+
+ it('supports a format that includes the year', () => {
+ const date = new Date('2024-01-05T14:30:00Z')
+ expect(formatDate(date, 'MMM d, yyyy')).toBe('Jan 5, 2024')
+ })
+
+ it('returns null for invalid dates', () => {
+ expect(formatDate(new Date('not-a-date'), 'MMM d')).toBeNull()
+ })
+ })
+
describe('parseUTCDateComponents', () => {
it('parses UTC date into components', () => {
const date = new Date('2024-01-05T14:30:45Z')
@@ -185,4 +203,23 @@ describe('Number Formatting', () => {
expect(formatCPUCores(4)).toBe('4 cores')
})
})
+
+ describe('pluralize', () => {
+ it('returns the singular word for a count of one', () => {
+ expect(pluralize(1, 'member')).toBe('member')
+ })
+
+ it('adds es for words ending in s-like sounds', () => {
+ expect(pluralize(2, 'class')).toBe('classes')
+ expect(pluralize(2, 'match')).toBe('matches')
+ })
+
+ it('changes a trailing consonant y to ies', () => {
+ expect(pluralize(2, 'company')).toBe('companies')
+ })
+
+ it('supports an explicit plural override for irregular nouns', () => {
+ expect(pluralize(2, 'person', 'people')).toBe('people')
+ })
+ })
})
diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts
new file mode 100644
index 000000000..4d85cfe7b
--- /dev/null
+++ b/src/__test__/unit/member-table-utils.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest'
+import type { TeamMember } from '@/core/modules/teams/models'
+import {
+ getAddedByMember,
+ shouldShowRemoveMemberAction,
+ wasAddedBySystem,
+} from '@/features/dashboard/members/member-table.utils'
+
+const createMember = ({
+ addedBy = null,
+ email,
+ id,
+ isDefault = false,
+ name,
+ providers,
+}: {
+ addedBy?: string | null
+ email: string
+ id: string
+ isDefault?: boolean
+ name?: string
+ providers?: string[]
+}): TeamMember => ({
+ info: {
+ id,
+ email,
+ name,
+ providers,
+ createdAt: '2026-04-08T00:00:00.000Z',
+ },
+ relation: {
+ added_by: addedBy,
+ is_default: isDefault,
+ },
+})
+
+describe('member table utils', () => {
+ it('finds the inviter from the full member list', () => {
+ const owner = createMember({
+ email: 'owner@example.com',
+ id: 'owner-id',
+ isDefault: true,
+ name: 'Owner',
+ })
+ const invited = createMember({
+ addedBy: owner.info.id,
+ email: 'invited@example.com',
+ id: 'invited-id',
+ name: 'Invited',
+ })
+
+ expect(getAddedByMember([owner, invited], invited.relation.added_by)).toBe(
+ owner
+ )
+ })
+
+ it('hides removal for default members and the current user', () => {
+ const defaultMember = createMember({
+ email: 'default@example.com',
+ id: 'default-id',
+ isDefault: true,
+ })
+ const currentUser = createMember({
+ email: 'me@example.com',
+ id: 'me-id',
+ })
+ const invited = createMember({
+ email: 'invited@example.com',
+ id: 'invited-id',
+ })
+
+ expect(shouldShowRemoveMemberAction(defaultMember, 'someone-else')).toBe(
+ false
+ )
+ expect(shouldShowRemoveMemberAction(currentUser, currentUser.info.id)).toBe(
+ false
+ )
+ expect(shouldShowRemoveMemberAction(invited, currentUser.info.id)).toBe(
+ true
+ )
+ })
+
+ it('treats self-added or unresolved rows as system-added', () => {
+ const owner = createMember({
+ email: 'owner@example.com',
+ id: 'owner-id',
+ isDefault: true,
+ })
+ const selfAdded = createMember({
+ addedBy: owner.info.id,
+ email: 'owner@example.com',
+ id: 'owner-id',
+ isDefault: true,
+ })
+ const invited = createMember({
+ addedBy: owner.info.id,
+ email: 'invited@example.com',
+ id: 'invited-id',
+ })
+
+ expect(wasAddedBySystem(selfAdded, owner)).toBe(true)
+ expect(wasAddedBySystem(invited, owner)).toBe(false)
+ expect(wasAddedBySystem(invited, undefined)).toBe(true)
+ })
+})
diff --git a/src/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx
index 6ce32e938..1fbdd1bf9 100644
--- a/src/app/dashboard/[teamSlug]/members/page.tsx
+++ b/src/app/dashboard/[teamSlug]/members/page.tsx
@@ -1,5 +1,5 @@
+import { Page } from '@/features/dashboard/layouts/page'
import { MemberCard } from '@/features/dashboard/members/member-card'
-import Frame from '@/ui/frame'
interface MembersPageProps {
params: Promise<{
@@ -9,15 +9,8 @@ interface MembersPageProps {
export default async function MembersPage({ params }: MembersPageProps) {
return (
-
-
-
+
+
+
)
}
diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts
index b77242521..9dd922c85 100644
--- a/src/core/modules/teams/models.ts
+++ b/src/core/modules/teams/models.ts
@@ -9,6 +9,7 @@ export type TeamMemberInfo = {
name?: string
avatar_url?: string
providers?: string[]
+ createdAt: string | null
}
export type TeamMemberRelation = {
diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts
index a3a5ef9df..219764395 100644
--- a/src/core/modules/teams/teams-repository.server.ts
+++ b/src/core/modules/teams/teams-repository.server.ts
@@ -87,6 +87,7 @@ export function createTeamsRepository(
name: user?.user_metadata?.name,
avatar_url: user?.user_metadata?.avatar_url,
providers: extractSignInProviders(user),
+ createdAt: member.createdAt,
},
relation: {
added_by: member.addedBy ?? null,
diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx
index f603d5885..f561445ce 100644
--- a/src/features/dashboard/billing/invoices.tsx
+++ b/src/features/dashboard/billing/invoices.tsx
@@ -17,12 +17,12 @@ import {
Table,
TableBody,
TableCell,
+ TableEmptyState,
TableHead,
TableHeader,
TableRow,
} from '@/ui/primitives/table'
import { useInvoices } from './hooks'
-import { TableEmptyRowBorder } from './table-empty-row-border'
const COLUMN_WIDTHS = {
date: 120,
@@ -48,34 +48,19 @@ interface InvoicesEmptyProps {
function InvoicesEmpty({ error }: InvoicesEmptyProps) {
return (
-
- {Array.from({ length: 3 }).map((_, index) => (
-
-
-
-
- {index === 1 && (
- <>
-
-
-
- {error ? error : 'No invoices yet'}
-
- >
- )}
-
- ))}
-
+
+
+
+ {error ? error : 'No invoices yet'}
+
+
)
}
@@ -146,13 +131,7 @@ export default function BillingInvoicesTable() {
)}
- {showEmpty && (
-
-
-
-
-
- )}
+ {showEmpty && }
{hasData &&
invoices.map((invoice) => (
diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx
new file mode 100644
index 000000000..ab28a6947
--- /dev/null
+++ b/src/features/dashboard/layouts/page.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+interface PageProps {
+ children: ReactNode
+ className?: string
+}
+
+export const Page = ({ children, className }: PageProps) => (
+
+ {children}
+
+)
diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx
new file mode 100644
index 000000000..61494b43a
--- /dev/null
+++ b/src/features/dashboard/members/add-member-dialog.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import { Plus } from 'lucide-react'
+import { useState } from 'react'
+import { AddMemberForm } from '@/features/dashboard/members/add-member-form'
+import { Button } from '@/ui/primitives/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/primitives/dialog'
+
+export const AddMemberDialog = () => {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx
index 68f7f9130..80d656cf2 100644
--- a/src/features/dashboard/members/add-member-form.tsx
+++ b/src/features/dashboard/members/add-member-form.tsx
@@ -20,6 +20,7 @@ import {
FormLabel,
FormMessage,
} from '@/ui/primitives/form'
+import { AddIcon } from '@/ui/primitives/icons'
import { Input } from '@/ui/primitives/input'
import { useDashboard } from '../context'
@@ -31,9 +32,10 @@ type AddMemberForm = z.infer
interface AddMemberFormProps {
className?: string
+ onSuccess?: () => void
}
-export default function AddMemberForm({ className }: AddMemberFormProps) {
+export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => {
'use no memo'
const { team } = useDashboard()
@@ -41,6 +43,7 @@ export default function AddMemberForm({ className }: AddMemberFormProps) {
const form = useForm({
resolver: zodResolver(addMemberSchema),
+ mode: 'onChange',
defaultValues: {
email: '',
},
@@ -50,16 +53,15 @@ export default function AddMemberForm({ className }: AddMemberFormProps) {
onSuccess: () => {
toast(defaultSuccessToast('The member has been added to the team.'))
form.reset()
+ onSuccess?.()
},
onError: ({ error }) => {
toast(defaultErrorToast(error.serverError || 'An error occurred.'))
},
})
- function onSubmit(data: AddMemberForm) {
- if (!team) {
- return
- }
+ const onSubmit = (data: AddMemberForm) => {
+ if (!team) return
execute({
teamSlug: team.slug,
@@ -71,31 +73,37 @@ export default function AddMemberForm({ className }: AddMemberFormProps) {
)
diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx
index 8df8f25be..8d5d97c4e 100644
--- a/src/features/dashboard/members/member-card.tsx
+++ b/src/features/dashboard/members/member-card.tsx
@@ -1,12 +1,9 @@
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/ui/primitives/card'
-import AddMemberForm from './add-member-form'
-import MemberTable from './member-table'
+import { Suspense } from 'react'
+import { getTeamMembers } from '@/core/server/functions/team/get-team-members'
+import { ErrorIndicator } from '@/ui/error-indicator'
+import { Card, CardContent } from '@/ui/primitives/card'
+import { Loader } from '@/ui/primitives/loader_d'
+import MembersPageContent from './members-page-content'
interface MemberCardProps {
params: Promise<{
@@ -15,21 +12,40 @@ interface MemberCardProps {
className?: string
}
-export function MemberCard({ params, className }: MemberCardProps) {
- return (
-
-
- Members
- Manage your team members.
-
-
-
-
-
- )
+export const MemberCard = ({ params, className }: MemberCardProps) => (
+
+
+ }>
+
+
+
+
+)
+
+const MembersPageContentLoader = async ({ params }: MemberCardProps) => {
+ const { teamSlug } = await params
+
+ try {
+ const result = await getTeamMembers({ teamSlug })
+
+ if (!result?.data || result.serverError || result.validationErrors) {
+ throw new Error(result?.serverError || 'Unknown error')
+ }
+
+ return
+ } catch (error) {
+ return (
+
+ )
+ }
}
+
+const MembersPageContentLoading = () => (
+
+
+
+)
diff --git a/src/features/dashboard/members/member-table-body.tsx b/src/features/dashboard/members/member-table-body.tsx
deleted file mode 100644
index ab218c0ca..000000000
--- a/src/features/dashboard/members/member-table-body.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { getTeamMembers } from '@/core/server/functions/team/get-team-members'
-import { ErrorIndicator } from '@/ui/error-indicator'
-import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert'
-import { TableCell, TableRow } from '@/ui/primitives/table'
-import MemberTableRow from './member-table-row'
-
-interface TableBodyContentProps {
- params: Promise<{
- teamSlug: string
- }>
-}
-
-export default async function MemberTableBody({
- params,
-}: TableBodyContentProps) {
- const { teamSlug } = await params
-
- try {
- const result = await getTeamMembers({ teamSlug })
-
- if (!result?.data || result.serverError || result.validationErrors) {
- throw new Error(result?.serverError || 'Unknown error')
- }
-
- const members = result.data
-
- if (members.length === 0) {
- return (
-
-
-
- No Members
- No team members found.
-
-
-
- )
- }
-
- return (
- <>
- {members.map((member, index) => (
- m.info.id === member.relation.added_by)?.info
- .email
- }
- />
- ))}
- >
- )
- } catch (error) {
- return (
-
-
-
-
-
- )
- }
-}
diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx
index afd83c8ee..341d68708 100644
--- a/src/features/dashboard/members/member-table-row.tsx
+++ b/src/features/dashboard/members/member-table-row.tsx
@@ -7,6 +7,7 @@ import type { IconType } from 'react-icons'
import { FaGithub, FaGoogle } from 'react-icons/fa'
import { FiMail } from 'react-icons/fi'
import { PROTECTED_URLS } from '@/configs/urls'
+import { getTeamDisplayName } from '@/core/modules/teams/utils'
import { removeTeamMemberAction } from '@/core/server/actions/team-actions'
import type { TeamMember } from '@/core/server/functions/team/types'
import {
@@ -14,17 +15,23 @@ import {
defaultSuccessToast,
useToast,
} from '@/lib/hooks/use-toast'
-import { AlertDialog } from '@/ui/alert-dialog'
+import { formatDate } from '@/lib/utils/formatting'
+import { E2BLogo } from '@/ui/brand'
import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar'
import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
+import { TrashIcon } from '@/ui/primitives/icons'
import { TableCell, TableRow } from '@/ui/primitives/table'
import { useDashboard } from '../context'
+import {
+ shouldShowRemoveMemberAction,
+ wasAddedBySystem,
+} from './member-table.utils'
+import { RemoveMemberDialog } from './remove-member-dialog'
interface TableRowProps {
member: TeamMember
- addedByEmail?: string
- index: number
+ addedByMember?: TeamMember
}
type MemberProvider = {
@@ -43,27 +50,19 @@ function normalizeProvider(provider: string): string {
function toMemberProvider(provider: string): MemberProvider | null {
const normalized = normalizeProvider(provider)
- if (normalized === 'google') {
+ if (normalized === 'google')
return { key: normalized, label: 'Google', Icon: FaGoogle }
- }
- if (normalized === 'github') {
+ if (normalized === 'github')
return { key: normalized, label: 'GitHub', Icon: FaGithub }
- }
- if (normalized === 'email') {
+ if (normalized === 'email')
return { key: normalized, label: 'Email', Icon: FiMail }
- }
return null
}
-export default function MemberTableRow({
- member,
- addedByEmail,
- index,
-}: TableRowProps) {
+export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => {
const { toast } = useToast()
- const { team } = useDashboard()
const router = useRouter()
- const { user } = useDashboard()
+ const { team, user } = useDashboard()
const [removeDialogOpen, setRemoveDialogOpen] = useState(false)
const { execute: removeMember, isExecuting: isRemoving } = useAction(
@@ -88,11 +87,8 @@ export default function MemberTableRow({
}
)
- const handleRemoveMember = async (userId: string) => {
- removeMember({
- teamSlug: team.slug,
- userId,
- })
+ const handleRemoveMember = (userId: string) => {
+ removeMember({ teamSlug: team.slug, userId })
}
const providers =
@@ -100,63 +96,192 @@ export default function MemberTableRow({
?.map(toMemberProvider)
.filter((provider): provider is MemberProvider => provider !== null) ?? []
+ const isCurrentUser = member.info.id === user?.id
+ const showRemove = shouldShowRemoveMemberAction(member, user?.id)
+ const dateStr = member.info.createdAt
+ ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy')
+ : null
+ const addedBySystem = wasAddedBySystem(member, addedByMember)
+
return (
-
-
-
-
-
- {member.info?.email?.charAt(0).toUpperCase() || '?'}
-
-
-
-
- {member.info.id === user?.id
- ? 'You'
- : (member.info.name ?? 'Anonymous')}
-
- {member.info.email}
-
- {providers.length > 0 ? (
-
- {providers.map(({ key, label, Icon }) => (
-
-
- {label}
-
- ))}
-
- ) : (
- -
- )}
-
-
- {member.relation.added_by === user?.id ? 'You' : (addedByEmail ?? '')}
-
-
- {!member.relation.is_default && user?.id !== member.info.id && (
- handleRemoveMember(member.info.id)}
- confirmProps={{
- loading: isRemoving,
- }}
- trigger={
-
- }
- open={removeDialogOpen}
- onOpenChange={setRemoveDialogOpen}
- />
- )}
-
+
+
+
+ handleRemoveMember(member.info.id)}
+ removeDialogOpen={removeDialogOpen}
+ setRemoveDialogOpen={setRemoveDialogOpen}
+ showRemove={showRemove}
+ teamName={getTeamDisplayName(team)}
+ />
)
}
+
+const NameCell = ({
+ avatarUrl,
+ email,
+ isCurrentUser,
+ name,
+}: {
+ avatarUrl?: string
+ email: string
+ isCurrentUser: boolean
+ name?: string
+}) => (
+
+
+
+
+
+ {(name?.charAt(0) ?? email.charAt(0)).toUpperCase()}
+
+
+
+
+
+ {name ?? email}
+
+ {isCurrentUser ? (
+
+ You
+
+ ) : null}
+
+ {name ? (
+
+ {email}
+
+ ) : null}
+
+
+
+)
+
+const ProvidersCell = ({ providers }: { providers: MemberProvider[] }) => (
+
+ {providers.length > 0 ? (
+ <>
+
+ {providers.map(({ key, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+
+ {providers.map(({ key, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+ >
+ ) : (
+ --
+ )}
+
+)
+
+const AddedCell = ({
+ addedByMember,
+ addedBySystem,
+ dateStr,
+ isRemoving,
+ memberEmail,
+ memberName,
+ onRemove,
+ removeDialogOpen,
+ setRemoveDialogOpen,
+ showRemove,
+ teamName,
+}: {
+ addedByMember?: TeamMember
+ addedBySystem: boolean
+ dateStr: string | null
+ isRemoving: boolean
+ memberEmail: string
+ memberName?: string
+ onRemove: () => void
+ removeDialogOpen: boolean
+ setRemoveDialogOpen: (v: boolean) => void
+ showRemove: boolean
+ teamName?: string | null
+}) => (
+
+
+
+ {dateStr ?? '—'}
+
+ {addedBySystem ? (
+
+
+
+ ) : (
+
+
+
+ {addedByMember?.info.email?.charAt(0).toUpperCase() ?? '?'}
+
+
+ )}
+ {showRemove ? (
+
+
+
+ }
+ />
+ ) : null}
+
+
+)
diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx
index 4b08a33cb..06d9bc32f 100644
--- a/src/features/dashboard/members/member-table.tsx
+++ b/src/features/dashboard/members/member-table.tsx
@@ -1,58 +1,74 @@
-import { type FC, Suspense } from 'react'
+'use client'
+
+import type { FC } from 'react'
+import type { TeamMember } from '@/core/modules/teams/models'
import { cn } from '@/lib/utils'
-import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert'
-import { Loader } from '@/ui/primitives/loader_d'
import {
Table,
TableBody,
- TableCell,
+ TableEmptyState,
TableHead,
TableHeader,
TableRow,
} from '@/ui/primitives/table'
-import MemberTableBody from './member-table-body'
+import { getAddedByMember } from './member-table.utils'
+import { MemberTableRow } from './member-table-row'
interface MemberTableProps {
- params: Promise<{
- teamSlug: string
- }>
+ allMembers: TeamMember[]
+ members: TeamMember[]
+ totalMemberCount: number
className?: string
}
-const MemberTable: FC = ({ params, className }) => {
- return (
-
-
-
- |
- Name
- E-Mail
- Providers
- Added By
- |
-
-
-
-
-
-
-
-
- Loading members...
-
- This may take a moment.
-
-
-
- }
- >
-
-
-
-
- )
-}
+const MemberTable: FC = ({
+ allMembers,
+ members,
+ totalMemberCount,
+ className,
+}) => (
+
+
+
+
+
+
+
+
+
+ NAME
+
+
+ PROVIDERS
+
+
+ ADDED
+
+
+
+
+ {members.length === 0 ? (
+
+
+ {totalMemberCount === 0
+ ? 'No team members found.'
+ : 'No members match your search.'}
+
+
+ ) : (
+ members.map((member) => (
+
+ ))
+ )}
+
+
+)
export default MemberTable
diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts
new file mode 100644
index 000000000..638fbb5af
--- /dev/null
+++ b/src/features/dashboard/members/member-table.utils.ts
@@ -0,0 +1,21 @@
+import type { TeamMember } from '@/core/modules/teams/models'
+
+const getAddedByMember = (
+ allMembers: TeamMember[],
+ addedById: string | null
+): TeamMember | undefined => {
+ if (!addedById) return undefined
+ return allMembers.find((member) => member.info.id === addedById)
+}
+
+const wasAddedBySystem = (
+ member: TeamMember,
+ addedByMember?: TeamMember
+): boolean => !addedByMember || addedByMember.info.id === member.info.id
+
+const shouldShowRemoveMemberAction = (
+ member: TeamMember,
+ currentUserId?: string
+): boolean => !member.relation.is_default && member.info.id !== currentUserId
+
+export { getAddedByMember, shouldShowRemoveMemberAction, wasAddedBySystem }
diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx
new file mode 100644
index 000000000..b43e7e772
--- /dev/null
+++ b/src/features/dashboard/members/members-page-content.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { Search } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import type { TeamMember } from '@/core/modules/teams/models'
+import { cn } from '@/lib/utils'
+import { pluralize } from '@/lib/utils/formatting'
+import { Input } from '@/ui/primitives/input'
+import { AddMemberDialog } from './add-member-dialog'
+import MemberTable from './member-table'
+
+interface MembersPageContentProps {
+ members: TeamMember[]
+ className?: string
+}
+
+const MembersPageContent = ({
+ members,
+ className,
+}: MembersPageContentProps) => {
+ const [query, setQuery] = useState('')
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ if (!q) return members
+
+ return members.filter((m) => {
+ const name = (m.info.name ?? '').toLowerCase()
+ const email = m.info.email.toLowerCase()
+ return name.includes(q) || email.includes(q)
+ })
+ }, [members, query])
+
+ const totalLabel = `${members.length} ${pluralize(members.length, 'member')} total`
+
+ return (
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search by name or email"
+ type="search"
+ value={query}
+ />
+
+
+
+
+
+
All members have the same roles & permissions
+
{totalLabel}
+
+
+
+
+
+
+ )
+}
+
+export default MembersPageContent
diff --git a/src/features/dashboard/members/remove-member-dialog.tsx b/src/features/dashboard/members/remove-member-dialog.tsx
new file mode 100644
index 000000000..db40343c6
--- /dev/null
+++ b/src/features/dashboard/members/remove-member-dialog.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { Button } from '@/ui/primitives/button'
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/primitives/dialog'
+import { TrashIcon } from '@/ui/primitives/icons'
+
+interface RemoveMemberDialogProps {
+ isRemoving: boolean
+ memberEmail: string
+ memberName?: string
+ onRemove: () => void
+ open: boolean
+ setOpen: (v: boolean) => void
+ teamName?: string | null
+ trigger: ReactNode
+}
+
+export const RemoveMemberDialog = ({
+ isRemoving,
+ memberEmail,
+ memberName,
+ onRemove,
+ open,
+ setOpen,
+ teamName,
+ trigger,
+}: RemoveMemberDialogProps) => {
+ const shortMemberName = memberName?.trim().split(/\s+/)[0] || memberEmail
+ const fullMemberName = memberName ?? memberEmail
+ const teamLabel = teamName || 'this team'
+
+ return (
+
+ )
+}
diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts
index b065142cd..20f0b04d3 100644
--- a/src/lib/utils/formatting.ts
+++ b/src/lib/utils/formatting.ts
@@ -142,6 +142,24 @@ export function formatCompactDate(timestamp: number): string {
return format(date, 'yyyy MMM d, h:mm:ss a zzz')
}
+const DATE_STRUCTURES = ['MMM d', 'MMM d, yyyy'] as const
+
+type DateStructure = (typeof DATE_STRUCTURES)[number]
+
+/**
+ * Returns a formatted date string
+ * @param date - Date to format
+ * @param dateStructure - Supported date format structure
+ * @returns Formatted date string (e.g., "Apr 8, 2026") or null for invalid dates
+ */
+export const formatDate = (
+ date: Date,
+ dateStructure: DateStructure
+): string | null => {
+ if (!isValid(date)) return null
+ return format(date, dateStructure)
+}
+
export function formatDay(timestamp: number): string {
if (isThisYear(timestamp)) {
return new Intl.DateTimeFormat('en-US', {
@@ -407,6 +425,29 @@ export function formatCPUCores(
return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}`
}
+/**
+ * Returns the singular or plural word for a count
+ * @param count - Number used to determine singular vs plural form
+ * @param singular - Singular form of the word
+ * @param plural - Optional plural form override (defaults to an inferred plural form)
+ * @returns Singular or plural word (e.g., "member" or "members")
+ */
+export const pluralize = (
+ count: number,
+ singular: string,
+ plural?: string
+): string => {
+ if (count === 1) return singular
+ if (plural) return plural
+ if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) {
+ return `${singular}es`
+ }
+ if (/[^aeiou]y$/i.test(singular)) {
+ return `${singular.slice(0, -1)}ies`
+ }
+ return `${singular}s`
+}
+
/**
* Format a number for chart axis labels with smart abbreviation
* Uses whole numbers when possible, abbreviated for large numbers
@@ -450,7 +491,7 @@ export function tryParseDatetime(input: string): Date | null {
// Try parsing as timestamp first (for performance with numeric inputs)
const timestamp = Number(input)
- if (!isNaN(timestamp)) {
+ if (!Number.isNaN(timestamp)) {
// if timestamp is less than 10 digits, multiply by 1000 to get milliseconds
const date = new Date(
timestamp < 10000000000 ? timestamp * 1000 : timestamp
diff --git a/src/features/dashboard/billing/table-empty-row-border.tsx b/src/ui/primitives/table-empty-row-border.tsx
similarity index 99%
rename from src/features/dashboard/billing/table-empty-row-border.tsx
rename to src/ui/primitives/table-empty-row-border.tsx
index aa7ec16f1..7e13f39ae 100644
--- a/src/features/dashboard/billing/table-empty-row-border.tsx
+++ b/src/ui/primitives/table-empty-row-border.tsx
@@ -12,6 +12,7 @@ const PATTERN_PATH_2 = `M32.944 -12.8C32.944 -13.344 32.76 -13.8 32.392 -14.168C
export function TableEmptyRowBorder({ className }: TableEmptyRowBorderProps) {
return (