fix team invitations with server actions (#983)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
    - Invite users to teams by email with customizable callback URLs.
    - View and revoke pending invitations from the team management UI.
    - Track and enforce team seat capacity, disabling invites when full.
- **Improvements**
    - Upgrade flow now redirects to the checkout URL from the team UI.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2025-10-30 09:59:09 -07:00 committed by GitHub
parent 5e0deef560
commit 40d878d304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 45 deletions

View File

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

View File

@ -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"}
</Typography>
{team && (
<TeamAddUserDialog
team={team}
/>
<TeamAddUserDialog team={team} />
)}
</div>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
@ -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: {
</DialogHeader>
<Suspense fallback={<TeamAddUserDialogContentSkeleton />}>
<TeamAddUserDialogContent
teamId={props.team.id}
team={props.team}
onClose={() => setOpen(false)}
/>
</Suspense>
@ -159,39 +156,51 @@ function TeamAddUserDialog(props: {
}
function TeamAddUserDialogContent(props: {
teamId: string,
team: Team,
onClose: () => void,
}) {
const [invitations, setInvitations] = useState<Awaited<ReturnType<typeof listInvitations>>>();
const fetchInvitations = useCallback(async () => {
const invitations = await listInvitations(props.team.id);
setInvitations(invitations);
}, [props.team.id]);
useEffect(() => {
let canceled = false;
runAsynchronously(async () => {
const invitations = await listInvitations(props.team.id);
if (!canceled) {
setInvitations(invitations);
}
});
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<string | null>(null);
const user = useUser();
const team = user?.useTeam(props.teamId);
if (!team) {
setTimeout(() => {
props.onClose();
});
return null;
}
//const invitations = team.useInvitations();
const users = team.useUsers();
const admins = team.useItem("dashboard_admins");
//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 (
<>
<div className="space-y-4 py-2">
{/*<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Typography type="label">Dashboard admin seats</Typography>
<Typography variant="secondary">
{activeSeats}/{seatLimit}
</Typography>*/}
{/*{atCapacity && (
</Typography>
</div>
{atCapacity && (
<Typography variant="secondary" className="text-destructive">
You are at capacity. Upgrade your plan to add more admins.
</Typography>
)}*/}
)}
<div className="space-y-2">
<Input
value={email}
@ -239,6 +249,7 @@ function TeamAddUserDialogContent(props: {
}}
placeholder="Email"
type="email"
disabled={atCapacity}
autoFocus
/>
{formError && (
@ -248,13 +259,13 @@ function TeamAddUserDialogContent(props: {
)}
</div>
{/*<div className="space-y-2">
<div className="space-y-2">
<Typography type="label">Pending invitations</Typography>
{invitations.length === 0 ? (
{invitations?.length === 0 ? (
<Typography variant="secondary">None</Typography>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{invitations.map((invitation) => (
{invitations?.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
@ -265,31 +276,36 @@ function TeamAddUserDialogContent(props: {
<Button
variant="ghost"
size="sm"
onClick={invitation.revoke}
onClick={async () => {
await revokeInvitation(props.team.id, invitation.id);
await fetchInvitations();
}}
>
Revoke
</Button>
</div>
))}
{!invitations && (
<Skeleton className="h-8 w-full" />
)}
</div>
)}
</div>*/}
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button variant="outline" onClick={props.onClose}>
Close
</Button>
{/*atCapacity ? (
{atCapacity ? (
<Button onClick={handleUpgrade} variant="default">
Upgrade plan
</Button>
) : */
(
<Button onClick={handleInvite}>
Invite
</Button>
)}
) : (
<Button onClick={handleInvite}>
Invite
</Button>
)}
</DialogFooter>
</>
);