History

Polish web workspace shell and member billing flows

Rhys Sullivan committed 9 months ago

Commit ffb327f1 parent 8e23be511 files · +90 −164Browse files

Unified · Split

11 files
  1. executor/apps/web/src/app/approvals/page.tsx +1 −6
  2. executor/apps/web/src/app/billing/page.tsx +1 −6
  3. executor/apps/web/src/app/layout.tsx +2 −1
  4. executor/apps/web/src/app/members/page.tsx +1 −6
  5. executor/apps/web/src/app/page.tsx +1 −6
  6. executor/apps/web/src/app/tasks/page.tsx +3 −6
  7. executor/apps/web/src/app/tools/page.tsx +1 −6
  8. executor/apps/web/src/components/app-shell.tsx +25 −89
  9. executor/apps/web/src/components/billing-view.tsx +6 −6
  10. executor/apps/web/src/components/members-view.tsx +40 −26
  11. executor/apps/web/src/lib/session-context.tsx +9 −6
executor/apps/web/src/app/approvals/page.tsx+1−6
@@ -1,10 +1,5 @@
1−import { AppShell } from "@/components/app-shell";
21 import { ApprovalsView } from "@/components/approvals-view";
32
43 export default function ApprovalsPage() {
5− return (
6− <AppShell>
7− <ApprovalsView />
8− </AppShell>
9− );
4+ return <ApprovalsView />;
105 }
executor/apps/web/src/app/billing/page.tsx+1−6
@@ -1,10 +1,5 @@
1−import { AppShell } from "@/components/app-shell";
21 import { BillingView } from "@/components/billing-view";
32
43 export default function BillingPage() {
5− return (
6− <AppShell>
7− <BillingView />
8− </AppShell>
9− );
4+ return <BillingView />;
105 }
executor/apps/web/src/app/layout.tsx+2−1
@@ -1,6 +1,7 @@
11 import type { Metadata } from "next";
22 import { JetBrains_Mono, Inter } from "next/font/google";
33 import { Toaster } from "sonner";
4+import { AppShell } from "@/components/app-shell";
45 import { AppConvexProvider } from "@/lib/convex-provider";
56 import { QueryProvider } from "@/lib/query-provider";
67 import { SessionProvider } from "@/lib/session-context";
@@ -34,7 +35,7 @@ export default function RootLayout({ · 27 unmodified lines
3435 <AppConvexProvider>
3536 <QueryProvider>
3637 <SessionProvider>
37− {children}
38+ <AppShell>{children}</AppShell>
3839 </SessionProvider>
3940 </QueryProvider>
4041 </AppConvexProvider>
executor/apps/web/src/app/members/page.tsx+1−6
@@ -1,10 +1,5 @@
1−import { AppShell } from "@/components/app-shell";
21 import { MembersView } from "@/components/members-view";
32
43 export default function MembersPage() {
5− return (
6− <AppShell>
7− <MembersView />
8− </AppShell>
9− );
4+ return <MembersView />;
105 }
executor/apps/web/src/app/page.tsx+1−6
@@ -1,10 +1,5 @@
1−import { AppShell } from "@/components/app-shell";
21 import { DashboardView } from "@/components/dashboard-view";
32
43 export default function DashboardPage() {
5− return (
6− <AppShell>
7− <DashboardView />
8− </AppShell>
9− );
4+ return <DashboardView />;
105 }
executor/apps/web/src/app/tasks/page.tsx+3−6
@@ -1,13 +1,10 @@
11 import { Suspense } from "react";
2−import { AppShell } from "@/components/app-shell";
32 import { TasksView } from "@/components/tasks-view";
43
54 export default function TasksPage() {
65 return (
7− <AppShell>
8− <Suspense>
9− <TasksView />
10− </Suspense>
11− </AppShell>
6+ <Suspense>
7+ <TasksView />
8+ </Suspense>
129 );
1310 }
executor/apps/web/src/app/tools/page.tsx+1−6
@@ -1,10 +1,5 @@
1−import { AppShell } from "@/components/app-shell";
21 import { ToolsView } from "@/components/tools-view";
32
43 export default function ToolsPage() {
5− return (
6− <AppShell>
7− <ToolsView />
8− </AppShell>
9− );
4+ return <ToolsView />;
105 }
executor/apps/web/src/components/app-shell.tsx+25−89
@@ -84,8 +84,6 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 83 unmodified lines
8484 context,
8585 mode,
8686 clientConfig,
87− organizations,
88− selectedOrganizationId,
8987 workspaces,
9088 switchWorkspace,
9189 creatingWorkspace,
@@ -99,25 +97,9 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 7 unmodified lines
9997 const activeWorkspace = context
10098 ? workspaces.find((workspace) => workspace.id === context.workspaceId)
10199 : null;
102− const currentOrganizationId = activeWorkspace?.organizationId ?? selectedOrganizationId;
103− const activeOrganization =
104− currentOrganizationId
105− ? organizations.find((organization) => organization.id === currentOrganizationId) ?? null
106− : null;
107100 const activeWorkspaceLabel = activeWorkspace?.name ?? (mode === "guest" ? "Guest Workspace" : "Select workspace");
108− const activeOrganizationLabel = activeOrganization?.name
109− ?? (activeWorkspace?.kind === "organization" ? "Organization" : mode === "guest" ? "Guest" : "Personal");
110101 const activeWorkspaceInitial = (activeWorkspaceLabel[0] ?? "W").toUpperCase();
111102
112− const organizationGroups = organizations
113− .map((organization) => ({
114− organization,
115− workspaces: workspaces.filter((workspace) => workspace.organizationId === organization.id),
116− }))
117− .filter((group) => group.workspaces.length > 0);
118− const personalWorkspaces = workspaces.filter((workspace) => workspace.organizationId === null);
119− const showOrganizationHeaders = organizationGroups.length > 1;
120−
121103 const openCreateWorkspace = () => {
122104 setCreateError(null);
123105 setNewWorkspaceName("");
@@ -172,9 +154,6 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 48 unmodified lines
172154 )}
173155 <span className="min-w-0">
174156 <span className="truncate block">{activeWorkspaceLabel}</span>
175− {mode === "workos" ? (
176− <span className="truncate block text-[10px] text-muted-foreground">{activeOrganizationLabel}</span>
177− ) : null}
178157 </span>
179158 </span>
180159 <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
@@ -184,75 +163,32 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 3 unmodified lines
184163 {mode === "workos"
185164 ? (
186165 <>
187− {organizationGroups.map((group, index) => (
188− <div key={group.organization.id}>
189− {showOrganizationHeaders ? (
190− <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
191− {group.organization.name}
192− </DropdownMenuLabel>
193− ) : null}
194− {group.workspaces.map((workspace) => {
195− const isActive = workspace.id === context?.workspaceId;
196− return (
197− <DropdownMenuItem
198− key={workspace.id}
199− onSelect={() => switchWorkspace(workspace.id)}
200− className="text-xs"
201− >
202− <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
203− {workspace.iconUrl ? (
204− <img
205− src={workspace.iconUrl}
206− alt={workspace.name}
207− className="mr-2 h-4 w-4 rounded-sm border border-border object-cover"
208− />
209− ) : (
210− <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
211− {(workspace.name[0] ?? "W").toUpperCase()}
212− </span>
213− )}
214− <span className="truncate">{workspace.name}</span>
215− </DropdownMenuItem>
216− );
217− })}
218− {showOrganizationHeaders && index < organizationGroups.length - 1 ? <DropdownMenuSeparator /> : null}
219− </div>
220− ))}
221−
222− {personalWorkspaces.length > 0 ? (
223− <>
224− {organizationGroups.length > 0 ? <DropdownMenuSeparator /> : null}
225− <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
226− Personal
227− </DropdownMenuLabel>
228− {personalWorkspaces.map((workspace) => {
229− const isActive = workspace.id === context?.workspaceId;
230− return (
231− <DropdownMenuItem
232− key={workspace.id}
233− onSelect={() => switchWorkspace(workspace.id)}
234− className="text-xs"
235− >
236− <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
237− {workspace.iconUrl ? (
238− <img
239− src={workspace.iconUrl}
240− alt={workspace.name}
241− className="mr-2 h-4 w-4 rounded-sm border border-border object-cover"
242− />
243− ) : (
244− <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
245− {(workspace.name[0] ?? "W").toUpperCase()}
246− </span>
247− )}
248− <span className="truncate">{workspace.name}</span>
249− </DropdownMenuItem>
250− );
251− })}
252− </>
253− ) : null}
166+ {workspaces.map((workspace) => {
167+ const isActive = workspace.id === context?.workspaceId;
168+ return (
169+ <DropdownMenuItem
170+ key={workspace.id}
171+ onSelect={() => switchWorkspace(workspace.id)}
172+ className="text-xs"
173+ >
174+ <Check className={cn("mr-2 h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
175+ {workspace.iconUrl ? (
176+ <img
177+ src={workspace.iconUrl}
178+ alt={workspace.name}
179+ className="mr-2 h-4 w-4 rounded-sm border border-border object-cover"
180+ />
181+ ) : (
182+ <span className="mr-2 h-4 w-4 rounded-sm border border-border bg-muted text-[9px] font-semibold flex items-center justify-center text-muted-foreground">
183+ {(workspace.name[0] ?? "W").toUpperCase()}
184+ </span>
185+ )}
186+ <span className="truncate">{workspace.name}</span>
187+ </DropdownMenuItem>
188+ );
189+ })}
254190
255− {organizationGroups.length === 0 && personalWorkspaces.length === 0 ? (
191+ {workspaces.length === 0 ? (
256192 <DropdownMenuItem disabled className="text-xs">
257193 No workspaces
258194 </DropdownMenuItem>
executor/apps/web/src/components/billing-view.tsx+6−6
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; · 7 unmodified lines
88 import { useSession } from "@/lib/session-context";
99
1010 export function BillingView() {
11− const { context, organizations, selectedOrganizationId, workspaces } = useSession();
11+ const { context, organizations, workspaces } = useSession();
1212 const [actionState, setActionState] = useState<"idle" | "running" | "error">("idle");
1313 const [actionMessage, setActionMessage] = useState<string | null>(null);
1414
@@ -18,8 +18,8 @@ export function BillingView() { · 3 unmodified lines
1818 const activeWorkspace = context
1919 ? workspaces.find((workspace) => workspace.id === context.workspaceId) ?? null
2020 : null;
21− const effectiveOrganizationId = derivedOrganizationId ?? selectedOrganizationId;
22− const hasLegacyOrganizationWorkspace = Boolean(activeWorkspace) && !effectiveOrganizationId;
21+ const effectiveOrganizationId = derivedOrganizationId;
22+ const hasLegacyOrganizationWorkspace = activeWorkspace?.kind === "organization" && !effectiveOrganizationId;
2323
2424 const activeOrganization = useMemo(
2525 () => organizations.find((organization) => organization.id === effectiveOrganizationId) ?? null,
@@ -40,7 +40,7 @@ export function BillingView() { · 14 unmodified lines
4040 <PageHeader title="Billing" description="Plan, seats, and subscription management" />
4141 <Card>
4242 <CardContent className="p-6 text-sm text-muted-foreground">
43− Select an organization from the switcher to manage billing.
43+ Select a workspace to manage billing.
4444 </CardContent>
4545 </Card>
4646 </div>
@@ -53,8 +53,8 @@ export function BillingView() { · 6 unmodified lines
5353 <PageHeader title="Billing" description="Plan, seats, and subscription management" />
5454 <Card>
5555 <CardContent className="p-6 text-sm text-muted-foreground">
56− Billing is waiting for an organization record for this workspace. The active workspace
57− does not have a linked `organizationId` yet.
56+ Billing is waiting for workspace provisioning to finish. This workspace is missing its
57+ internal organization link.
5858 </CardContent>
5959 </Card>
6060 </div>
executor/apps/web/src/components/members-view.tsx+40−26
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useMemo, useState } from "react";
3+import { useState } from "react";
44 import { useMutation, useQuery } from "convex/react";
55 import { Mail, UserMinus, Users } from "lucide-react";
66 import { PageHeader } from "@/components/page-header";
@@ -18,8 +18,6 @@ export function MembersView() { · 11 unmodified lines
1818 const {
1919 context,
2020 clientConfig,
21− organizations,
22− selectedOrganizationId,
2321 workspaces,
2422 } = useSession();
2523
@@ -30,8 +28,7 @@ export function MembersView() { · 4 unmodified lines
3028 ? workspaces.find((workspace) => workspace.id === context.workspaceId) ?? null
3129 : null;
3230 const typedOrganizationId = derivedOrganizationId;
33− const effectiveOrganizationId = typedOrganizationId ?? selectedOrganizationId;
34− const hasLegacyOrganizationWorkspace = Boolean(activeWorkspace) && !typedOrganizationId;
31+ const hasLegacyOrganizationWorkspace = activeWorkspace?.kind === "organization" && !typedOrganizationId;
3532
3633 const members = useQuery(
3734 convexApi.organizationMembers.list,
@@ -43,6 +40,13 @@ export function MembersView() { · 5 unmodified lines
4340 const updateRole = useMutation(convexApi.organizationMembers.updateRole);
4441 const updateBillable = useMutation(convexApi.organizationMembers.updateBillable);
4542 const removeMember = useMutation(convexApi.organizationMembers.remove);
43+ const listInvites = useQuery(
44+ convexApi.invites.list,
45+ typedOrganizationId
46+ ? { organizationId: typedOrganizationId, sessionId: context?.sessionId ?? undefined }
47+ : "skip",
48+ );
49+ const createInvite = useMutation(convexApi.invites.create);
4650
4751 const [inviteEmail, setInviteEmail] = useState("");
4852 const [inviteRole, setInviteRole] = useState<Role>("member");
@@ -50,20 +54,14 @@ export function MembersView() { · 1 unmodified line
5054 const [inviteMessage, setInviteMessage] = useState<string | null>(null);
5155 const [busyMemberAccountId, setBusyMemberAccountId] = useState<string | null>(null);
5256
53− const activeOrganization = useMemo(
54− () => organizations.find((organization) => organization.id === effectiveOrganizationId) ?? null,
55− [organizations, effectiveOrganizationId],
56− );
57−
58− const canManageMembers = activeOrganization
59− ? activeOrganization.role === "owner" || activeOrganization.role === "admin"
60− : false;
61− const canManageBilling = activeOrganization
62− ? activeOrganization.role === "owner" || activeOrganization.role === "admin" || activeOrganization.role === "billing_admin"
63− : false;
64−
6557 const memberItems = members?.items ?? [];
66− const inviteItems: Array<{ id: string; email: string; role: string; status: string }> = [];
58+ const inviteItems = listInvites?.items ?? [];
59+ const actorMembership = memberItems.find((member) =>
60+ context?.accountId ? String(member.accountId) === context.accountId : false,
61+ );
62+ const actorRole = actorMembership?.role ?? null;
63+ const canManageMembers = actorRole === "owner" || actorRole === "admin";
64+ const canManageBilling = actorRole === "owner" || actorRole === "admin" || actorRole === "billing_admin";
6765
6866 const submitInvite = async () => {
6967 if (!typedOrganizationId) {
@@ -72,10 +70,17 @@ export function MembersView() { · 2 unmodified lines
7270 setInviteState("sending");
7371 setInviteMessage(null);
7472 try {
75− await new Promise((resolve) => setTimeout(resolve, 300));
76− const deliveryProvider = clientConfig?.invitesProvider ?? "local";
77− setInviteState("failed");
78− setInviteMessage(`Invite creation via ${deliveryProvider} is not wired yet on backend.`);
73+ const result = await createInvite({
74+ organizationId: typedOrganizationId,
75+ email: inviteEmail.trim(),
76+ role: inviteRole,
77+ workspaceId: activeWorkspace?.docId ?? undefined,
78+ sessionId: context?.sessionId ?? undefined,
79+ });
80+ setInviteState("sent");
81+ const deliveryProvider = result.delivery.provider;
82+ setInviteMessage(`Invite ${result.delivery.state} via ${deliveryProvider}.`);
83+ setInviteEmail("");
7984 } catch (error) {
8085 setInviteState("failed");
8186 setInviteMessage(error instanceof Error ? error.message : "Failed to send invite");
@@ -88,7 +93,7 @@ export function MembersView() { · 6 unmodified lines
8893 <PageHeader title="Members" description="Manage organization membership and invites" />
8994 <Card>
9095 <CardContent className="p-6 text-sm text-muted-foreground">
91− Select an organization from the switcher to manage members.
96+ Select a workspace to manage members.
9297 </CardContent>
9398 </Card>
9499 </div>
@@ -101,8 +106,8 @@ export function MembersView() { · 6 unmodified lines
101106 <PageHeader title="Members" description="Manage organization membership and invites" />
102107 <Card>
103108 <CardContent className="p-6 text-sm text-muted-foreground">
104− Member management is waiting for an organization record for this workspace. The active
105− workspace does not have a linked `organizationId` yet.
109+ Member management is waiting for workspace provisioning to finish. This workspace is
110+ missing its internal organization link.
106111 </CardContent>
107112 </Card>
108113 </div>
@@ -166,7 +171,7 @@ export function MembersView() { · 57 unmodified lines
166171 <CardHeader>
167172 <CardTitle className="text-sm flex items-center gap-2">
168173 <Users className="h-4 w-4" />
169− Organization Members
174+ Workspace Members
170175 </CardTitle>
171176 </CardHeader>
172177 <CardContent className="space-y-3">
@@ -189,6 +194,9 @@ export function MembersView() { · 16 unmodified lines
189194 onChange={async (event) => {
190195 setBusyMemberAccountId(member.accountId);
191196 try {
197+ if (!typedOrganizationId) {
198+ return;
199+ }
192200 await updateRole({
193201 organizationId: typedOrganizationId,
194202 accountId: member.accountId,
@@ -215,6 +223,9 @@ export function MembersView() { · 20 unmodified lines
215223 onClick={async () => {
216224 setBusyMemberAccountId(member.accountId);
217225 try {
226+ if (!typedOrganizationId) {
227+ return;
228+ }
218229 await updateBillable({
219230 organizationId: typedOrganizationId,
220231 accountId: member.accountId,
@@ -237,6 +248,9 @@ export function MembersView() { · 16 unmodified lines
237248 onClick={async () => {
238249 setBusyMemberAccountId(member.accountId);
239250 try {
251+ if (!typedOrganizationId) {
252+ return;
253+ }
240254 await removeMember({
241255 organizationId: typedOrganizationId,
242256 accountId: member.accountId,
executor/apps/web/src/lib/session-context.tsx+9−6
@@ -20,8 +20,8 @@ interface SessionState { · 19 unmodified lines
2020 loading: boolean;
2121 error: string | null;
2222 clientConfig: {
23− authProviderMode: "workos" | "local";
24− invitesProvider: "workos" | "local";
23+ authProviderMode: string;
24+ invitesProvider: string;
2525 features: {
2626 organizations: boolean;
2727 billing: boolean;
@@ -40,6 +40,7 @@ interface SessionState { · 12 unmodified lines
4040 switchOrganization: (organizationId: string | null) => void;
4141 workspaces: Array<{
4242 id: string;
43+ docId: Id<"workspaces"> | null;
4344 name: string;
4445 kind: "organization" | "personal" | "anonymous";
4546 organizationId: Id<"organizations"> | null;
@@ -236,7 +237,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 190 unmodified lines
236237 useEffect(() => {
237238 if (
238239 !workosEnabled
239− || account !== null
240+ || account === undefined
240241 || bootstrappingWorkos
241242 || workosBootstrapAttempted
242243 ) {
@@ -333,7 +334,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 90 unmodified lines
333334 setCreatingWorkspace(true);
334335 setError(null);
335336 try {
336− let iconStorageId: string | undefined;
337+ let iconStorageId: Id<"_storage"> | undefined;
337338
338339 if (iconFile) {
339340 const uploadUrl = await generateWorkspaceIconUploadUrl({
@@ -356,7 +357,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 16 unmodified lines
356357 if (!json.storageId) {
357358 throw new Error("Upload did not return a storage id");
358359 }
359− iconStorageId = json.storageId;
360+ iconStorageId = json.storageId as Id<"_storage">;
360361 }
361362
362363 const created = await createWorkspaceMutation({
@@ -423,8 +424,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 60 unmodified lines
423424 );
424425 const workspaceOptions = useMemo(() => {
425426 if (mode === "workos" && workspaces) {
426− return workspaces.map((workspace) => ({
427+ return workspaces.map((workspace): SessionState["workspaces"][number] => ({
427428 id: workspace.runtimeWorkspaceId,
429+ docId: workspace._id,
428430 name: workspace.name,
429431 kind:
430432 workspace.kind === "organization" || workspace.kind === "org"
@@ -441,6 +443,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 10 unmodified lines
441443 return [
442444 {
443445 id: guestContext.workspaceId,
446+ docId: null,
444447 name: "Guest Workspace",
445448 kind: "anonymous" as const,
446449 organizationId: null,