feat: team design changes

This commit is contained in:
Alex committed 2026-06-16 10:48:42 +01:00
1 parent d21f4758be
commit d3dc976d3c
9 files changed
+447 -116

No files matched your search

+6 -1
View File
@@ -62,7 +62,12 @@ class TeamsRepository:
"""
SELECT t.*,
CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin'
ELSE 'team_member' END AS member_role
ELSE 'team_member' END AS member_role,
(SELECT count(DISTINCT mm.user_id) FROM team_members mm
WHERE mm.team_id = t.id) AS member_count,
(SELECT count(DISTINCT g.resource_id)
FROM team_resource_grants g
WHERE g.team_id = t.id) AS shared_count
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = :user_id
+49 -1
View File
@@ -175,6 +175,42 @@ export default function AgentCard({
iconHeight: 13,
},
],
// Agents shared with the user via a team. They don't own it, so only
// non-destructive, non-owner actions are offered: open the config
// (editors can save, viewers see it read-only) and pin for quick access.
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
team: [
{
icon: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
navigate(`/agents/workflow/edit/${agent.id}`);
} else {
navigate(`/agents/edit/${agent.id}`);
}
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'default' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
],
shared: [
{
icon: Link,
@@ -214,7 +250,9 @@ export default function AgentCard({
const menuOptions = menuOptionsConfig[section] || [];
const handleClick = () => {
if (section === 'user') {
// Team-shared agents open/run exactly like the user's own published
// agents (the run + GetAgent routes authorize team grantees server-side).
if (section === 'user' || section === 'team') {
if (agent.status === 'published') {
dispatch(setSelectedAgent(agent));
navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new');
@@ -378,6 +416,16 @@ export default function AgentCard({
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Team access badge — pinned to the top row, left of the ⋯ menu
(right-11 clears the 19px trigger at right-4) so the two align. */}
{agent.ownership === 'team' && (
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<Users size={11} strokeWidth={2} aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
</span>
)}
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<Avatar
+1
View File
@@ -32,6 +32,7 @@ const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
{ id: 'all', labelKey: 'agents.filters.all' },
{ id: 'template', labelKey: 'agents.filters.byDocsGPT' },
{ id: 'user', labelKey: 'agents.filters.byMe' },
{ id: 'team', labelKey: 'agents.filters.team' },
{ id: 'shared', labelKey: 'agents.filters.shared' },
];
+21 -7
View File
@@ -8,12 +8,12 @@ import {
setTemplateAgents,
} from '../preferences/preferenceSlice';
export type AgentSectionId = 'template' | 'user' | 'shared';
export type AgentSectionId = 'template' | 'user' | 'team' | 'shared';
export const agentSectionsConfig = [
{
id: 'template' as const,
title: 'By DocsGPT',
title: 'Templates',
description: 'Agents provided by DocsGPT',
showNewAgentButton: false,
emptyStateDescription: 'No template agents found.',
@@ -23,7 +23,7 @@ export const agentSectionsConfig = [
},
{
id: 'user' as const,
title: 'By me',
title: 'My agents',
description: 'Agents created or published by you',
showNewAgentButton: true,
emptyStateDescription: 'You don’t have any created agents yet.',
@@ -32,11 +32,25 @@ export const agentSectionsConfig = [
updateAction: setAgents,
},
{
id: 'shared' as const,
title: 'Shared with me',
description: 'Agents imported by using a public link',
// Team-shared agents are split out of the 'user' payload client-side
// (they arrive in /api/get_agents tagged ownership:'team'), so this
// section reuses the same selector/action — the hook partitions by
// ownership for display.
id: 'team' as const,
title: 'Team',
description: 'Agents your teammates shared with you',
showNewAgentButton: false,
emptyStateDescription: 'No shared agents found.',
emptyStateDescription: 'No agents have been shared with your team yet.',
fetchAgents: (token: string | null) => userService.getAgents(token),
selectData: selectAgents,
updateAction: setAgents,
},
{
id: 'shared' as const,
title: 'Discovered',
description: "Public agents you've opened via a link",
showNewAgentButton: false,
emptyStateDescription: "You haven't opened any public agents yet.",
fetchAgents: (token: string | null) => userService.getSharedAgents(token),
selectData: selectSharedAgents,
updateAction: setSharedAgents,
+36 -4
View File
@@ -45,9 +45,28 @@ export function useAgentSearch(): UseAgentSearchResult {
const [activeFilter, setActiveFilter] = useState<AgentFilterTab>('all');
const templateAgents = useSelector(selectTemplateAgents);
const userAgents = useSelector(selectAgents);
const allUserAgents = useSelector(selectAgents);
const sharedAgents = useSelector(selectSharedAgents);
// /api/get_agents returns both the caller's own agents and agents shared
// with their teams (tagged ownership:'team'). Split them so "By me" shows
// only owned agents and "Shared with my team" gets its own section.
// Preserve the null (still-loading) state for both buckets.
const userAgents = useMemo(
() =>
allUserAgents === null
? null
: allUserAgents.filter((a) => a.ownership !== 'team'),
[allUserAgents],
);
const teamAgents = useMemo(
() =>
allUserAgents === null
? null
: allUserAgents.filter((a) => a.ownership === 'team'),
[allUserAgents],
);
const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query);
}, []);
@@ -60,24 +79,27 @@ export function useAgentSearch(): UseAgentSearchResult {
(): Record<AgentSectionId, boolean> => ({
template: templateAgents !== null,
user: userAgents !== null,
team: teamAgents !== null,
shared: sharedAgents !== null,
}),
[templateAgents, userAgents, sharedAgents],
[templateAgents, userAgents, teamAgents, sharedAgents],
);
const totalAgentsBySection = useMemo(
(): Record<AgentSectionId, number> => ({
template: templateAgents?.length ?? 0,
user: userAgents?.length ?? 0,
team: teamAgents?.length ?? 0,
shared: sharedAgents?.length ?? 0,
}),
[templateAgents, userAgents, sharedAgents],
[templateAgents, userAgents, teamAgents, sharedAgents],
);
const filteredAgentsBySection = useMemo((): AgentsBySection => {
const filtered = {
template: filterAgentsByQuery(templateAgents, searchQuery),
user: filterAgentsByQuery(userAgents, searchQuery),
team: filterAgentsByQuery(teamAgents, searchQuery),
shared: filterAgentsByQuery(sharedAgents, searchQuery),
};
@@ -88,14 +110,23 @@ export function useAgentSearch(): UseAgentSearchResult {
return {
template: activeFilter === 'template' ? filtered.template : [],
user: activeFilter === 'user' ? filtered.user : [],
team: activeFilter === 'team' ? filtered.team : [],
shared: activeFilter === 'shared' ? filtered.shared : [],
};
}, [templateAgents, userAgents, sharedAgents, searchQuery, activeFilter]);
}, [
templateAgents,
userAgents,
teamAgents,
sharedAgents,
searchQuery,
activeFilter,
]);
const hasAnyAgents = useMemo(() => {
return (
totalAgentsBySection.template > 0 ||
totalAgentsBySection.user > 0 ||
totalAgentsBySection.team > 0 ||
totalAgentsBySection.shared > 0
);
}, [totalAgentsBySection]);
@@ -104,6 +135,7 @@ export function useAgentSearch(): UseAgentSearchResult {
return (
filteredAgentsBySection.template.length > 0 ||
filteredAgentsBySection.user.length > 0 ||
filteredAgentsBySection.team.length > 0 ||
filteredAgentsBySection.shared.length > 0
);
}, [filteredAgentsBySection]);
+9 -3
View File
@@ -25,6 +25,9 @@ export function useAgentsFetch(): UseAgentsFetchResult {
const [isLoading, setIsLoading] = useState<Record<AgentSectionId, boolean>>({
template: true,
user: true,
// 'team' agents are split out of the same /api/get_agents payload, so
// their loading state tracks the user fetch.
team: true,
shared: true,
});
@@ -50,7 +53,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
} catch (error) {
dispatch(setAgents([]));
} finally {
setIsLoading((prev) => ({ ...prev, user: false }));
setIsLoading((prev) => ({ ...prev, user: false, team: false }));
}
}, [token, dispatch]);
@@ -79,7 +82,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
}, [token, dispatch]);
useEffect(() => {
setIsLoading({ template: true, user: true, shared: true });
setIsLoading({ template: true, user: true, team: true, shared: true });
Promise.all([
fetchTemplateAgents(),
fetchUserAgents(),
@@ -89,7 +92,10 @@ export function useAgentsFetch(): UseAgentsFetchResult {
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]);
const isAllLoaded =
!isLoading.template && !isLoading.user && !isLoading.shared;
!isLoading.template &&
!isLoading.user &&
!isLoading.team &&
!isLoading.shared;
return {
isLoading,
+30 -8
View File
@@ -224,10 +224,18 @@
"backToTeams": "Back to teams",
"createTeam": "Create a team",
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
"editTeam": "Edit",
"teamNamePlaceholder": "Team name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "What's this team for? (optional)",
"create": "Create",
"save": "Save",
"createTeamError": "Could not create team.",
"noTeams": "No teams yet.",
"noDescription": "No description",
"memberCountOne": "{{count}} member",
"memberCountOther": "{{count}} members",
"sharedCount": "{{count}} shared",
"loadError": "Failed to load teams.",
"roleAdmin": "admin",
"roleMember": "member",
@@ -241,6 +249,10 @@
"deleteTeam": "Delete team",
"deleteTeamConfirmation": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
"members": "Members",
"addMember": "Add member",
"addMemberTitle": "Add a member",
"addMemberDescription": "Invite someone by email. They must have signed in at least once.",
"teamActions": "Team actions",
"memberEmailLabel": "Member email",
"memberEmailPlaceholder": "name@example.com",
"memberRoleLabel": "Role",
@@ -891,25 +903,35 @@
"tryDifferentSearch": "Try a different search term",
"filters": {
"all": "All",
"byDocsGPT": "By DocsGPT",
"byMe": "By Me",
"shared": "Shared With Me"
"byDocsGPT": "Templates",
"byMe": "My agents",
"team": "Team",
"shared": "Discovered"
},
"teamBadge": {
"editor": "Editor",
"viewer": "Viewer"
},
"sections": {
"template": {
"title": "By DocsGPT",
"title": "Templates",
"description": "Agents provided by DocsGPT",
"emptyState": "No template agents found."
},
"user": {
"title": "By me",
"title": "My agents",
"description": "Agents created or published by you",
"emptyState": "You don't have any created agents yet."
},
"team": {
"title": "Team",
"description": "Agents your teammates shared with you",
"emptyState": "No agents have been shared with your team yet."
},
"shared": {
"title": "Shared with me",
"description": "Agents imported by using a public link",
"emptyState": "No shared agents found."
"title": "Discovered",
"description": "Public agents you've opened via a link",
"emptyState": "You haven't opened any public agents yet."
}
},
"form": {
+292 -92
View File
@@ -4,8 +4,11 @@ import {
ChevronRight,
FileText,
MessageSquare,
MoreVertical,
Pencil,
Plus,
Trash2,
Users,
Wrench,
} from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
@@ -23,6 +26,12 @@ import NoFilesIcon from '../assets/no-files.svg';
import SkeletonLoader from '../components/SkeletonLoader';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Input } from '../components/ui/input';
import { Modal } from '../components/ui/modal';
import {
@@ -109,8 +118,14 @@ export default function Teams() {
const [newTeamName, setNewTeamName] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editError, setEditError] = useState<string | null>(null);
const [newMemberEmail, setNewMemberEmail] = useState('');
const [newMemberRole, setNewMemberRole] = useState<TeamRole>('team_member');
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [addMemberError, setAddMemberError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleteTeamModalState, setDeleteTeamModalState] =
@@ -284,6 +299,42 @@ export default function Teams() {
setCreateError(null);
};
const openEditModal = () => {
if (!selected) return;
setEditName(selected.name);
setEditDescription(selected.description ?? '');
setEditError(null);
setEditOpen(true);
};
const closeEditModal = () => {
setEditOpen(false);
setEditError(null);
};
const handleEditSave = async () => {
if (!selected || !editName.trim()) return;
const name = editName.trim();
const description = editDescription.trim();
try {
const res = await teamsService.update(
selected.id,
{ name, description },
token,
);
if (!res || res.success === false) {
setEditError(t('settings.teams.updateFailed'));
return;
}
// Reflect locally and refresh the list so the card/switcher update too.
setSelected({ ...selected, name, description });
dispatch(loadTeams({ token }));
setEditOpen(false);
} catch {
setEditError(t('settings.teams.updateFailed'));
}
};
// Consume navigation intent from the team switcher: "Manage team" passes
// openTeamId (open that team's detail directly); "Create team" passes
// create:true (pop the create modal). Consumed once, then the history state
@@ -321,9 +372,21 @@ export default function Teams() {
}
}, [teams]);
const openAddMemberModal = () => {
setNewMemberEmail('');
setNewMemberRole('team_member');
setAddMemberError(null);
setAddMemberOpen(true);
};
const closeAddMemberModal = () => {
setAddMemberOpen(false);
setAddMemberError(null);
};
const handleAddMember = async () => {
if (!selected || !newMemberEmail.trim()) return;
setError(null);
setAddMemberError(null);
try {
const res = await teamsService.addMember(
selected.id,
@@ -333,13 +396,15 @@ export default function Teams() {
if (res?.success === false) {
// The backend returns 404 with a message when the email maps to no
// known user; surface its message (e.g. "they must sign in first").
setError(res.message ?? t('settings.teams.memberNotFound'));
setAddMemberError(res.message ?? t('settings.teams.memberNotFound'));
return;
}
setNewMemberEmail('');
setNewMemberRole('team_member');
setAddMemberOpen(false);
openTeam(selected);
} catch {
setError(t('settings.teams.addMemberError'));
setAddMemberError(t('settings.teams.addMemberError'));
}
};
@@ -419,7 +484,7 @@ export default function Teams() {
<span
className={`rounded-full px-2 py-0.5 text-xs ${
role === 'team_admin'
? 'bg-primary/10 text-primary dark:bg-primary/20'
? 'bg-muted-foreground/15 text-foreground'
: 'bg-muted-foreground/10 text-muted-foreground'
}`}
>
@@ -446,7 +511,7 @@ export default function Teams() {
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-foreground text-2xl font-bold">
{t('settings.teams.label')}
@@ -462,7 +527,7 @@ export default function Teams() {
</div>
{!selected ? (
<div className="mt-8 space-y-2">
<div className="mx-auto mt-8 w-full max-w-5xl">
{teamsLoading ? (
<SkeletonLoader component="default" />
) : teamsError ? (
@@ -482,36 +547,66 @@ export default function Teams() {
</Button>,
)
) : (
teams.map((team) => (
<button
key={team.id}
className="border-border bg-muted dark:bg-accent hover:border-primary/40 flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors"
onClick={() => openTeam(team)}
>
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-9 items-center justify-center rounded-md text-sm font-medium"
>
{initialOf(team.name)}
</Avatar>
</span>
<span className="text-foreground min-w-0 flex-1 truncate text-sm font-medium">
{team.name}
</span>
{roleBadge(team.member_role ?? 'team_member')}
<ChevronRight
className="text-muted-foreground shrink-0"
size={18}
strokeWidth={1.75}
aria-hidden
/>
</button>
))
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<button
key={team.id}
className="border-border bg-muted dark:bg-accent hover:border-primary/40 group flex h-full flex-col gap-3 rounded-2xl border p-4 text-left transition-colors"
onClick={() => openTeam(team)}
>
<div className="flex items-center gap-3">
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-muted-foreground/15 text-foreground flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-medium"
>
{initialOf(team.name)}
</Avatar>
</span>
<span className="text-foreground min-w-0 flex-1 truncate text-sm font-semibold">
{team.name}
</span>
{roleBadge(team.member_role ?? 'team_member')}
<ChevronRight
className="text-muted-foreground shrink-0 transition-transform group-hover:translate-x-0.5"
size={18}
strokeWidth={1.75}
aria-hidden
/>
</div>
{team.description ? (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{team.description}
</p>
) : (
<p className="text-muted-foreground/50 text-xs italic">
{t('settings.teams.noDescription')}
</p>
)}
<div className="text-muted-foreground mt-auto flex items-center gap-1.5 text-xs">
<Users size={13} strokeWidth={1.75} aria-hidden />
<span>
{t(
(team.member_count ?? 0) === 1
? 'settings.teams.memberCountOne'
: 'settings.teams.memberCountOther',
{ count: team.member_count ?? 0 },
)}
</span>
<span aria-hidden>·</span>
<span>
{t('settings.teams.sharedCount', {
count: team.shared_count ?? 0,
})}
</span>
</div>
</button>
))}
</div>
)}
</div>
) : (
<div className="mt-8 space-y-6">
<div className="mx-auto mt-8 max-w-5xl space-y-6">
<Button
variant="ghost"
size="sm"
@@ -532,29 +627,48 @@ export default function Teams() {
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-10 shrink-0 items-center justify-center rounded-md text-base font-medium"
className="bg-muted-foreground/15 text-foreground flex size-10 shrink-0 items-center justify-center rounded-md text-base font-medium"
>
{initialOf(selected.name)}
</Avatar>
</span>
<div className="min-w-0">
<p className="text-muted-foreground text-xs">
{t('settings.teams.label')} /
</p>
<h3 className="text-foreground truncate text-xl font-bold">
{selected.name}
</h3>
{selected.description && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{selected.description}
</p>
)}
</div>
</div>
{isAdmin && (
<Button
variant="destructive-outline"
size="sm"
className="shrink-0"
onClick={() => requestDeleteTeam(selected)}
>
{t('settings.teams.deleteTeam')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label={t('settings.teams.teamActions')}
>
<MoreVertical size={18} strokeWidth={1.75} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onSelect={openEditModal}>
<Pencil size={15} strokeWidth={1.75} aria-hidden />
<span>{t('settings.teams.editTeam')}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => requestDeleteTeam(selected)}
>
<Trash2 size={15} strokeWidth={1.75} aria-hidden />
<span>{t('settings.teams.deleteTeam')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
@@ -565,9 +679,22 @@ export default function Teams() {
)}
<div className="border-border mt-6 border-t pt-6">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wide uppercase">
{t('settings.teams.members')} · {members.length}
</h4>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t('settings.teams.members')} · {members.length}
</h4>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={openAddMemberModal}
>
<Plus size={15} strokeWidth={1.75} aria-hidden />
{t('settings.teams.addMember')}
</Button>
)}
</div>
{members.length === 0 ? (
emptyState(t('settings.teams.noMembers'))
) : (
@@ -580,7 +707,7 @@ export default function Teams() {
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-8 items-center justify-center rounded-full text-sm font-medium"
className="bg-muted-foreground/15 text-foreground flex size-8 items-center justify-center rounded-full text-sm font-medium"
>
{initialOf(memberLabel(m))}
</Avatar>
@@ -638,49 +765,6 @@ export default function Teams() {
))}
</ul>
)}
{isAdmin && (
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<div className="flex-1">
<Input
type="email"
label={t('settings.teams.memberEmailLabel')}
aria-label={t('settings.teams.memberEmailLabel')}
placeholder={t('settings.teams.memberEmailPlaceholder')}
value={newMemberEmail}
labelBgClassName="bg-background"
onChange={(e) => setNewMemberEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
/>
</div>
<Select
value={newMemberRole}
onValueChange={(value) => setNewMemberRole(value as TeamRole)}
>
<SelectTrigger
className="h-[42px] sm:w-36"
aria-label={t('settings.teams.memberRoleLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_member">
{t('settings.teams.roleMember')}
</SelectItem>
<SelectItem value="team_admin">
{t('settings.teams.roleAdmin')}
</SelectItem>
</SelectContent>
</Select>
<Button
className="h-[42px]"
onClick={handleAddMember}
disabled={!newMemberEmail.trim()}
>
{t('settings.teams.add')}
</Button>
</div>
)}
</div>
<div className="border-border mt-6 border-t pt-6">
@@ -768,6 +852,122 @@ export default function Teams() {
)}
</Modal>
<Modal
open={editOpen}
onOpenChange={(open) => (open ? setEditOpen(true) : closeEditModal())}
size="sm"
mobileVariant="sheet"
title={t('settings.teams.editTeam')}
footer={
<>
<Button variant="ghost" onClick={closeEditModal}>
{t('cancel')}
</Button>
<Button onClick={handleEditSave} disabled={!editName.trim()}>
{t('settings.teams.save')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input
type="text"
autoFocus
label={t('settings.teams.teamNamePlaceholder')}
aria-label={t('settings.teams.teamNamePlaceholder')}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="team-edit-description"
className="text-foreground text-sm font-medium"
>
{t('settings.teams.descriptionLabel')}
</label>
<textarea
id="team-edit-description"
rows={3}
className="border-border bg-background text-foreground focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground w-full resize-none rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
placeholder={t('settings.teams.descriptionPlaceholder')}
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
/>
</div>
</div>
{editError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{editError}
</p>
)}
</Modal>
<Modal
open={addMemberOpen}
onOpenChange={(open) =>
open ? setAddMemberOpen(true) : closeAddMemberModal()
}
size="sm"
mobileVariant="sheet"
title={t('settings.teams.addMemberTitle')}
description={t('settings.teams.addMemberDescription')}
footer={
<>
<Button variant="ghost" onClick={closeAddMemberModal}>
{t('cancel')}
</Button>
<Button onClick={handleAddMember} disabled={!newMemberEmail.trim()}>
{t('settings.teams.add')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input
type="email"
autoFocus
label={t('settings.teams.memberEmailLabel')}
aria-label={t('settings.teams.memberEmailLabel')}
placeholder={t('settings.teams.memberEmailPlaceholder')}
value={newMemberEmail}
onChange={(e) => setNewMemberEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="add-member-role"
className="text-foreground text-sm font-medium"
>
{t('settings.teams.memberRoleLabel')}
</label>
<Select
value={newMemberRole}
onValueChange={(value) => setNewMemberRole(value as TeamRole)}
>
<SelectTrigger
id="add-member-role"
aria-label={t('settings.teams.memberRoleLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_member">
{t('settings.teams.roleMember')}
</SelectItem>
<SelectItem value="team_admin">
{t('settings.teams.roleAdmin')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{addMemberError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{addMemberError}
</p>
)}
</Modal>
<ConfirmationModal
message={t('settings.teams.deleteTeamConfirmation', {
name: teamToDelete?.name ?? '',
+3
View File
@@ -9,6 +9,9 @@ export type Team = {
description?: string | null;
owner_id: string;
member_role?: TeamRole;
// Annotations from the list endpoint for the teams grid cards.
member_count?: number;
shared_count?: number;
};
export type TeamsState = {