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
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
'use client';

import { FormDialog } from "@/components/form-dialog";
import { InputField } from "@/components/form-fields";
import { ProjectCard } from "@/components/project-card";
import { useRouter } from "@/components/router";
import { SearchBar } from "@/components/search-bar";
import { AdminOwnedProject, Team, useUser } from "@stackframe/stack";
import { AdminOwnedProject, StackAdminApp, 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 { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import { Button, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Typography, toast } from "@stackframe/stack-ui";
import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Skeleton, Spinner, Typography, toast } from "@stackframe/stack-ui";
import { Settings } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, Suspense } from "react";
import * as yup from "yup";


export default function PageClient(props: { inviteUser: (origin: string, teamId: string, email: string) => Promise<void> }) {
export default function PageClient() {
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const rawProjects = user.useOwnedProjects();
const teams = user.useTeams();
Expand Down Expand Up @@ -107,7 +104,7 @@ export default function PageClient(props: { inviteUser: (origin: string, teamId:
{team && (
<TeamAddUserDialog
team={team}
onSubmit={(email) => props.inviteUser(window.location.origin, team.id, email)}
adminApp={projects[0].app}
/>
)}
</div>
Expand All @@ -127,43 +124,208 @@ const inviteFormSchema = yupObject({
email: strictEmailSchema("Please enter a valid email address").defined(),
});


function TeamAddUserDialog(props: {
team: Team,
onSubmit: (email: string) => Promise<void>,
adminApp: StackAdminApp<false>,
}) {
const onSubmit = async (values: yup.InferType<typeof inviteFormSchema>) => {
const [users, admins] = await Promise.all([
props.team.listUsers(),
props.team.getItem("dashboard_admins"),
]);
if (users.length + 1 > admins.quantity) {
alert("You have reached the maximum number of dashboard admins. Please upgrade your plan to add more admins.");
const checkoutUrl = await props.team.createCheckoutUrl({
productId: "team",
returnUrl: window.location.href,
});
window.location.assign(checkoutUrl);
return "prevent-close-and-prevent-reset";
}
await props.onSubmit(values.email);
toast({ variant: "success", title: "Team invitation sent" });
};
const [open, setOpen] = useState(false);

return <FormDialog
title={`Invite a new user to ${JSON.stringify(props.team.displayName)}`}
formSchema={inviteFormSchema}
okButton={{ label: "Invite" }}
onSubmit={onSubmit}
trigger={
return (
<>
<Button
variant="ghost"
size="icon"
aria-label={`Invite teammates to ${props.team.displayName}`}
title={`Invite teammates to ${props.team.displayName}`}
onClick={() => setOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>

<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Invite a new user to {props.team.displayName}</DialogTitle>
</DialogHeader>
<Suspense fallback={<TeamAddUserDialogContentSkeleton />}>
<TeamAddUserDialogContent
teamId={props.team.id}
adminApp={props.adminApp}
onClose={() => setOpen(false)}
/>
</Suspense>
</DialogContent>
</Dialog>
</>
);
}

function TeamAddUserDialogContent(props: {
teamId: string,
adminApp: StackAdminApp<false>,
onClose: () => void,
}) {
const team = props.adminApp.useTeam(props.teamId)!;
const invitations = team.useInvitations();
const users = team.useUsers();
const admins = team.useItem("dashboard_admins");

const [email, setEmail] = useState("");
const [formError, setFormError] = useState<string | null>(null);

const activeSeats = users.length + invitations.length;
const seatLimit = admins.quantity;
const atCapacity = activeSeats >= seatLimit;

const handleInvite = async () => {
if (atCapacity) {
return;
}

try {
setFormError(null);
const values = await inviteFormSchema.validate({ email: email.trim() });
await team.inviteUser({ email: values.email });
toast({ variant: "success", title: "Team invitation sent" });
setEmail("");
} catch (error) {
if (error instanceof yup.ValidationError) {
setFormError(error.errors[0] ?? error.message);
} else {
const message = error instanceof Error ? error.message : "Unknown error";
toast({ variant: "destructive", title: "Failed to send invitation", description: message });
}
}
render={(form) => <InputField control={form.control} name="email" placeholder="Email" />}
/>;
};

const handleUpgrade = async () => {
try {
const checkoutUrl = await team.createCheckoutUrl({
productId: "team",
returnUrl: window.location.href,
});
window.location.assign(checkoutUrl);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
toast({ variant: "destructive", title: "Failed to start upgrade", description: message });
};
};

return (
<>
<div className="space-y-4 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>
</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}
onChange={(event) => {
setEmail(event.target.value);
if (formError) {
setFormError(null);
}
}}
placeholder="Email"
type="email"
autoFocus
/>
{formError && (
<Typography type="label" className="text-destructive">
{formError}
</Typography>
)}
</div>

<div className="space-y-2">
<Typography type="label">Pending invitations</Typography>
{invitations.length === 0 ? (
<Typography variant="secondary">None</Typography>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div className="flex flex-col">
<Typography>{invitation.recipientEmail ?? "Pending invitation"}</Typography>
</div>
<Button
variant="ghost"
size="sm"
onClick={invitation.revoke}
>
Revoke
</Button>
</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 ? (
<Button onClick={handleUpgrade} variant="default">
Upgrade plan
</Button>
) : (
<Button onClick={handleInvite}>
Invite
</Button>
)}
</DialogFooter>
</>
);
}

function TeamAddUserDialogContentSkeleton() {
return (
<>
<div className="space-y-4 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>
<div className="stack-scope text-md text-zinc-600 dark:text-zinc-400">
<Skeleton className="h-4 w-16" />
</div>
</div>

<div className="space-y-2">
<Input
disabled
placeholder="Email"
type="email"
autoFocus
/>
</div>

<div className="space-y-2">
<Typography type="label">Pending invitations</Typography>
<Skeleton className="h-8 w-full" />
</div>
</div>

<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button variant="outline" disabled>
Close
</Button>
<Button disabled>
Invite
</Button>
</DialogFooter>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,6 @@ export const metadata = {
title: "Projects",
};

// internal users don't have team permission to invite users, so we use server function instead
async function inviteUser(origin: string, teamId: string, email: string) {
"use server";
const team = await stackServerApp.getTeam(teamId);
if (!team) {
throw new Error("Team not found");
}
await team.inviteUser({
email,
callbackUrl: new URL(stackServerApp.urls.teamInvitation, origin).toString()
});
}

export default async function Page() {
const user = await stackServerApp.getUser({ or: "redirect" });
const projects = await user.listOwnedProjects();
Expand All @@ -39,8 +26,7 @@ export default async function Page() {
backgroundSize: '10px 10px',
}}
/>

<PageClient inviteUser={inviteUser} />
<PageClient />
<Footer />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
expiresAt: new Date(crud.expires_at_millis),
revoke: async () => {
await this._interface.revokeServerTeamInvitation(crud.id, crud.team_id);
await this._serverTeamInvitationsCache.refresh([crud.team_id]);
},
};
}
Expand Down