Add team settings pages and sections for dashboard account settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Developing-Gamer 2026-05-27 12:47:54 -07:00
parent f245ea55da
commit 10688411be
9 changed files with 574 additions and 0 deletions

View File

@ -0,0 +1,55 @@
'use client';
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Team, useUser } from "@stackframe/stack";
import { Section } from "../section";
export function LeaveTeamSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const [leaving, setLeaving] = useState(false);
return (
<Section
title="Leave Team"
description="Leave this team and remove your team profile"
>
<div className="w-full md:w-[350px] flex flex-col items-stretch md:items-end">
{!leaving ? (
<Button
variant="outline"
onClick={() => setLeaving(true)}
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl px-4 py-2 w-full transition-colors duration-150 text-red-500 hover:text-red-600"
>
Leave team
</Button>
) : (
<div className="flex flex-col gap-3 w-full">
<span className="text-xs font-semibold text-red-500 leading-relaxed text-left md:text-right">
Are you sure you want to leave the team? You will lose access to all of its resources.
</span>
<div className="flex gap-2 w-full">
<Button
variant="destructive"
onClick={async () => {
await user.leaveTeam(props.team);
window.location.reload();
}}
className="rounded-xl flex-1 text-xs"
>
Leave
</Button>
<Button
variant="outline"
onClick={() => setLeaving(false)}
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl flex-1 text-xs"
>
Cancel
</Button>
</div>
</div>
)}
</div>
</Section>
);
}

View File

@ -0,0 +1,71 @@
'use client';
import { HexclaveAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { CreateApiKeyDialog, ShowApiKeyDialog } from "../supporting/api-key-dialogs";
import { ApiKeyTable } from "../supporting/api-key-table";
import { useStackApp, useUser, Team } from "@stackframe/stack";
import { Section } from "../section";
export function TeamApiKeysSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const team = user.useTeam(props.team.id);
const stackApp = useStackApp();
const project = stackApp.useProject();
if (!team) {
throw new HexclaveAssertionError("Team not found");
}
const teamApiKeysEnabled = project.config.allowTeamApiKeys;
const manageApiKeysPermission = user.usePermission(props.team, '$manage_api_keys');
if (!manageApiKeysPermission || !teamApiKeysEnabled) {
return null;
}
return <TeamApiKeysSectionInner team={props.team} />;
}
function TeamApiKeysSectionInner(props: { team: Team }) {
const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(false);
const [returnedApiKey, setReturnedApiKey] = useState<any | null>(null);
const apiKeys = props.team.useApiKeys();
const CreateDialog = CreateApiKeyDialog<"team">;
const ShowDialog = ShowApiKeyDialog<"team">;
return (
<>
<Section
title="API Keys"
description="API keys grant programmatic access to your team."
>
<Button
onClick={() => setIsNewApiKeyDialogOpen(true)}
className="bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 rounded-xl px-4 py-2 w-full md:w-auto transition-colors duration-150"
>
Create API Key
</Button>
</Section>
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm">
<ApiKeyTable apiKeys={apiKeys as any} />
</div>
<CreateDialog
open={isNewApiKeyDialogOpen}
onOpenChange={setIsNewApiKeyDialogOpen}
onKeyCreated={setReturnedApiKey}
createApiKey={async (data) => {
const apiKey = await props.team.createApiKey(data as any);
return apiKey as any;
}}
/>
<ShowDialog
apiKey={returnedApiKey}
onClose={() => setReturnedApiKey(null)}
/>
</>
);
}

View File

@ -0,0 +1,101 @@
'use client';
import { yupResolver } from "@hookform/resolvers/yup";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { useStackApp, useUser } from "@stackframe/stack";
import { PageLayout } from "../page-layout";
import { Section } from "../section";
import { Warning } from "@phosphor-icons/react";
export function TeamCreationPage(props?: {
mockMode?: boolean,
}) {
const teamCreationSchema = yupObject({
displayName: yupString().defined().nonEmpty("Please enter a team name"),
});
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(teamCreationSchema)
});
const app = useStackApp();
const project = app.useProject();
const user = useUser({ or: props?.mockMode ? 'return-null' : 'redirect' });
const navigate = app.useNavigate();
const [loading, setLoading] = useState(false);
// In mock mode, show that team creation is disabled
if (props?.mockMode) {
return (
<PageLayout>
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex gap-4 items-center">
<Warning className="h-5 w-5 text-zinc-500 shrink-0" />
<span className="text-sm text-muted-foreground font-medium">Team creation is disabled in demo mode.</span>
</div>
</PageLayout>
);
}
if (!project.config.clientTeamCreationEnabled) {
return (
<PageLayout>
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex gap-4 items-center">
<Warning className="h-5 w-5 text-zinc-500 shrink-0" />
<span className="text-sm text-muted-foreground font-medium">Team creation is not enabled for this project.</span>
</div>
</PageLayout>
);
}
const onSubmit = async (data: yup.InferType<typeof teamCreationSchema>) => {
setLoading(true);
let team;
try {
team = await user?.createTeam({ displayName: data.displayName });
} finally {
setLoading(false);
}
if (team) {
navigate(`#team-${team.id}`);
}
};
return (
<PageLayout>
<Section title="Create a Team" description="Enter a display name for your new team">
<form
onSubmit={e => runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))}
noValidate
className="flex flex-col gap-2 w-full md:w-[350px]"
>
<div className="flex gap-2 w-full">
<Input
id="displayName"
type="text"
{...register("displayName")}
placeholder="Team name"
className="bg-white dark:bg-zinc-900 border-black/[0.08] dark:border-white/[0.08] rounded-xl px-3 py-2 shadow-sm focus-visible:ring-black/[0.06] dark:focus-visible:ring-white/[0.06] flex-1"
/>
<Button
type="submit"
loading={loading}
className="bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 rounded-xl px-4"
>
Create
</Button>
</div>
{errors.displayName && (
<span className="text-red-500 text-xs font-medium">{errors.displayName.message?.toString()}</span>
)}
</form>
</Section>
</PageLayout>
);
}

