481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
7.5 KiB
TypeScript
233 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { IconTrash, IconCrown, IconUsers } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { useWorkspaceTeamAccess } from "@/hooks/domains/workspace/use-workspace-team-access";
|
|
import {
|
|
ASSIGNABLE_WORKSPACE_ROLES,
|
|
type AssignableWorkspaceRole,
|
|
type DirectoryUser,
|
|
type WorkspaceMember,
|
|
} from "@/lib/types/team-access";
|
|
|
|
type WorkspaceTeamAccessCardProps = {
|
|
workspaceId: string;
|
|
ownerId: string;
|
|
/** Scopes the *requesting* user holds here, from the workspace payload. */
|
|
scopes: readonly string[] | undefined;
|
|
};
|
|
|
|
function useRoleLabel() {
|
|
const { t } = useTranslation();
|
|
return (role: AssignableWorkspaceRole) =>
|
|
role === "collaborator"
|
|
? t("workspaces:teamAccess.roleCollaborator")
|
|
: t("workspaces:teamAccess.roleViewer");
|
|
}
|
|
|
|
function RoleOptions() {
|
|
const roleLabel = useRoleLabel();
|
|
return (
|
|
<>
|
|
{ASSIGNABLE_WORKSPACE_ROLES.map((role) => (
|
|
<SelectItem key={role} value={role}>
|
|
{roleLabel(role)}
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
type MemberRowProps = {
|
|
member: WorkspaceMember;
|
|
canManageMembers: boolean;
|
|
busy: boolean;
|
|
onChangeRole: (userId: string, role: AssignableWorkspaceRole) => void;
|
|
onRemove: (userId: string) => void;
|
|
onMakeOwner: (userId: string) => void;
|
|
};
|
|
|
|
function MemberRow({
|
|
member,
|
|
canManageMembers,
|
|
busy,
|
|
onChangeRole,
|
|
onRemove,
|
|
onMakeOwner,
|
|
}: MemberRowProps) {
|
|
const { t } = useTranslation();
|
|
const isOwner = member.role === "owner";
|
|
return (
|
|
<li className="flex flex-wrap items-center gap-3 p-3" data-testid="workspace-member-row">
|
|
<span className="min-w-0 flex-1 truncate text-sm">
|
|
{member.display_name || member.user_id}
|
|
</span>
|
|
{isOwner ? (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<IconCrown className="size-3" aria-hidden />
|
|
{t("workspaces:teamAccess.roleOwner")}
|
|
</Badge>
|
|
) : (
|
|
<Select
|
|
value={member.role}
|
|
disabled={!canManageMembers || busy}
|
|
onValueChange={(role) => onChangeRole(member.user_id, role as AssignableWorkspaceRole)}
|
|
>
|
|
<SelectTrigger
|
|
className="w-40 cursor-pointer"
|
|
aria-label={t("workspaces:teamAccess.roleLabel")}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<RoleOptions />
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{canManageMembers && !isOwner ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
disabled={busy}
|
|
onClick={() => onMakeOwner(member.user_id)}
|
|
>
|
|
{t("workspaces:teamAccess.makeOwner")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="cursor-pointer"
|
|
disabled={busy}
|
|
aria-label={t("workspaces:teamAccess.removeMember")}
|
|
onClick={() => onRemove(member.user_id)}
|
|
>
|
|
<IconTrash className="size-4" aria-hidden />
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
type AddMemberRowProps = {
|
|
users: DirectoryUser[];
|
|
busy: boolean;
|
|
onAdd: (userId: string, role: AssignableWorkspaceRole, onAdded: () => void) => void;
|
|
};
|
|
|
|
function AddMemberRow({ users, busy, onAdd }: AddMemberRowProps) {
|
|
const { t } = useTranslation();
|
|
const [userId, setUserId] = useState("");
|
|
const [role, setRole] = useState<AssignableWorkspaceRole>("collaborator");
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="min-w-48 flex-1 space-y-1">
|
|
<Label htmlFor="add-member-user">{t("workspaces:teamAccess.addMemberLabel")}</Label>
|
|
<Select value={userId} onValueChange={setUserId} disabled={busy || users.length === 0}>
|
|
<SelectTrigger id="add-member-user" className="cursor-pointer">
|
|
<SelectValue placeholder={t("workspaces:teamAccess.addMemberPlaceholder")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{users.map((user) => (
|
|
<SelectItem key={user.id} value={user.id}>
|
|
{user.display_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="w-40 space-y-1">
|
|
<Label htmlFor="add-member-role">{t("workspaces:teamAccess.roleLabel")}</Label>
|
|
<Select
|
|
value={role}
|
|
onValueChange={(next) => setRole(next as AssignableWorkspaceRole)}
|
|
disabled={busy}
|
|
>
|
|
<SelectTrigger id="add-member-role" className="cursor-pointer">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<RoleOptions />
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
className="cursor-pointer"
|
|
disabled={busy || !userId}
|
|
onClick={() => onAdd(userId, role, () => setUserId(""))}
|
|
>
|
|
{t("workspaces:teamAccess.addMember")}
|
|
</Button>
|
|
{users.length === 0 ? (
|
|
<p className="text-muted-foreground w-full text-sm">
|
|
{t("workspaces:teamAccess.noUsersToAdd")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Workspace membership.
|
|
*
|
|
* Membership here is the exception path. Reach normally follows the
|
|
* organization unit the workspace sits in, so a team sets that once and never
|
|
* anyone. Membership below is the exception path, for private workspaces,
|
|
* guests, and narrowing a colleague to a viewer.
|
|
*/
|
|
export function WorkspaceTeamAccessCard(props: WorkspaceTeamAccessCardProps) {
|
|
const { t } = useTranslation();
|
|
const access = useWorkspaceTeamAccess(props);
|
|
|
|
return (
|
|
<SettingsCard data-testid="workspace-team-access-card">
|
|
<CardHeader>
|
|
<CardTitle>{t("workspaces:teamAccess.title")}</CardTitle>
|
|
<CardDescription>{t("workspaces:teamAccess.description")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<IconUsers className="text-muted-foreground size-4" aria-hidden />
|
|
<Label>{t("workspaces:teamAccess.membersLabel")}</Label>
|
|
</div>
|
|
|
|
<ul className="divide-border divide-y rounded-md border">
|
|
{access.members.length === 0 ? (
|
|
<li className="text-muted-foreground p-3 text-sm">
|
|
{t("workspaces:teamAccess.noMembers")}
|
|
</li>
|
|
) : (
|
|
access.members.map((member) => (
|
|
<MemberRow
|
|
key={member.user_id}
|
|
member={member}
|
|
canManageMembers={access.canManageMembers}
|
|
busy={access.busy}
|
|
onChangeRole={access.changeRole}
|
|
onRemove={access.removeMember}
|
|
onMakeOwner={access.makeOwner}
|
|
/>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
{access.canManageMembers ? (
|
|
<AddMemberRow users={access.addableUsers} busy={access.busy} onAdd={access.addMember} />
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</SettingsCard>
|
|
);
|
|
}
|