mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
feat: group access, notices
This commit is contained in:
1 parent
c9f2cf8a12
commit
5213bbb170
34 files changed
+2286
-93
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
RemoteHoursMeter,
|
||||
} from "@/components/cookie-bot-shared";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { TeamAccess } from "@/components/team-access";
|
||||
import { TeamActivityLog } from "@/components/team-activity-log";
|
||||
import { TeamUsagePanel } from "@/components/team-usage-panel";
|
||||
import {
|
||||
@@ -115,7 +116,10 @@ export function AccountPage({
|
||||
// click back to.
|
||||
useEffect(() => {
|
||||
if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account");
|
||||
if (!showTeamActivity && activeTab === "team-activity") {
|
||||
if (
|
||||
!showTeamActivity &&
|
||||
(activeTab === "team-activity" || activeTab === "team-access")
|
||||
) {
|
||||
setActiveTab("account");
|
||||
}
|
||||
}, [showTeamUsage, showTeamActivity, activeTab]);
|
||||
@@ -346,6 +350,11 @@ export function AccountPage({
|
||||
{t("account.tabs.teamActivity")}
|
||||
</AnimatedTabsTrigger>
|
||||
)}
|
||||
{showTeamActivity && (
|
||||
<AnimatedTabsTrigger value="team-access">
|
||||
{t("account.tabs.teamAccess")}
|
||||
</AnimatedTabsTrigger>
|
||||
)}
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
@@ -563,6 +572,12 @@ export function AccountPage({
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
|
||||
{showTeamActivity && (
|
||||
<AnimatedTabsContent value="team-access" className="mt-4">
|
||||
<TeamAccess actorRole={user?.teamRole} />
|
||||
</AnimatedTabsContent>
|
||||
)}
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="self-hosted"
|
||||
className="@container mt-4"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialo
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
isoToLocalInput,
|
||||
isWebhookUrl,
|
||||
listScheduleRuns,
|
||||
listSchedules,
|
||||
localTimezone,
|
||||
@@ -36,10 +38,12 @@ import {
|
||||
MAX_SCHEDULE_PROFILES,
|
||||
MIN_INTERVAL_MINUTES,
|
||||
minuteToTime,
|
||||
rotateWebhookSecret,
|
||||
runScheduleNow,
|
||||
type ScheduleCadence,
|
||||
type ScheduleTarget,
|
||||
scheduleBody,
|
||||
testScheduleNotice,
|
||||
timeToMinute,
|
||||
updateSchedule,
|
||||
WEEKDAYS,
|
||||
@@ -428,6 +432,13 @@ function ScheduleEditor({
|
||||
String(schedule?.intervalMinutes ?? 60),
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [notifyEmail, setNotifyEmail] = useState(schedule?.notifyEmail ?? true);
|
||||
const [webhookUrl, setWebhookUrl] = useState(schedule?.webhookUrl ?? "");
|
||||
const [webhookSecret, setWebhookSecret] = useState(
|
||||
schedule?.webhookSecret ?? null,
|
||||
);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const webhookValid = isWebhookUrl(webhookUrl);
|
||||
|
||||
const minute = timeToMinute(time);
|
||||
const interval = Number.parseInt(intervalMinutes, 10);
|
||||
@@ -448,8 +459,42 @@ function ScheduleEditor({
|
||||
profileIds.length > 0 &&
|
||||
(target === "fleet" || canRunOnDesktop) &&
|
||||
cadenceReady &&
|
||||
webhookValid &&
|
||||
!isSaving;
|
||||
|
||||
const sendTestNotice = async () => {
|
||||
if (!schedule) return;
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testScheduleNotice(schedule.id);
|
||||
const failed = result.email === "failed" || result.webhook === "failed";
|
||||
if (failed) {
|
||||
showErrorToast(
|
||||
t("agent.schedules.notices.testFailed", {
|
||||
status: result.webhookStatus ?? "-",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("agent.schedules.notices.testSent"));
|
||||
}
|
||||
} catch (testError) {
|
||||
showErrorToast(translateBackendError(t, testError));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const newSecret = async () => {
|
||||
if (!schedule) return;
|
||||
try {
|
||||
const updated = await rotateWebhookSecret(schedule.id);
|
||||
setWebhookSecret(updated.webhookSecret);
|
||||
showSuccessToast(t("agent.schedules.notices.secretRotated"));
|
||||
} catch (rotateError) {
|
||||
showErrorToast(translateBackendError(t, rotateError));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProfile = (id: string, on: boolean) => {
|
||||
setProfileIds((current) =>
|
||||
on
|
||||
@@ -472,6 +517,8 @@ function ScheduleEditor({
|
||||
timezone,
|
||||
runOnceAt: onceDate?.toISOString(),
|
||||
intervalMinutes: interval,
|
||||
notifyEmail,
|
||||
webhookUrl: webhookUrl.trim() || null,
|
||||
};
|
||||
try {
|
||||
if (schedule) {
|
||||
@@ -685,6 +732,76 @@ function ScheduleEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2" data-slot="agent-schedule-notices">
|
||||
<Label className={labelClass}>
|
||||
{t("agent.schedules.notices.title")}
|
||||
</Label>
|
||||
<label
|
||||
htmlFor="agent-schedule-notify-email"
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id="agent-schedule-notify-email"
|
||||
checked={notifyEmail}
|
||||
onCheckedChange={(checked) => setNotifyEmail(checked === true)}
|
||||
/>
|
||||
{t("agent.schedules.notices.email")}
|
||||
</label>
|
||||
<Input
|
||||
id="agent-schedule-webhook"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={webhookUrl}
|
||||
placeholder={t("agent.schedules.notices.webhookPlaceholder")}
|
||||
aria-label={t("agent.schedules.notices.webhook")}
|
||||
aria-invalid={!webhookValid}
|
||||
onChange={(event) => setWebhookUrl(event.target.value)}
|
||||
/>
|
||||
{!webhookValid && (
|
||||
<p className="text-xs text-destructive-text">
|
||||
{t("agent.schedules.notices.webhookInvalid")}
|
||||
</p>
|
||||
)}
|
||||
{schedule &&
|
||||
webhookSecret &&
|
||||
webhookUrl.trim() === schedule.webhookUrl && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
{t("agent.schedules.notices.secret")}{" "}
|
||||
<code className="font-mono">{webhookSecret}</code>
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
variant="subtle"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
text={webhookSecret}
|
||||
aria-label={t("agent.schedules.notices.copySecret")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="h-7 rounded-lg text-xs"
|
||||
onClick={() => void newSecret()}
|
||||
>
|
||||
{t("agent.schedules.notices.rotate")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{schedule && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="h-8 self-start rounded-lg text-xs"
|
||||
disabled={isTesting}
|
||||
onClick={() => void sendTestNotice()}
|
||||
data-slot="agent-schedule-test-notice"
|
||||
>
|
||||
{t("agent.schedules.notices.test")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
canChangeAccess,
|
||||
listTeamMembers,
|
||||
setTeamMemberAccess,
|
||||
type TeamMember,
|
||||
UNGROUPED_ACCESS,
|
||||
} from "@/lib/team-activity";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Which profile groups each member may reach. Owners and admins only. */
|
||||
export function TeamAccess({ actorRole }: { actorRole: string | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const [members, setMembers] = React.useState<TeamMember[]>([]);
|
||||
const [groups, setGroups] = React.useState<Group[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [editing, setEditing] = React.useState<string | null>(null);
|
||||
const [everyGroup, setEveryGroup] = React.useState(true);
|
||||
const [chosen, setChosen] = React.useState<Set<string>>(() => new Set());
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void Promise.all([
|
||||
listTeamMembers(),
|
||||
invoke<Group[]>("get_profile_groups").catch(() => []),
|
||||
])
|
||||
.then(([loaded, localGroups]) => {
|
||||
setMembers(loaded);
|
||||
setGroups(
|
||||
[...localGroups].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
})
|
||||
.catch((err) => setError(translateBackendError(t, err)))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [t]);
|
||||
|
||||
const roleLabel = (role: string) =>
|
||||
role === "owner"
|
||||
? t("sync.team.roleOwner")
|
||||
: role === "admin"
|
||||
? t("sync.team.roleAdmin")
|
||||
: role === "launcher"
|
||||
? t("sync.team.roleLauncher")
|
||||
: t("sync.team.roleMember");
|
||||
|
||||
const accessLabel = (access: string[] | null) =>
|
||||
access === null
|
||||
? t("teamAccess.allGroups")
|
||||
: t("teamAccess.someGroups", { count: access.length });
|
||||
|
||||
const startEditing = (member: TeamMember) => {
|
||||
setEditing(member.userId);
|
||||
setEveryGroup(member.groupAccess === null);
|
||||
setChosen(new Set(member.groupAccess ?? []));
|
||||
};
|
||||
|
||||
const toggle = (id: string, on: boolean) =>
|
||||
setChosen((current) => {
|
||||
const next = new Set(current);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const saveAccess = async (member: TeamMember) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updated = await setTeamMemberAccess(
|
||||
member.userId,
|
||||
everyGroup ? null : [...chosen],
|
||||
);
|
||||
setMembers((current) =>
|
||||
current.map((item) =>
|
||||
item.userId === member.userId
|
||||
? {
|
||||
...item,
|
||||
groupAccess:
|
||||
updated?.groupAccess ?? (everyGroup ? null : [...chosen]),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setEditing(null);
|
||||
showSuccessToast(t("teamAccess.saved"));
|
||||
} catch (err) {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const options: Group[] = [
|
||||
...groups,
|
||||
{ id: UNGROUPED_ACCESS, name: t("teamAccess.ungrouped") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-testid="team-access">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t("teamAccess.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("teamAccess.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-6 text-center text-xs text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{members.map((member) => (
|
||||
<li
|
||||
key={member.userId}
|
||||
className="rounded-md px-2 py-1.5 hover:bg-foreground/5"
|
||||
data-member={member.userId}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">
|
||||
{member.email ?? member.userId}
|
||||
</span>{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleLabel(member.role)} ·{" "}
|
||||
{accessLabel(
|
||||
member.role === "owner" ? null : member.groupAccess,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{canChangeAccess(actorRole, member) &&
|
||||
editing !== member.userId && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => startEditing(member)}
|
||||
>
|
||||
{t("teamAccess.change")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing === member.userId && (
|
||||
<div className="mt-2 flex flex-col gap-3 rounded-md bg-foreground/5 p-3">
|
||||
<RadioGroup
|
||||
value={everyGroup ? "all" : "some"}
|
||||
onValueChange={(value) => setEveryGroup(value === "all")}
|
||||
>
|
||||
<label
|
||||
htmlFor={`access-all-${member.userId}`}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={`access-all-${member.userId}`}
|
||||
value="all"
|
||||
/>
|
||||
{t("teamAccess.allGroups")}
|
||||
</label>
|
||||
<label
|
||||
htmlFor={`access-some-${member.userId}`}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={`access-some-${member.userId}`}
|
||||
value="some"
|
||||
/>
|
||||
{t("teamAccess.onlyThese")}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{!everyGroup && (
|
||||
<div className="flex max-h-56 flex-col gap-1.5 overflow-y-auto pl-6">
|
||||
{options.map((group) => (
|
||||
<label
|
||||
key={group.id}
|
||||
htmlFor={`access-${member.userId}-${group.id}`}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={`access-${member.userId}-${group.id}`}
|
||||
checked={chosen.has(group.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggle(group.id, checked === true)
|
||||
}
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setEditing(null)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void saveAccess(member)}
|
||||
disabled={isSaving}
|
||||
data-testid="team-access-save"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
ACTIVITY_PERIODS,
|
||||
ACTIVITY_TARGET_TYPES,
|
||||
type ActivityFilter,
|
||||
type ActivityPeriod,
|
||||
actionKey,
|
||||
actorKind,
|
||||
exportTeamActivityCsv,
|
||||
fieldKeys,
|
||||
listTeamActivity,
|
||||
listTeamMembers,
|
||||
sinceFor,
|
||||
type TeamActivityItem,
|
||||
type TeamMember,
|
||||
targetLabel,
|
||||
} from "@/lib/team-activity";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
/** The value a filter Select holds for "no filter". */
|
||||
const ANY = "any";
|
||||
|
||||
interface Named {
|
||||
id: string;
|
||||
@@ -55,14 +75,34 @@ export function TeamActivityLog() {
|
||||
);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [members, setMembers] = React.useState<TeamMember[]>([]);
|
||||
const [actor, setActor] = React.useState(ANY);
|
||||
const [targetType, setTargetType] = React.useState(ANY);
|
||||
const [period, setPeriod] = React.useState<ActivityPeriod>("all");
|
||||
|
||||
const filter = React.useMemo<ActivityFilter>(
|
||||
() => ({
|
||||
actorUserId: actor === ANY ? undefined : actor,
|
||||
targetType: targetType === ANY ? undefined : targetType,
|
||||
since: sinceFor(period),
|
||||
}),
|
||||
[actor, targetType, period],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void listTeamMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [page, names] = await Promise.all([
|
||||
listTeamActivity(null),
|
||||
listTeamActivity(null, filter),
|
||||
loadLocalNames(),
|
||||
]);
|
||||
setItems(page.items);
|
||||
@@ -73,17 +113,34 @@ export function TeamActivityLog() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, [t, filter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const exportCsv = async () => {
|
||||
const path = await save({
|
||||
defaultPath: `team-activity-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const rows = await exportTeamActivityCsv(path, filter);
|
||||
showSuccessToast(t("teamActivity.exported", { count: rows }));
|
||||
} catch (err) {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const page = await listTeamActivity(nextCursor);
|
||||
const page = await listTeamActivity(nextCursor, filter);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (err) {
|
||||
@@ -112,14 +169,23 @@ export function TeamActivityLog() {
|
||||
? t("sync.team.roleLauncher")
|
||||
: t("sync.team.roleMember");
|
||||
|
||||
/** "Member → Launcher" for a role change, when the log says both. */
|
||||
const roleChange = (item: TeamActivityItem) => {
|
||||
const { from, to } = item.detail ?? {};
|
||||
return item.action === "member.role_change" &&
|
||||
/** "Member → Launcher" for a role change, or the groups an access change gave. */
|
||||
const memberChange = (item: TeamActivityItem) => {
|
||||
const { from, to, groups } = item.detail ?? {};
|
||||
if (
|
||||
item.action === "member.role_change" &&
|
||||
typeof from === "string" &&
|
||||
typeof to === "string"
|
||||
? `${roleLabel(from)} → ${roleLabel(to)}`
|
||||
: null;
|
||||
) {
|
||||
return `${roleLabel(from)} → ${roleLabel(to)}`;
|
||||
}
|
||||
if (item.action === "member.access_change") {
|
||||
if (groups === "all") return t("teamAccess.allGroups");
|
||||
if (typeof groups === "number") {
|
||||
return t("teamAccess.someGroups", { count: groups });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const describe = (item: TeamActivityItem) => {
|
||||
@@ -140,14 +206,87 @@ export function TeamActivityLog() {
|
||||
{t("teamActivity.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={isLoading}
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
onClick={() => void exportCsv()}
|
||||
disabled={isExporting || isLoading}
|
||||
data-testid="team-activity-export"
|
||||
>
|
||||
{t("teamActivity.exportCsv")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.buttons.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={actor} onValueChange={setActor}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="w-[200px]"
|
||||
aria-label={t("teamActivity.filterMember")}
|
||||
data-testid="team-activity-member"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t("teamActivity.anyMember")}</SelectItem>
|
||||
{members.map((member) => (
|
||||
<SelectItem key={member.userId} value={member.userId}>
|
||||
{member.email ?? member.userId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={targetType} onValueChange={setTargetType}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="w-[160px]"
|
||||
aria-label={t("teamActivity.filterType")}
|
||||
data-testid="team-activity-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t("teamActivity.anyType")}</SelectItem>
|
||||
{ACTIVITY_TARGET_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{t(`teamActivity.types.${type}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={period}
|
||||
onValueChange={(value) => setPeriod(value as ActivityPeriod)}
|
||||
>
|
||||
{t("common.buttons.refresh")}
|
||||
</Button>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="w-[150px]"
|
||||
aria-label={t("teamActivity.filterPeriod")}
|
||||
data-testid="team-activity-period"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVITY_PERIODS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`teamActivity.periods.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -170,7 +309,7 @@ export function TeamActivityLog() {
|
||||
const fields = fieldKeys(item.detail);
|
||||
const kind = actorKind(item.actor.kind);
|
||||
const notes = [
|
||||
roleChange(item),
|
||||
memberChange(item),
|
||||
fields.length > 0 &&
|
||||
t("teamActivity.changedFields", {
|
||||
fields: fields
|
||||
|
||||
Reference in new issue
Block a user