Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions packages/web/src/components/admin/GrantList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 33 additions & 53 deletions packages/web/src/components/admin/OrgBillingReconcilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -91,7 +58,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);
Expand All @@ -104,28 +77,35 @@ export function OrgBillingReconcilePanel({ orgId }: { orgId: string }) {
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 (
<AdminPanel
title='Billing Reconciliation'
padded
action={
<Button
variant='outline'
size='sm'
onClick={() => reconcileQuery.refetch()}
disabled={reconcileQuery.isFetching}
>
<RefreshCwIcon
className={reconcileQuery.isFetching ? 'animate-spin' : ''}
data-icon='inline-start'
/>
Refresh
</Button>
<>
{onHide && (
<Button variant='ghost' size='sm' onClick={onHide}>
Hide
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={() => reconcileQuery.refetch()}
disabled={reconcileQuery.isFetching}
>
<RefreshCwIcon
className={reconcileQuery.isFetching ? 'animate-spin' : ''}
data-icon='inline-start'
/>
Refresh
</Button>
</>
}
>
<div className='border-border bg-muted/40 mb-5 grid grid-cols-1 gap-4 rounded-lg border p-4 md:grid-cols-4'>
Expand Down Expand Up @@ -163,7 +143,7 @@ export function OrgBillingReconcilePanel({ orgId }: { orgId: string }) {
</div>

<div className='mb-5 grid grid-cols-2 gap-3 md:grid-cols-5'>
<AdminStat label='Total stuck' value={summary.stuckStateCount ?? 0} loading={isLoading} />
<AdminStat label='Total stuck' value={summary?.stuckStateCount ?? 0} loading={isLoading} />
<AdminStat
label='Critical'
value={stuckStates.filter(s => s.severity === 'critical').length}
Expand All @@ -178,12 +158,12 @@ export function OrgBillingReconcilePanel({ orgId }: { orgId: string }) {
/>
<AdminStat
label='Failed webhooks'
value={summary.failedWebhooks ?? 0}
value={summary?.failedWebhooks ?? 0}
loading={isLoading}
/>
<AdminStat
label='Ignored webhooks'
value={summary.ignoredWebhooks ?? 0}
value={summary?.ignoredWebhooks ?? 0}
loading={isLoading}
/>
</div>
Expand Down
20 changes: 3 additions & 17 deletions packages/web/src/components/admin/OrgBillingSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<string, boolean | string | number>;
quotas?: Record<string, number | null | undefined>;
}

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

Expand All @@ -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';

Expand Down
25 changes: 7 additions & 18 deletions packages/web/src/components/admin/SubscriptionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -92,7 +78,10 @@ export function SubscriptionList({
<StripeId label='Customer' value={subscription.stripeCustomerId} />
)}
{subscription.stripeSubscriptionId && (
<StripeId label='Subscription' value={subscription.stripeSubscriptionId} />
<StripeId
label='AdminOrgSubscription'
value={subscription.stripeSubscriptionId}
/>
)}
</div>
)}
Expand Down
23 changes: 5 additions & 18 deletions packages/web/src/components/admin/UserTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,7 +20,7 @@ const PROVIDER_INFO: Record<string, ProviderInfo> = {
};

interface UserTableProps {
users: UserRow[];
users: AdminUserListItem[];
loading?: boolean;
refreshing?: boolean;
skeletonRows?: number;
Expand All @@ -51,7 +38,7 @@ export function UserTable({
}: UserTableProps) {
const navigate = useNavigate();

const columns = useMemo<AdminColumnDef<UserRow>[]>(
const columns = useMemo<AdminColumnDef<AdminUserListItem>[]>(
() => [
{
accessorKey: 'name',
Expand All @@ -61,7 +48,7 @@ export function UserTable({
return (
<div className='flex items-center gap-2.5'>
<UserAvatar
src={user.avatarUrl || user.image}
src={user.avatarUrl || user.image || undefined}
name={user.name}
className='size-6.5'
/>
Expand Down Expand Up @@ -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<string, string>,
Expand Down
91 changes: 91 additions & 0 deletions packages/web/src/components/admin/orgs/OrgMembersSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminPanel
title={`Members (${total})`}
footer={
rows.length < total ?
<span className='text-muted-foreground text-[13px]'>
Showing the {rows.length} most recently joined.
</span>
: undefined
}
>
{isLoading ?
<div className='p-4'>
<Skeleton className='h-40 w-full' />
</div>
: rows.length === 0 ?
<AdminEmpty title='No members' />
: <Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-border hover:bg-transparent'>
<TableHead className={ADMIN_TH}>User</TableHead>
<TableHead className={`${ADMIN_TH} w-28`}>Role</TableHead>
<TableHead className={`${ADMIN_TH} w-32`}>Joined</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(member => (
<TableRow key={member.id} className='border-border'>
<TableCell className={ADMIN_TD}>
<div className='flex items-center gap-2.5'>
<UserAvatar
src={member.userAvatar ?? undefined}
name={member.userName ?? undefined}
className='size-6.5'
/>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<Link
to={'/admin/users/$userId' as string}
params={{ userId: member.userId } as Record<string, string>}
className='text-foreground hover:text-primary font-medium transition-colors'
>
{member.userName || member.userEmail}
</Link>
{member.userBanned && <Badge variant='destructive'>Banned</Badge>}
</div>
<p className='text-muted-foreground truncate text-xs'>{member.userEmail}</p>
</div>
</div>
</TableCell>
<TableCell className={ADMIN_TD}>
<Badge variant={member.role === 'owner' ? 'default' : 'secondary'}>
{member.role ?? 'member'}
</Badge>
</TableCell>
<TableCell className={`${ADMIN_TD_MUTED} tabular-nums`}>
{formatDate(member.joinedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
}
</AdminPanel>
);
}
Loading
Loading