View File

@ -0,0 +1,26 @@
'use client';
import { Team, useUser } from "@stackframe/stack";
import { EditableText } from "../editable-text";
import { Section } from "../section";
export function TeamDisplayNameSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const updateTeamPermission = user.usePermission(props.team, '$update_team');
if (!updateTeamPermission) {
return null;
}
return (
<Section
title="Team display name"
description="Change the display name of your team"
>
<EditableText
value={props.team.displayName}
onSave={async (newDisplayName) => await props.team.update({ displayName: newDisplayName })}
/>
</Section>
);
}

View File

@ -0,0 +1,162 @@
'use client';
import { yupResolver } from "@hookform/resolvers/yup";
import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Trash } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { Team, useUser } from "@stackframe/stack";
import { Section } from "../section";
export function TeamMemberInvitationSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const inviteMemberPermission = user.usePermission(props.team, '$invite_members');
if (!inviteMemberPermission) {
return null;
}
return <MemberInvitationSectionInner team={props.team} />;
}
function MemberInvitationsSectionInvitationsList(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const invitationsToShow = props.team.useInvitations();
const removeMemberPermission = user.usePermission(props.team, '$remove_members');
return (
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex flex-col gap-5 mt-4">
<div>
<h3 className="font-semibold text-base text-foreground leading-snug">
Outstanding Invitations
</h3>
<p className="text-muted-foreground text-sm mt-1 leading-relaxed">
Sent invitations that are currently pending.
</p>
</div>
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-zinc-50/50 dark:bg-zinc-900/50">
<TableRow className="border-b border-black/[0.06] dark:border-white/[0.06]">
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Email</TableHead>
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Expires</TableHead>
<TableHead className="py-3 px-4 text-right w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitationsToShow.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-6 text-muted-foreground italic text-sm">
No outstanding invitations
</TableCell>
</TableRow>
) : (
invitationsToShow.map((invitation) => (
<TableRow key={invitation.id} className="border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 hover:bg-zinc-50/30 dark:hover:bg-zinc-900/30 transition-colors duration-150">
<TableCell className="py-3 px-4 text-sm font-medium text-foreground/90">
{invitation.recipientEmail}
</TableCell>
<TableCell className="py-3 px-4 text-xs text-muted-foreground/80">
{new Date(invitation.expiresAt).toLocaleString()}
</TableCell>
<TableCell className="py-3 px-4 text-right">
{removeMemberPermission && (
<Button
onClick={async () => await invitation.revoke()}
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-red-500 hover:bg-zinc-100 dark:hover:bg-zinc-900 rounded-lg transition-colors"
>
<Trash className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
function MemberInvitationSectionInner(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const readMemberPermission = user.usePermission(props.team, '$read_members');
const invitationSchema = yupObject({
email: strictEmailSchema('Please enter a valid email address').defined().nonEmpty('Please enter an email address'),
});
const { register, handleSubmit, formState: { errors }, watch, reset } = useForm({
resolver: yupResolver(invitationSchema)
});
const [loading, setLoading] = useState(false);
const [invitedEmail, setInvitedEmail] = useState<string | null>(null);
const onSubmit = async (data: yup.InferType<typeof invitationSchema>) => {
setLoading(true);
try {
await props.team.inviteUser({ email: data.email });
setInvitedEmail(data.email);
reset();
} finally {
setLoading(false);
}
};
useEffect(() => {
setInvitedEmail(null);
}, [watch('email')]);
return (
<>
<Section
title="Invite member"
description="Invite a user to your team through email"
>
<form
onSubmit={e => runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))}
noValidate
className="flex flex-col gap-2 w-full md:w-[350px]"
>
<div className="flex gap-2 w-full">
<Input
placeholder="Email address"
{...register("email")}
className="bg-white dark:bg-zinc-900 border-black/[0.08] dark:border-white/[0.08] rounded-xl px-3 py-2 shadow-sm focus-visible:ring-black/[0.06] dark:focus-visible:ring-white/[0.06] flex-1"
/>
<Button
type="submit"
loading={loading}
className="bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 rounded-xl px-4"
>
Invite
</Button>
</div>
{errors.email && (
<span className="text-red-500 text-xs font-medium">{errors.email.message?.toString()}</span>
)}
{invitedEmail && (
<span className="text-xs text-muted-foreground/80 font-medium">Successfully invited {invitedEmail}</span>
)}
</form>
</Section>
{readMemberPermission && <MemberInvitationsSectionInvitationsList team={props.team} />}
</>
);
}

View File

@ -0,0 +1,83 @@
'use client';
import { Team, useUser } from "@stackframe/stack";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { User } from "@phosphor-icons/react";
export function TeamMemberListSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const readMemberPermission = user.usePermission(props.team, '$read_members');
const inviteMemberPermission = user.usePermission(props.team, '$invite_members');
if (!readMemberPermission && !inviteMemberPermission) {
return null;
}
return <MemberListSectionInner team={props.team} />;
}
function MemberListSectionInner(props: { team: Team }) {
const users = props.team.useUsers();
return (
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex flex-col gap-5">
<div>
<h3 className="font-semibold text-base text-foreground leading-snug">
Team Members
</h3>
<p className="text-muted-foreground text-sm mt-1 leading-relaxed">
The users who have access to this team.
</p>
</div>
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-zinc-50/50 dark:bg-zinc-900/50">
<TableRow className="border-b border-black/[0.06] dark:border-white/[0.06]">
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider w-[80px]">Avatar</TableHead>
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-6 text-muted-foreground italic text-sm">
No members found
</TableCell>
</TableRow>
) : (
users.map(({ id, teamProfile }) => {
const initials = teamProfile.displayName?.slice(0, 2).toUpperCase() || '';
return (
<TableRow key={id} className="border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 hover:bg-zinc-50/30 dark:hover:bg-zinc-900/30 transition-colors duration-150">
<TableCell className="py-3 px-4">
<Avatar className="h-9 w-9 border border-black/[0.08] dark:border-white/[0.08] shadow-sm">
<AvatarImage src={teamProfile.profileImageUrl || undefined} />
<AvatarFallback className="bg-zinc-100 dark:bg-zinc-900 text-foreground font-semibold text-xs">
{initials || <User className="h-4 w-4 text-zinc-500" />}
</AvatarFallback>
</Avatar>
</TableCell>
<TableCell className="py-3 px-4 text-sm font-semibold text-foreground/90">
{teamProfile.displayName || (
<span className="text-muted-foreground italic font-normal text-xs">No display name set</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { Team } from "@stackframe/stack";
import { PageLayout } from "../page-layout";
import { LeaveTeamSection } from "./leave-team-section";
import { TeamApiKeysSection } from "./team-api-keys-section";
import { TeamDisplayNameSection } from "./team-display-name-section";
import { TeamMemberInvitationSection } from "./team-member-invitation-section";
import { TeamMemberListSection } from "./team-member-list-section";
import { TeamProfileImageSection } from "./team-profile-image-section";
import { TeamUserProfileSection } from "./team-profile-user-section";
export function TeamPage(props: { team: Team }) {
return (
<PageLayout>
<TeamUserProfileSection key={`user-profile-${props.team.id}`} team={props.team} />
<TeamProfileImageSection key={`profile-image-${props.team.id}`} team={props.team} />
<TeamDisplayNameSection key={`display-name-${props.team.id}`} team={props.team} />
<TeamMemberListSection key={`member-list-${props.team.id}`} team={props.team} />
<TeamMemberInvitationSection key={`member-invitation-${props.team.id}`} team={props.team} />
<TeamApiKeysSection key={`api-keys-${props.team.id}`} team={props.team} />
<LeaveTeamSection key={`leave-team-${props.team.id}`} team={props.team} />
</PageLayout>
);
}

View File

@ -0,0 +1,28 @@
'use client';
import { Team, useUser } from "@stackframe/stack";
import { ProfileImageEditor } from "../profile-image-editor";
import { Section } from "../section";
export function TeamProfileImageSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const updateTeamPermission = user.usePermission(props.team, '$update_team');
if (!updateTeamPermission) {
return null;
}
return (
<Section
title="Team profile image"
description="Upload an image for your team"
>
<ProfileImageEditor
user={props.team as any}
onProfileImageUrlChange={async (profileImageUrl) => {
await props.team.update({ profileImageUrl });
}}
/>
</Section>
);
}

View File

@ -0,0 +1,24 @@
'use client';
import { Team, useUser } from "@stackframe/stack";
import { EditableText } from "../editable-text";
import { Section } from "../section";
export function TeamUserProfileSection(props: { team: Team }) {
const user = useUser({ or: 'redirect' });
const profile = user.useTeamProfile(props.team);
return (
<Section
title="Team user name"
description="Overwrite your user display name in this team"
>
<EditableText
value={profile.displayName || ''}
onSave={async (newDisplayName) => {
await profile.update({ displayName: newDisplayName });
}}
/>
</Section>
);
}