feat: group access, notices

This commit is contained in:
zhom committed 2026-10-12 14:27:39 +04:00
1 parent c9f2cf8a12
commit 5213bbb170
34 files changed
+2286 -93

No files matched your search

+16 -1
View File
@@ -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"
+117
View File
@@ -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"
+237
View File
@@ -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>
);
}
+156 -17
View File
@@ -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