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
10 changes: 4 additions & 6 deletions apps/web/src/app/(app)/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import PersonalAppSidebar from './PersonalAppSidebar';
import OrganizationAppSidebar from './OrganizationAppSidebar';
import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar';
import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar';
import { compareOrganizationsForDefault } from '@/lib/organizations/sales-demo-sort';

const UUID = '[0-9a-f-]{36}';

Expand Down Expand Up @@ -72,13 +73,10 @@ export default function AppSidebar(props: React.ComponentProps<typeof Sidebar>)
trpc: { context: { skipBatch: true } },
})
);
// Match the server-side default (oldest org) so the sidebar org is consistent
// with getProfileRedirectPath.
// Match the server-side default (sales demo org, then oldest org) so the
// sidebar org is consistent with getProfileRedirectPath.
const defaultOrganizationId = organizations?.length
? [...organizations].sort((a, b) => {
const byCreatedAt = a.created_at.localeCompare(b.created_at);
return byCreatedAt !== 0 ? byCreatedAt : a.organizationId.localeCompare(b.organizationId);
})[0].organizationId
? [...organizations].sort(compareOrganizationsForDefault)[0].organizationId
: null;
const previousSidebarOpen = useRef<boolean | null>(null);
const currentSidebarOpen = useRef(open);
Expand Down
192 changes: 192 additions & 0 deletions apps/web/src/app/admin/components/CreateDemoOrganizationDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
'use client';

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useTRPC } from '@/lib/trpc/utils';
import { isAllowedSalesDemoEmail } from '@/lib/organizations/sales-demo-email';

const ALREADY_OWNS_DEMO_PREFIX = 'ALREADY_OWNS_DEMO:';

type CreateDemoOrganizationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};

type ConflictLinkState = {
organizationId: string;
organizationName: string;
} | null;

export function CreateDemoOrganizationDialog({
open,
onOpenChange,
}: CreateDemoOrganizationDialogProps) {
const router = useRouter();
const queryClient = useQueryClient();
const trpc = useTRPC();

const [email, setEmail] = useState('');
const [fieldError, setFieldError] = useState<string | null>(null);
const [conflictLink, setConflictLink] = useState<ConflictLinkState>(null);

const createDemoOrganizationMutation = useMutation(
trpc.admin.salesDemo.create.mutationOptions({
onSuccess: data => {
toast.success(`Demo organization "${data.organizationName}" created`);
void queryClient.invalidateQueries({ queryKey: ['admin-organizations'] });
setEmail('');
setFieldError(null);
setConflictLink(null);
onOpenChange(false);
router.push(`/admin/organizations/${encodeURIComponent(data.organizationId)}`);
},
onError: error => {
const code = error.data?.code;
const message = error.message || 'Failed to create demo organization';

if (code === 'BAD_REQUEST' || code === 'NOT_FOUND') {
setFieldError(message);
return;
}

if (code === 'CONFLICT' && message.startsWith(ALREADY_OWNS_DEMO_PREFIX)) {
const rest = message.slice(ALREADY_OWNS_DEMO_PREFIX.length);
const firstColon = rest.indexOf(':');
if (firstColon !== -1) {
setConflictLink({
organizationId: rest.slice(0, firstColon),
organizationName: rest.slice(firstColon + 1),
});
return;
}
}

toast.error(message);
},
})
);

const handleBlur = () => {
const trimmed = email.trim();
if (!trimmed || isAllowedSalesDemoEmail(trimmed)) {
setFieldError(null);
} else {
setFieldError(
'Only emails ending in @kilocode.ai or @anaconda.com can own a sales demo organization.'
);
}
};

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

const trimmed = email.trim();
if (!trimmed) {
// Do not call the mutation on an empty email.
setConflictLink(null);
setFieldError('Enter an email.');
return;
}

setFieldError(null);
setConflictLink(null);
createDemoOrganizationMutation.mutate({ email: trimmed });
};

const handleCancel = () => {
setEmail('');
setFieldError(null);
setConflictLink(null);
onOpenChange(false);
};

const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setEmail('');
setFieldError(null);
setConflictLink(null);
}
onOpenChange(nextOpen);
};

