mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Add team settings pages and sections for dashboard account settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f245ea55da
commit
10688411be
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user