diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/leave-team-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/leave-team-section.tsx new file mode 100644 index 000000000..edd30a598 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/leave-team-section.tsx @@ -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 ( + + + {!leaving ? ( + 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 + + ) : ( + + + Are you sure you want to leave the team? You will lose access to all of its resources. + + + { + await user.leaveTeam(props.team); + window.location.reload(); + }} + className="rounded-xl flex-1 text-xs" + > + Leave + + 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 + + + + )} + + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-api-keys-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-api-keys-section.tsx new file mode 100644 index 000000000..79ce9fdd4 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-api-keys-section.tsx @@ -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 ; +} + +function TeamApiKeysSectionInner(props: { team: Team }) { + const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(false); + const [returnedApiKey, setReturnedApiKey] = useState(null); + + const apiKeys = props.team.useApiKeys(); + + const CreateDialog = CreateApiKeyDialog<"team">; + const ShowDialog = ShowApiKeyDialog<"team">; + + return ( + <> + + 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 + + + + + + + { + const apiKey = await props.team.createApiKey(data as any); + return apiKey as any; + }} + /> + setReturnedApiKey(null)} + /> + > + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-creation-page.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-creation-page.tsx new file mode 100644 index 000000000..237918e91 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-creation-page.tsx @@ -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 ( + + + + Team creation is disabled in demo mode. + + + ); + } + + if (!project.config.clientTeamCreationEnabled) { + return ( + + + + Team creation is not enabled for this project. + + + ); + } + + const onSubmit = async (data: yup.InferType) => { + setLoading(true); + + let team; + try { + team = await user?.createTeam({ displayName: data.displayName }); + } finally { + setLoading(false); + } + + if (team) { + navigate(`#team-${team.id}`); + } + }; + + return ( + + + runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))} + noValidate + className="flex flex-col gap-2 w-full md:w-[350px]" + > + + + + Create + + + {errors.displayName && ( + {errors.displayName.message?.toString()} + )} + + + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-display-name-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-display-name-section.tsx new file mode 100644 index 000000000..1a95acb67 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-display-name-section.tsx @@ -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 ( + + await props.team.update({ displayName: newDisplayName })} + /> + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-invitation-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-invitation-section.tsx new file mode 100644 index 000000000..218c0c728 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-invitation-section.tsx @@ -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 ; +} + +function MemberInvitationsSectionInvitationsList(props: { team: Team }) { + const user = useUser({ or: 'redirect' }); + const invitationsToShow = props.team.useInvitations(); + const removeMemberPermission = user.usePermission(props.team, '$remove_members'); + + return ( + + + + Outstanding Invitations + + + Sent invitations that are currently pending. + + + + + + + + Email + Expires + + + + + {invitationsToShow.length === 0 ? ( + + + No outstanding invitations + + + ) : ( + invitationsToShow.map((invitation) => ( + + + {invitation.recipientEmail} + + + {new Date(invitation.expiresAt).toLocaleString()} + + + {removeMemberPermission && ( + 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" + > + + + )} + + + )) + )} + + + + + ); +} + +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(null); + + const onSubmit = async (data: yup.InferType) => { + setLoading(true); + try { + await props.team.inviteUser({ email: data.email }); + setInvitedEmail(data.email); + reset(); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + setInvitedEmail(null); + }, [watch('email')]); + + return ( + <> + + runAsynchronouslyWithAlert(handleSubmit(onSubmit)(e))} + noValidate + className="flex flex-col gap-2 w-full md:w-[350px]" + > + + + + Invite + + + {errors.email && ( + {errors.email.message?.toString()} + )} + {invitedEmail && ( + Successfully invited {invitedEmail} + )} + + + {readMemberPermission && } + > + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-list-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-list-section.tsx new file mode 100644 index 000000000..318e9a964 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-member-list-section.tsx @@ -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 ; +} + +function MemberListSectionInner(props: { team: Team }) { + const users = props.team.useUsers(); + + return ( + + + + Team Members + + + The users who have access to this team. + + + + + + + + Avatar + Name + + + + {users.length === 0 ? ( + + + No members found + + + ) : ( + users.map(({ id, teamProfile }) => { + const initials = teamProfile.displayName?.slice(0, 2).toUpperCase() || ''; + return ( + + + + + + {initials || } + + + + + {teamProfile.displayName || ( + No display name set + )} + + + ); + }) + )} + + + + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-page.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-page.tsx new file mode 100644 index 000000000..5e605c350 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-page.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-image-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-image-section.tsx new file mode 100644 index 000000000..06b9801e5 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-image-section.tsx @@ -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 ( + + { + await props.team.update({ profileImageUrl }); + }} + /> + + ); +} diff --git a/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-user-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-user-section.tsx new file mode 100644 index 000000000..fb654f7c8 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/teams/team-profile-user-section.tsx @@ -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 ( + + { + await profile.update({ displayName: newDisplayName }); + }} + /> + + ); +}
+ Sent invitations that are currently pending. +
+ The users who have access to this team. +