const isPending = createDemoOrganizationMutation.isPending;

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Demo Organization</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="sales-demo-email" className="text-right">
Email
</Label>
<Input
id="sales-demo-email"
type="email"
autoComplete="off"
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={handleBlur}
className="col-span-3"
placeholder="owner@example.com"
aria-invalid={fieldError ? true : undefined}
aria-describedby={fieldError ? 'sales-demo-email-error' : undefined}
/>
</div>
{fieldError && (
<div id="sales-demo-email-error" className="text-sm text-destructive" role="alert">
{fieldError}
</div>
)}
{conflictLink && (
<div className="text-sm text-muted-foreground" role="alert">
This user already owns a demo organization:{' '}
<Link
href={`/admin/organizations/${encodeURIComponent(conflictLink.organizationId)}`}
className="text-primary break-words hover:underline"
>
{conflictLink.organizationName}
</Link>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Demo Organization'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

export function CreateDemoOrganizationButton() {
const [isOpen, setIsOpen] = useState(false);

return (
<>
<Button onClick={() => setIsOpen(true)}>Create Demo Organization</Button>
<CreateDemoOrganizationDialog open={isOpen} onOpenChange={setIsOpen} />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { OrganizationAdminCreditTransactions } from './OrganizationAdminCreditTr
import { OrganizationAdminDelete } from './OrganizationAdminDelete';
import { OrganizationAdminCreditGrant } from './OrganizationAdminCreditGrant';
import { OrganizationAdminCreditNullify } from './OrganizationAdminCreditNullify';
import { OrganizationAdminSalesDemoReset } from './OrganizationAdminSalesDemoReset';
import { OrganizationAdminCreatedBy } from './OrganizationAdminCreatedBy';
import { OrganizationAdminHierarchyManagement } from './OrganizationAdminHierarchyManagement';
import { OrganizationAdminKiloPass } from './OrganizationAdminKiloPass';
Expand Down Expand Up @@ -66,6 +67,7 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId:
<OrganizationAdminCreatedBy organizationId={organizationId} />
<OrganizationAdminCreditGrant organizationId={organizationId} />
<OrganizationAdminCreditNullify organizationId={organizationId} />
<OrganizationAdminSalesDemoReset organizationId={organizationId} />
<OrganizationWorkOSCard organizationId={organizationId} />
</div>
<div className="space-y-8 lg:col-span-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { RefreshCcw } from 'lucide-react';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
useInvalidateAllOrganizationData,
useOrganizationWithMembers,
} from '@/app/api/organizations/hooks';
import { toast } from 'sonner';
import { useTRPC } from '@/lib/trpc/utils';

export function OrganizationAdminSalesDemoReset({ organizationId }: { organizationId: string }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const invalidate = useInvalidateAllOrganizationData();
const { data: organization } = useOrganizationWithMembers(organizationId);

const [isOpen, setIsOpen] = useState(false);

const resetMutation = useMutation(
trpc.admin.salesDemo.reset.mutationOptions({
onSuccess: data => {
toast.success(`Reset demo organization "${data.organizationName}"`);
setIsOpen(false);
void queryClient.invalidateQueries({ queryKey: ['organization', organizationId] });
void queryClient.invalidateQueries({ queryKey: ['admin-organizations'] });
void invalidate();
},
})
);

const isSalesDemo = organization?.settings.is_sales_demo === true;

if (!isSalesDemo) {
return null;
}

const handleReset = async () => {
try {
await resetMutation.mutateAsync({ organizationId });
} catch (error) {
// Keep the confirm dialog open on failure.
toast.error(error instanceof Error ? error.message : 'Failed to reset demo organization');
}
};

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCcw className="h-5 w-5" />
Reset demo organization
</CardTitle>
<CardDescription>
Restore the $50.00 balance, the owner plus 25 demo members, and the demo organization
settings.
</CardDescription>
</CardHeader>
<CardContent>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">Reset demo organization</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset demo organization</DialogTitle>
<DialogDescription>
This restores the organization to its demo state: a $50.00 balance, the owner plus
25 demo members, and the demo organization settings. Usage and credits are cleared.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button onClick={handleReset} disabled={resetMutation.isPending}>
{resetMutation.isPending ? 'Resetting...' : 'Reset demo organization'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}
23 changes: 17 additions & 6 deletions apps/web/src/app/admin/components/OrganizationsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ type OrganizationsTableProps = {
// which sets `stripe_status=any` in the URL so the default no longer kicks
// in on refresh.
defaultStripeStatus?: string;
// Extra action controls rendered beside the create button (e.g. the
// demo-organization dialog trigger). Kept as a node so callers own their
// dialog/trigger pair without the table needing to know about them.
actions?: React.ReactNode;
};

const ANY_STRIPE_STATUS_TOKEN = 'any';
Expand All @@ -63,6 +67,7 @@ export function OrganizationsTable({
showTrialEndDate = false,
showTrialFilters = false,
defaultStripeStatus,
actions,
}: OrganizationsTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
Expand Down Expand Up @@ -322,12 +327,18 @@ export function OrganizationsTable({
[sharedParams, updateUrl]
);

const buttons = create ? (
<Button variant="outline" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4" />
{create.label}
</Button>
) : null;
const buttons =
create || actions ? (
<>
{create ? (
<Button variant="outline" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4" />
{create.label}
</Button>
) : null}
{actions}
</>
) : null;

const breadcrumbs = (
<BreadcrumbItem>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/admin/organizations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Suspense } from 'react';
import { OrganizationsTable } from '../components/OrganizationsTable';
import { CreateDemoOrganizationButton } from '../components/CreateDemoOrganizationDialog';

export default async function OrganizationsPage() {
return (
<Suspense fallback={<div>Loading organizations...</div>}>
<OrganizationsTable defaultStripeStatus="active" />
<OrganizationsTable defaultStripeStatus="active" actions={<CreateDemoOrganizationButton />} />
</Suspense>
);
}
Loading