diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts new file mode 100644 index 0000000000..5da92edd66 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -0,0 +1,39 @@ +"use server"; +import { stackServerApp } from "@/stack"; + +export async function revokeInvitation(teamId: string, invitationId: string) { + "use server"; + const user = await stackServerApp.getUser(); + const team = await user?.getTeam(teamId); + if (!team) { + throw new Error("Team not found"); + } + const invite = await team.listInvitations().then(invites => invites.find(invite => invite.id === invitationId)); + if (!invite) { + throw new Error("Invitation not found"); + } + await invite.revoke(); +} + +export async function listInvitations(teamId: string) { + const user = await stackServerApp.getUser(); + const team = await user?.getTeam(teamId); + if (!team) { + throw new Error("Team not found"); + } + const invitations = await team.listInvitations(); + return invitations.map(invite => ({ + id: invite.id, + recipientEmail: invite.recipientEmail, + expiresAt: invite.expiresAt, + })); +} + +export async function inviteUser(teamId: string, email: string, callbackUrl: string) { + const user = await stackServerApp.getUser(); + const team = await user?.getTeam(teamId); + if (!team) { + throw new Error("Team not found"); + } + await team.inviteUser({ email, callbackUrl }); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index f4e35d9fcc..51da8ff45a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -6,12 +6,13 @@ import { SearchBar } from "@/components/search-bar"; import { AdminOwnedProject, Team, useUser } from "@stackframe/stack"; import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays"; -import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Typography, toast } from "@stackframe/stack-ui"; import { Settings } from "lucide-react"; -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; +import { inviteUser, listInvitations, revokeInvitation } from "./actions"; export default function PageClient() { const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); @@ -102,9 +103,7 @@ export default function PageClient() { {teamId ? teamIdMap.get(teamId) : "No Team"} {team && ( - + )}
@@ -124,9 +123,7 @@ const inviteFormSchema = yupObject({ }); -function TeamAddUserDialog(props: { - team: Team, -}) { +function TeamAddUserDialog(props: { team: Team }) { const [open, setOpen] = useState(false); return ( @@ -148,7 +145,7 @@ function TeamAddUserDialog(props: { }> setOpen(false)} /> @@ -159,39 +156,51 @@ function TeamAddUserDialog(props: { } function TeamAddUserDialogContent(props: { - teamId: string, + team: Team, onClose: () => void, }) { - const [email, setEmail] = useState(""); - const [formError, setFormError] = useState(null); + const [invitations, setInvitations] = useState>>(); + + const fetchInvitations = useCallback(async () => { + const invitations = await listInvitations(props.team.id); + setInvitations(invitations); + }, [props.team.id]); - const user = useUser(); - const team = user?.useTeam(props.teamId); - if (!team) { - setTimeout(() => { - props.onClose(); + useEffect(() => { + let canceled = false; + runAsynchronously(async () => { + const invitations = await listInvitations(props.team.id); + if (!canceled) { + setInvitations(invitations); + } }); - return null; - } - //const invitations = team.useInvitations(); - const users = team.useUsers(); - const admins = team.useItem("dashboard_admins"); + return () => { + canceled = true; + }; + }, [props.team.id]); + + const users = props.team.useUsers(); + const admins = props.team.useItem("dashboard_admins"); + + const [email, setEmail] = useState(""); + const [formError, setFormError] = useState(null); - //const activeSeats = users.length + invitations.length; + const activeSeats = users.length + (invitations?.length ?? 0); const seatLimit = admins.quantity; - //const atCapacity = activeSeats >= seatLimit; + const atCapacity = activeSeats >= seatLimit; const handleInvite = async () => { - //if (atCapacity) { - // return; - //} + if (atCapacity) { + return; + } try { setFormError(null); const values = await inviteFormSchema.validate({ email: email.trim() }); - await team.inviteUser({ email: values.email }); + await inviteUser(props.team.id, values.email, window.location.origin); toast({ variant: "success", title: "Team invitation sent" }); setEmail(""); + await fetchInvitations(); } catch (error) { if (error instanceof yup.ValidationError) { setFormError(error.errors[0] ?? error.message); @@ -204,7 +213,7 @@ function TeamAddUserDialogContent(props: { const handleUpgrade = async () => { try { - const checkoutUrl = await team.createCheckoutUrl({ + const checkoutUrl = await props.team.createCheckoutUrl({ productId: "team", returnUrl: window.location.href, }); @@ -218,16 +227,17 @@ function TeamAddUserDialogContent(props: { return ( <>
- {/*
+
Dashboard admin seats {activeSeats}/{seatLimit} - */} - {/*{atCapacity && ( + +
+ {atCapacity && ( You are at capacity. Upgrade your plan to add more admins. - )}*/} + )}
{formError && ( @@ -248,13 +259,13 @@ function TeamAddUserDialogContent(props: { )}
- {/*
+
Pending invitations - {invitations.length === 0 ? ( + {invitations?.length === 0 ? ( None ) : (
- {invitations.map((invitation) => ( + {invitations?.map((invitation) => (
{ + await revokeInvitation(props.team.id, invitation.id); + await fetchInvitations(); + }} > Revoke
))} + {!invitations && ( + + )}
)} -
*/} +
- {/*atCapacity ? ( + {atCapacity ? ( - ) : */ - ( - - )} + ) : ( + + )} );