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
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "Deine Teamrolle kann Profile öffnen, aber nicht ändern. Frag einen Teambesitzer oder Admin.",
"teamActivityForbidden": "Nur Teambesitzer und Admins sehen die Aktivität des Teams.",
"teamActivityNotEntitled": "Das Aktivitätsprotokoll braucht einen aktiven Team- oder Enterprise-Plan.",
"teamActivityInvalid": "Die Anfrage für das Aktivitätsprotokoll war ungültig."
"teamActivityInvalid": "Die Anfrage für das Aktivitätsprotokoll war ungültig.",
"teamActivityExportFailed": "Die Aktivitätsdatei konnte nicht gespeichert werden.",
"teamAccessForbidden": "Nur Teambesitzer und Admins können den Zugriff ändern.",
"teamAccessInvalid": "Diese Zugriffseinstellung ist ungültig.",
"teamMemberNotFound": "Dieses Mitglied ist nicht mehr im Team.",
"teamGroupAccessDenied": "Du hast keinen Zugriff auf die Gruppe dieses Profils.",
"teamAccessNotEntitled": "Zugriff nach Gruppe braucht einen aktiven Team- oder Enterprise-Plan.",
"replayRecipeNotFound": "Dieses Rezept gibt es nicht mehr."
},
"rail": {
"profiles": "Profile",
@@ -2736,7 +2743,8 @@
"account": "Konto",
"selfHosted": "Selbst gehostet",
"teamUsage": "Team-Nutzung",
"teamActivity": "Teamaktivität"
"teamActivity": "Teamaktivität",
"teamAccess": "Teamzugriff"
},
"selfHosted": {
"title": "Selbst gehosteter Sync-Server",
@@ -3680,6 +3688,20 @@
"partial": "Teilweise fertig",
"failed": "Fehlgeschlagen",
"cancelled": "Abgebrochen"
},
"notices": {
"title": "Benachrichtigungen",
"email": "Mir eine E-Mail senden, wenn ein Lauf fehlschlägt",
"webhook": "Webhook-URL",
"webhookPlaceholder": "Webhook-URL (optional, https)",
"webhookInvalid": "Verwende eine https-Adresse ohne Benutzernamen oder Passwort.",
"secret": "Signaturschlüssel:",
"copySecret": "Signaturschlüssel kopieren",
"rotate": "Neuer Schlüssel",
"test": "Testbenachrichtigung senden",
"testSent": "Testbenachrichtigung gesendet.",
"testFailed": "Die Testbenachrichtigung ist fehlgeschlagen (Webhook-Status {{status}}).",
"secretRotated": "Neuer Signaturschlüssel erstellt."
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "hat den Status {{target}} erstellt",
"statusUpdate": "hat den Status {{target}} geändert",
"statusDelete": "hat den Status {{target}} gelöscht",
"other": "hat {{action}} für {{target}} ausgeführt"
"other": "hat {{action}} für {{target}} ausgeführt",
"memberAccessChange": "hat den Zugriff von {{target}} geändert"
},
"fields": {
"name": "Name",
@@ -4025,6 +4048,41 @@
"mcp": "durch einen MCP-Agenten",
"service": "mit einem API-Schlüssel",
"remote_vm": "in einem Cloud-Browser"
},
"exportCsv": "CSV exportieren",
"exported_one": "{{count}} Eintrag gespeichert.",
"exported_other": "{{count}} Einträge gespeichert.",
"filterMember": "Mitglied",
"filterType": "Elementtyp",
"filterPeriod": "Zeitraum",
"anyMember": "Alle Mitglieder",
"anyType": "Alle Elemente",
"types": {
"profile": "Profile",
"proxy": "Proxys",
"group": "Gruppen",
"vpn": "VPNs",
"extension": "Erweiterungen",
"template": "Vorlagen",
"status": "Status",
"member": "Mitglieder"
},
"periods": {
"1": "Letzte 24 Stunden",
"7": "Letzte 7 Tage",
"30": "Letzte 30 Tage",
"all": "Gesamter Zeitraum"
}
},
"teamAccess": {
"title": "Zugriff nach Gruppe",
"description": "Beschränke ein Mitglied auf einige Profilgruppen. Gruppennamen bleiben auf diesem Computer; die Cloud speichert nur Gruppen-IDs.",
"allGroups": "Alle Gruppen",
"onlyThese": "Nur diese Gruppen",
"ungrouped": "Profile ohne Gruppe",
"change": "Ändern",
"saved": "Zugriff gespeichert.",
"someGroups_one": "{{count}} Gruppe",
"someGroups_other": "{{count}} Gruppen"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "Your team role can open profiles but not change them. Ask a team owner or admin.",
"teamActivityForbidden": "Only team owners and admins can see the team's activity.",
"teamActivityNotEntitled": "The activity log needs an active Team or Enterprise plan.",
"teamActivityInvalid": "The activity request was not valid."
"teamActivityInvalid": "The activity request was not valid.",
"teamActivityExportFailed": "The activity file could not be saved.",
"teamAccessForbidden": "Only team owners and admins can change access.",
"teamAccessInvalid": "This access setting is not valid.",
"teamMemberNotFound": "This member is no longer on the team.",
"teamGroupAccessDenied": "You have no access to the group of this profile.",
"teamAccessNotEntitled": "Access by group needs an active Team or Enterprise plan.",
"replayRecipeNotFound": "This recipe no longer exists."
},
"rail": {
"profiles": "Profiles",
@@ -2736,7 +2743,8 @@
"account": "Account",
"selfHosted": "Self-hosted",
"teamUsage": "Team usage",
"teamActivity": "Team activity"
"teamActivity": "Team activity",
"teamAccess": "Team access"
},
"selfHosted": {
"title": "Self-hosted sync server",
@@ -3680,6 +3688,20 @@
"partial": "Partly done",
"failed": "Failed",
"cancelled": "Cancelled"
},
"notices": {
"title": "Notices",
"email": "Email me when a run fails",
"webhook": "Webhook URL",
"webhookPlaceholder": "Webhook URL (optional, https)",
"webhookInvalid": "Use an https address without a user name or password.",
"secret": "Signing secret:",
"copySecret": "Copy the signing secret",
"rotate": "New secret",
"test": "Send a test notice",
"testSent": "Test notice sent.",
"testFailed": "The test notice failed (webhook status {{status}}).",
"secretRotated": "New signing secret made."
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "created the status {{target}}",
"statusUpdate": "changed the status {{target}}",
"statusDelete": "deleted the status {{target}}",
"other": "did {{action}} on {{target}}"
"other": "did {{action}} on {{target}}",
"memberAccessChange": "changed the access of {{target}}"
},
"fields": {
"name": "Name",
@@ -4025,6 +4048,41 @@
"mcp": "by an MCP agent",
"service": "with an API key",
"remote_vm": "from a cloud browser"
},
"exportCsv": "Export CSV",
"exported_one": "{{count}} entry saved.",
"exported_other": "{{count}} entries saved.",
"filterMember": "Member",
"filterType": "Item type",
"filterPeriod": "Period",
"anyMember": "All members",
"anyType": "All items",
"types": {
"profile": "Profiles",
"proxy": "Proxies",
"group": "Groups",
"vpn": "VPNs",
"extension": "Extensions",
"template": "Templates",
"status": "Statuses",
"member": "Members"
},
"periods": {
"1": "Last 24 hours",
"7": "Last 7 days",
"30": "Last 30 days",
"all": "All time"
}
},
"teamAccess": {
"title": "Access by group",
"description": "Limit a member to some profile groups. Group names stay on this computer; the cloud keeps only group IDs.",
"allGroups": "All groups",
"onlyThese": "Only these groups",
"ungrouped": "Profiles in no group",
"change": "Change",
"saved": "Access saved.",
"someGroups_one": "{{count}} group",
"someGroups_other": "{{count}} groups"
}
}
+63 -3
View File
@@ -2666,7 +2666,14 @@
"teamRoleReadOnly": "Tu rol en el equipo puede abrir perfiles, pero no cambiarlos. Pídelo a un propietario o administrador del equipo.",
"teamActivityForbidden": "Solo los propietarios y administradores del equipo pueden ver su actividad.",
"teamActivityNotEntitled": "El registro de actividad necesita un plan Team o Enterprise activo.",
"teamActivityInvalid": "La solicitud del registro de actividad no era válida."
"teamActivityInvalid": "La solicitud del registro de actividad no era válida.",
"teamActivityExportFailed": "No se pudo guardar el archivo de actividad.",
"teamAccessForbidden": "Solo los propietarios y administradores del equipo pueden cambiar el acceso.",
"teamAccessInvalid": "Esta configuración de acceso no es válida.",
"teamMemberNotFound": "Este miembro ya no está en el equipo.",
"teamGroupAccessDenied": "No tienes acceso al grupo de este perfil.",
"teamAccessNotEntitled": "El acceso por grupo necesita un plan Team o Enterprise activo.",
"replayRecipeNotFound": "Esta receta ya no existe."
},
"rail": {
"profiles": "Perfiles",
@@ -2756,7 +2763,8 @@
"account": "Cuenta",
"selfHosted": "Autoalojado",
"teamUsage": "Uso del equipo",
"teamActivity": "Actividad del equipo"
"teamActivity": "Actividad del equipo",
"teamAccess": "Acceso del equipo"
},
"selfHosted": {
"title": "Servidor de sincronización autoalojado",
@@ -3723,6 +3731,20 @@
"partial": "Hecho en parte",
"failed": "Falló",
"cancelled": "Cancelado"
},
"notices": {
"title": "Avisos",
"email": "Enviarme un correo cuando falle una ejecución",
"webhook": "URL del webhook",
"webhookPlaceholder": "URL del webhook (opcional, https)",
"webhookInvalid": "Usa una dirección https sin usuario ni contraseña.",
"secret": "Secreto de firma:",
"copySecret": "Copiar el secreto de firma",
"rotate": "Nuevo secreto",
"test": "Enviar un aviso de prueba",
"testSent": "Aviso de prueba enviado.",
"testFailed": "El aviso de prueba falló (estado del webhook {{status}}).",
"secretRotated": "Se creó un nuevo secreto de firma."
}
}
},
@@ -4040,7 +4062,8 @@
"statusCreate": "creó el estado {{target}}",
"statusUpdate": "cambió el estado {{target}}",
"statusDelete": "eliminó el estado {{target}}",
"other": "hizo {{action}} en {{target}}"
"other": "hizo {{action}} en {{target}}",
"memberAccessChange": "cambió el acceso de {{target}}"
},
"fields": {
"name": "Nombre",
@@ -4068,6 +4091,43 @@
"mcp": "mediante un agente MCP",
"service": "con una clave de API",
"remote_vm": "desde un navegador en la nube"
},
"exportCsv": "Exportar CSV",
"exported_one": "{{count}} entrada guardada.",
"exported_many": "{{count}} entradas guardadas.",
"exported_other": "{{count}} entradas guardadas.",
"filterMember": "Miembro",
"filterType": "Tipo de elemento",
"filterPeriod": "Periodo",
"anyMember": "Todos los miembros",
"anyType": "Todos los elementos",
"types": {
"profile": "Perfiles",
"proxy": "Proxies",
"group": "Grupos",
"vpn": "VPN",
"extension": "Extensiones",
"template": "Plantillas",
"status": "Estados",
"member": "Miembros"
},
"periods": {
"1": "Últimas 24 horas",
"7": "Últimos 7 días",
"30": "Últimos 30 días",
"all": "Todo"
}
},
"teamAccess": {
"title": "Acceso por grupo",
"description": "Limita a un miembro a algunos grupos de perfiles. Los nombres de los grupos se quedan en este equipo; la nube solo guarda los ID de grupo.",
"allGroups": "Todos los grupos",
"onlyThese": "Solo estos grupos",
"ungrouped": "Perfiles sin grupo",
"change": "Cambiar",
"saved": "Acceso guardado.",
"someGroups_one": "{{count}} grupo",
"someGroups_many": "{{count}} grupos",
"someGroups_other": "{{count}} grupos"
}
}
+63 -3
View File
@@ -2666,7 +2666,14 @@
"teamRoleReadOnly": "Votre rôle dans l’équipe peut ouvrir des profils, mais pas les modifier. Demandez à un propriétaire ou un administrateur de l’équipe.",
"teamActivityForbidden": "Seuls les propriétaires et administrateurs de l’équipe voient son activité.",
"teamActivityNotEntitled": "Le journal d’activité nécessite un forfait Team ou Enterprise actif.",
"teamActivityInvalid": "La demande du journal d’activité n’était pas valide."
"teamActivityInvalid": "La demande du journal d’activité n’était pas valide.",
"teamActivityExportFailed": "Le fichier d’activité n’a pas pu être enregistré.",
"teamAccessForbidden": "Seuls les propriétaires et administrateurs de l’équipe peuvent modifier l’accès.",
"teamAccessInvalid": "Ce réglage d’accès n’est pas valide.",
"teamMemberNotFound": "Ce membre ne fait plus partie de l’équipe.",
"teamGroupAccessDenied": "Vous n’avez pas accès au groupe de ce profil.",
"teamAccessNotEntitled": "L’accès par groupe nécessite un forfait Team ou Enterprise actif.",
"replayRecipeNotFound": "Cette recette n’existe plus."
},
"rail": {
"profiles": "Profils",
@@ -2756,7 +2763,8 @@
"account": "Compte",
"selfHosted": "Auto-hébergé",
"teamUsage": "Utilisation de l'équipe",
"teamActivity": "Activité de l’équipe"
"teamActivity": "Activité de l’équipe",
"teamAccess": "Accès de l’équipe"
},
"selfHosted": {
"title": "Serveur de synchronisation auto-hébergé",
@@ -3723,6 +3731,20 @@
"partial": "Terminé en partie",
"failed": "Échec",
"cancelled": "Annulé"
},
"notices": {
"title": "Notifications",
"email": "M’envoyer un e-mail quand une exécution échoue",
"webhook": "URL du webhook",
"webhookPlaceholder": "URL du webhook (facultative, https)",
"webhookInvalid": "Utilisez une adresse https sans nom d’utilisateur ni mot de passe.",
"secret": "Secret de signature :",
"copySecret": "Copier le secret de signature",
"rotate": "Nouveau secret",
"test": "Envoyer une notification de test",
"testSent": "Notification de test envoyée.",
"testFailed": "La notification de test a échoué (statut du webhook {{status}}).",
"secretRotated": "Nouveau secret de signature créé."
}
}
},
@@ -4040,7 +4062,8 @@
"statusCreate": "a créé le statut {{target}}",
"statusUpdate": "a modifié le statut {{target}}",
"statusDelete": "a supprimé le statut {{target}}",
"other": "a effectué {{action}} sur {{target}}"
"other": "a effectué {{action}} sur {{target}}",
"memberAccessChange": "a modifié l’accès de {{target}}"
},
"fields": {
"name": "Nom",
@@ -4068,6 +4091,43 @@
"mcp": "par un agent MCP",
"service": "avec une clé d’API",
"remote_vm": "depuis un navigateur cloud"
},
"exportCsv": "Exporter en CSV",
"exported_one": "{{count}} entrée enregistrée.",
"exported_many": "{{count}} entrées enregistrées.",
"exported_other": "{{count}} entrées enregistrées.",
"filterMember": "Membre",
"filterType": "Type d’élément",
"filterPeriod": "Période",
"anyMember": "Tous les membres",
"anyType": "Tous les éléments",
"types": {
"profile": "Profils",
"proxy": "Proxys",
"group": "Groupes",
"vpn": "VPN",
"extension": "Extensions",
"template": "Modèles",
"status": "Statuts",
"member": "Membres"
},
"periods": {
"1": "Dernières 24 heures",
"7": "7 derniers jours",
"30": "30 derniers jours",
"all": "Toute la période"
}
},
"teamAccess": {
"title": "Accès par groupe",
"description": "Limitez un membre à certains groupes de profils. Les noms des groupes restent sur cet ordinateur ; le cloud ne garde que les ID de groupe.",
"allGroups": "Tous les groupes",
"onlyThese": "Seulement ces groupes",
"ungrouped": "Profils sans groupe",
"change": "Modifier",
"saved": "Accès enregistré.",
"someGroups_one": "{{count}} groupe",
"someGroups_many": "{{count}} groupes",
"someGroups_other": "{{count}} groupes"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "あなたのチームロールはプロファイルを開けますが、変更はできません。チームのオーナーか管理者に依頼してください。",
"teamActivityForbidden": "チームのアクティビティを見られるのは、オーナーと管理者だけです。",
"teamActivityNotEntitled": "アクティビティログには、有効な Team または Enterprise プランが必要です。",
"teamActivityInvalid": "アクティビティログのリクエストが無効でした。"
"teamActivityInvalid": "アクティビティログのリクエストが無効でした。",
"teamActivityExportFailed": "アクティビティファイルを保存できませんでした。",
"teamAccessForbidden": "アクセスを変更できるのは、チームのオーナーと管理者だけです。",
"teamAccessInvalid": "このアクセス設定は無効です。",
"teamMemberNotFound": "このメンバーはもうチームにいません。",
"teamGroupAccessDenied": "このプロファイルのグループにアクセスできません。",
"teamAccessNotEntitled": "グループ別のアクセスには、有効な Team または Enterprise プランが必要です。",
"replayRecipeNotFound": "このレシピはもう存在しません。"
},
"rail": {
"profiles": "プロファイル",
@@ -2736,7 +2743,8 @@
"account": "アカウント",
"selfHosted": "セルフホスト",
"teamUsage": "チームの使用状況",
"teamActivity": "チームのアクティビティ"
"teamActivity": "チームのアクティビティ",
"teamAccess": "チームのアクセス"
},
"selfHosted": {
"title": "セルフホスト同期サーバー",
@@ -3680,6 +3688,20 @@
"partial": "一部完了",
"failed": "失敗",
"cancelled": "キャンセル済み"
},
"notices": {
"title": "通知",
"email": "実行が失敗したらメールで知らせる",
"webhook": "Webhook URL",
"webhookPlaceholder": "Webhook URL(任意、https)",
"webhookInvalid": "ユーザー名やパスワードを含まない https アドレスを使ってください。",
"secret": "署名シークレット:",
"copySecret": "署名シークレットをコピー",
"rotate": "新しいシークレット",
"test": "テスト通知を送信",
"testSent": "テスト通知を送信しました。",
"testFailed": "テスト通知に失敗しました(Webhook のステータス {{status}})。",
"secretRotated": "新しい署名シークレットを作成しました。"
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "がステータス {{target}} を作成しました",
"statusUpdate": "がステータス {{target}} を変更しました",
"statusDelete": "がステータス {{target}} を削除しました",
"other": "が {{target}} に {{action}} を実行しました"
"other": "が {{target}} に {{action}} を実行しました",
"memberAccessChange": "が {{target}} のアクセスを変更しました"
},
"fields": {
"name": "名前",
@@ -4025,6 +4048,41 @@
"mcp": "MCP エージェントによって",
"service": "API キーで",
"remote_vm": "クラウドブラウザから"
},
"exportCsv": "CSV をエクスポート",
"exported_one": "{{count}} 件を保存しました。",
"exported_other": "{{count}} 件を保存しました。",
"filterMember": "メンバー",
"filterType": "項目の種類",
"filterPeriod": "期間",
"anyMember": "すべてのメンバー",
"anyType": "すべての項目",
"types": {
"profile": "プロファイル",
"proxy": "プロキシ",
"group": "グループ",
"vpn": "VPN",
"extension": "拡張機能",
"template": "テンプレート",
"status": "ステータス",
"member": "メンバー"
},
"periods": {
"1": "過去 24 時間",
"7": "過去 7 日間",
"30": "過去 30 日間",
"all": "すべての期間"
}
},
"teamAccess": {
"title": "グループ別のアクセス",
"description": "メンバーを一部のプロファイルグループに制限します。グループ名はこのコンピューターに残り、クラウドにはグループ ID だけが保存されます。",
"allGroups": "すべてのグループ",
"onlyThese": "これらのグループのみ",
"ungrouped": "グループのないプロファイル",
"change": "変更",
"saved": "アクセスを保存しました。",
"someGroups_one": "{{count}} グループ",
"someGroups_other": "{{count}} グループ"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "내 팀 역할은 프로필을 열 수 있지만 변경할 수는 없습니다. 팀 소유자나 관리자에게 요청하세요.",
"teamActivityForbidden": "팀 활동은 팀 소유자와 관리자만 볼 수 있습니다.",
"teamActivityNotEntitled": "활동 기록에는 활성 Team 또는 Enterprise 요금제가 필요합니다.",
"teamActivityInvalid": "활동 기록 요청이 올바르지 않습니다."
"teamActivityInvalid": "활동 기록 요청이 올바르지 않습니다.",
"teamActivityExportFailed": "활동 파일을 저장할 수 없습니다.",
"teamAccessForbidden": "접근 권한은 팀 소유자와 관리자만 변경할 수 있습니다.",
"teamAccessInvalid": "이 접근 설정은 올바르지 않습니다.",
"teamMemberNotFound": "이 멤버는 더 이상 팀에 없습니다.",
"teamGroupAccessDenied": "이 프로필의 그룹에 접근할 수 없습니다.",
"teamAccessNotEntitled": "그룹별 접근 권한에는 활성 Team 또는 Enterprise 요금제가 필요합니다.",
"replayRecipeNotFound": "이 레시피는 더 이상 없습니다."
},
"rail": {
"profiles": "프로필",
@@ -2736,7 +2743,8 @@
"account": "계정",
"selfHosted": "자체 호스팅",
"teamUsage": "팀 사용량",
"teamActivity": "팀 활동"
"teamActivity": "팀 활동",
"teamAccess": "팀 접근 권한"
},
"selfHosted": {
"title": "자체 호스팅 동기화 서버",
@@ -3680,6 +3688,20 @@
"partial": "일부 완료",
"failed": "실패",
"cancelled": "취소됨"
},
"notices": {
"title": "알림",
"email": "실행이 실패하면 이메일 보내기",
"webhook": "웹훅 URL",
"webhookPlaceholder": "웹훅 URL(선택, https)",
"webhookInvalid": "사용자 이름이나 비밀번호가 없는 https 주소를 사용하세요.",
"secret": "서명 비밀 값:",
"copySecret": "서명 비밀 값 복사",
"rotate": "새 비밀 값",
"test": "테스트 알림 보내기",
"testSent": "테스트 알림을 보냈습니다.",
"testFailed": "테스트 알림이 실패했습니다(웹훅 상태 {{status}}).",
"secretRotated": "새 서명 비밀 값을 만들었습니다."
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "님이 상태 {{target}}을(를) 만들었습니다",
"statusUpdate": "님이 상태 {{target}}을(를) 변경했습니다",
"statusDelete": "님이 상태 {{target}}을(를) 삭제했습니다",
"other": "님이 {{target}}에 {{action}}을(를) 실행했습니다"
"other": "님이 {{target}}에 {{action}}을(를) 실행했습니다",
"memberAccessChange": "님이 {{target}} 님의 접근 권한을 변경했습니다"
},
"fields": {
"name": "이름",
@@ -4025,6 +4048,41 @@
"mcp": "MCP 에이전트가",
"service": "API 키로",
"remote_vm": "클라우드 브라우저에서"
},
"exportCsv": "CSV 내보내기",
"exported_one": "{{count}}개 항목을 저장했습니다.",
"exported_other": "{{count}}개 항목을 저장했습니다.",
"filterMember": "멤버",
"filterType": "항목 유형",
"filterPeriod": "기간",
"anyMember": "모든 멤버",
"anyType": "모든 항목",
"types": {
"profile": "프로필",
"proxy": "프록시",
"group": "그룹",
"vpn": "VPN",
"extension": "확장 프로그램",
"template": "템플릿",
"status": "상태",
"member": "멤버"
},
"periods": {
"1": "지난 24시간",
"7": "지난 7일",
"30": "지난 30일",
"all": "전체 기간"
}
},
"teamAccess": {
"title": "그룹별 접근 권한",
"description": "멤버를 일부 프로필 그룹으로 제한합니다. 그룹 이름은 이 컴퓨터에 남고, 클라우드에는 그룹 ID만 저장됩니다.",
"allGroups": "모든 그룹",
"onlyThese": "이 그룹만",
"ungrouped": "그룹이 없는 프로필",
"change": "변경",
"saved": "접근 권한을 저장했습니다.",
"someGroups_one": "{{count}}개 그룹",
"someGroups_other": "{{count}}개 그룹"
}
}
+63 -3
View File
@@ -2666,7 +2666,14 @@
"teamRoleReadOnly": "Sua função na equipe pode abrir perfis, mas não alterá-los. Peça a um proprietário ou administrador da equipe.",
"teamActivityForbidden": "Só os proprietários e administradores da equipe veem a atividade dela.",
"teamActivityNotEntitled": "O registro de atividade precisa de um plano Team ou Enterprise ativo.",
"teamActivityInvalid": "A solicitação do registro de atividade não era válida."
"teamActivityInvalid": "A solicitação do registro de atividade não era válida.",
"teamActivityExportFailed": "Não foi possível salvar o arquivo de atividade.",
"teamAccessForbidden": "Só os proprietários e administradores da equipe podem alterar o acesso.",
"teamAccessInvalid": "Esta configuração de acesso não é válida.",
"teamMemberNotFound": "Este membro não está mais na equipe.",
"teamGroupAccessDenied": "Você não tem acesso ao grupo deste perfil.",
"teamAccessNotEntitled": "O acesso por grupo precisa de um plano Team ou Enterprise ativo.",
"replayRecipeNotFound": "Esta receita não existe mais."
},
"rail": {
"profiles": "Perfis",
@@ -2756,7 +2763,8 @@
"account": "Conta",
"selfHosted": "Auto-hospedado",
"teamUsage": "Uso da equipe",
"teamActivity": "Atividade da equipe"
"teamActivity": "Atividade da equipe",
"teamAccess": "Acesso da equipe"
},
"selfHosted": {
"title": "Servidor de sincronização auto-hospedado",
@@ -3723,6 +3731,20 @@
"partial": "Concluído em parte",
"failed": "Falhou",
"cancelled": "Cancelado"
},
"notices": {
"title": "Avisos",
"email": "Enviar-me um e-mail quando uma execução falhar",
"webhook": "URL do webhook",
"webhookPlaceholder": "URL do webhook (opcional, https)",
"webhookInvalid": "Use um endereço https sem nome de usuário ou senha.",
"secret": "Segredo de assinatura:",
"copySecret": "Copiar o segredo de assinatura",
"rotate": "Novo segredo",
"test": "Enviar um aviso de teste",
"testSent": "Aviso de teste enviado.",
"testFailed": "O aviso de teste falhou (status do webhook {{status}}).",
"secretRotated": "Novo segredo de assinatura criado."
}
}
},
@@ -4040,7 +4062,8 @@
"statusCreate": "criou o status {{target}}",
"statusUpdate": "alterou o status {{target}}",
"statusDelete": "excluiu o status {{target}}",
"other": "fez {{action}} em {{target}}"
"other": "fez {{action}} em {{target}}",
"memberAccessChange": "alterou o acesso de {{target}}"
},
"fields": {
"name": "Nome",
@@ -4068,6 +4091,43 @@
"mcp": "por um agente MCP",
"service": "com uma chave de API",
"remote_vm": "de um navegador na nuvem"
},
"exportCsv": "Exportar CSV",
"exported_one": "{{count}} entrada salva.",
"exported_many": "{{count}} entradas salvas.",
"exported_other": "{{count}} entradas salvas.",
"filterMember": "Membro",
"filterType": "Tipo de item",
"filterPeriod": "Período",
"anyMember": "Todos os membros",
"anyType": "Todos os itens",
"types": {
"profile": "Perfis",
"proxy": "Proxies",
"group": "Grupos",
"vpn": "VPNs",
"extension": "Extensões",
"template": "Modelos",
"status": "Status",
"member": "Membros"
},
"periods": {
"1": "Últimas 24 horas",
"7": "Últimos 7 dias",
"30": "Últimos 30 dias",
"all": "Todo o período"
}
},
"teamAccess": {
"title": "Acesso por grupo",
"description": "Limite um membro a alguns grupos de perfis. Os nomes dos grupos ficam neste computador; a nuvem guarda só os IDs dos grupos.",
"allGroups": "Todos os grupos",
"onlyThese": "Só estes grupos",
"ungrouped": "Perfis sem grupo",
"change": "Alterar",
"saved": "Acesso salvo.",
"someGroups_one": "{{count}} grupo",
"someGroups_many": "{{count}} grupos",
"someGroups_other": "{{count}} grupos"
}
}
+65 -3
View File
@@ -2686,7 +2686,14 @@
"teamRoleReadOnly": "Ваша роль в команде позволяет открывать профили, но не изменять их. Обратитесь к владельцу или администратору команды.",
"teamActivityForbidden": "Активность команды видят только её владельцы и администраторы.",
"teamActivityNotEntitled": "Для журнала активности нужен активный план Team или Enterprise.",
"teamActivityInvalid": "Запрос журнала активности недействителен."
"teamActivityInvalid": "Запрос журнала активности недействителен.",
"teamActivityExportFailed": "Не удалось сохранить файл активности.",
"teamAccessForbidden": "Изменять доступ могут только владельцы и администраторы команды.",
"teamAccessInvalid": "Эта настройка доступа недействительна.",
"teamMemberNotFound": "Этого участника больше нет в команде.",
"teamGroupAccessDenied": "У вас нет доступа к группе этого профиля.",
"teamAccessNotEntitled": "Для доступа по группам нужен активный план Team или Enterprise.",
"replayRecipeNotFound": "Этого рецепта больше нет."
},
"rail": {
"profiles": "Профили",
@@ -2776,7 +2783,8 @@
"account": "Аккаунт",
"selfHosted": "Свой сервер",
"teamUsage": "Использование командой",
"teamActivity": "Активность команды"
"teamActivity": "Активность команды",
"teamAccess": "Доступ команды"
},
"selfHosted": {
"title": "Свой сервер синхронизации",
@@ -3766,6 +3774,20 @@
"partial": "Частично готово",
"failed": "Ошибка",
"cancelled": "Отменено"
},
"notices": {
"title": "Уведомления",
"email": "Отправлять письмо, если запуск не удался",
"webhook": "URL вебхука",
"webhookPlaceholder": "URL вебхука (необязательно, https)",
"webhookInvalid": "Используйте адрес https без имени пользователя и пароля.",
"secret": "Секрет подписи:",
"copySecret": "Скопировать секрет подписи",
"rotate": "Новый секрет",
"test": "Отправить тестовое уведомление",
"testSent": "Тестовое уведомление отправлено.",
"testFailed": "Тестовое уведомление не отправлено (статус вебхука {{status}}).",
"secretRotated": "Создан новый секрет подписи."
}
}
},
@@ -4083,7 +4105,8 @@
"statusCreate": "создал(а) статус {{target}}",
"statusUpdate": "изменил(а) статус {{target}}",
"statusDelete": "удалил(а) статус {{target}}",
"other": "выполнил(а) {{action}} для {{target}}"
"other": "выполнил(а) {{action}} для {{target}}",
"memberAccessChange": "изменил(а) доступ {{target}}"
},
"fields": {
"name": "Название",
@@ -4111,6 +4134,45 @@
"mcp": "через MCP-агента",
"service": "с ключом API",
"remote_vm": "из облачного браузера"
},
"exportCsv": "Экспорт в CSV",
"exported_one": "Сохранена {{count}} запись.",
"exported_few": "Сохранено {{count}} записи.",
"exported_many": "Сохранено {{count}} записей.",
"exported_other": "Сохранено {{count}} записи.",
"filterMember": "Участник",
"filterType": "Тип элемента",
"filterPeriod": "Период",
"anyMember": "Все участники",
"anyType": "Все элементы",
"types": {
"profile": "Профили",
"proxy": "Прокси",
"group": "Группы",
"vpn": "VPN",
"extension": "Расширения",
"template": "Шаблоны",
"status": "Статусы",
"member": "Участники"
},
"periods": {
"1": "Последние 24 часа",
"7": "Последние 7 дней",
"30": "Последние 30 дней",
"all": "За всё время"
}
},
"teamAccess": {
"title": "Доступ по группам",
"description": "Ограничьте участника некоторыми группами профилей. Названия групп остаются на этом компьютере; облако хранит только ID групп.",
"allGroups": "Все группы",
"onlyThese": "Только эти группы",
"ungrouped": "Профили без группы",
"change": "Изменить",
"saved": "Доступ сохранён.",
"someGroups_one": "{{count}} группа",
"someGroups_few": "{{count}} группы",
"someGroups_many": "{{count}} групп",
"someGroups_other": "{{count}} группы"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "Ekip rolün profilleri açabilir ama değiştiremez. Bir ekip sahibine veya yöneticisine sor.",
"teamActivityForbidden": "Ekibin etkinliğini yalnızca ekip sahipleri ve yöneticileri görebilir.",
"teamActivityNotEntitled": "Etkinlik günlüğü için etkin bir Team veya Enterprise planı gerekir.",
"teamActivityInvalid": "Etkinlik günlüğü isteği geçerli değildi."
"teamActivityInvalid": "Etkinlik günlüğü isteği geçerli değildi.",
"teamActivityExportFailed": "Etkinlik dosyası kaydedilemedi.",
"teamAccessForbidden": "Erişimi yalnızca ekip sahipleri ve yöneticileri değiştirebilir.",
"teamAccessInvalid": "Bu erişim ayarı geçerli değil.",
"teamMemberNotFound": "Bu üye artık ekipte değil.",
"teamGroupAccessDenied": "Bu profilin grubuna erişimin yok.",
"teamAccessNotEntitled": "Gruba göre erişim için etkin bir Team veya Enterprise planı gerekir.",
"replayRecipeNotFound": "Bu tarif artık yok."
},
"rail": {
"profiles": "Profiller",
@@ -2736,7 +2743,8 @@
"account": "Hesap",
"selfHosted": "Kendi sunucunuz",
"teamUsage": "Ekip kullanımı",
"teamActivity": "Ekip etkinliği"
"teamActivity": "Ekip etkinliği",
"teamAccess": "Ekip erişimi"
},
"selfHosted": {
"title": "Kendi eşitleme sunucunuz",
@@ -3680,6 +3688,20 @@
"partial": "Kısmen tamamlandı",
"failed": "Başarısız",
"cancelled": "İptal edildi"
},
"notices": {
"title": "Bildirimler",
"email": "Bir çalıştırma başarısız olunca bana e-posta gönder",
"webhook": "Webhook URL'si",
"webhookPlaceholder": "Webhook URL'si (isteğe bağlı, https)",
"webhookInvalid": "Kullanıcı adı veya parola içermeyen bir https adresi kullan.",
"secret": "İmza anahtarı:",
"copySecret": "İmza anahtarını kopyala",
"rotate": "Yeni anahtar",
"test": "Test bildirimi gönder",
"testSent": "Test bildirimi gönderildi.",
"testFailed": "Test bildirimi başarısız oldu (webhook durumu {{status}}).",
"secretRotated": "Yeni imza anahtarı oluşturuldu."
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "{{target}} durumunu oluşturdu",
"statusUpdate": "{{target}} durumunu değiştirdi",
"statusDelete": "{{target}} durumunu sildi",
"other": "{{target}} üzerinde {{action}} yaptı"
"other": "{{target}} üzerinde {{action}} yaptı",
"memberAccessChange": "{{target}} kişisinin erişimini değiştirdi"
},
"fields": {
"name": "Ad",
@@ -4025,6 +4048,41 @@
"mcp": "bir MCP ajanıyla",
"service": "bir API anahtarıyla",
"remote_vm": "bir bulut tarayıcısından"
},
"exportCsv": "CSV olarak dışa aktar",
"exported_one": "{{count}} kayıt kaydedildi.",
"exported_other": "{{count}} kayıt kaydedildi.",
"filterMember": "Üye",
"filterType": "Öğe türü",
"filterPeriod": "Dönem",
"anyMember": "Tüm üyeler",
"anyType": "Tüm öğeler",
"types": {
"profile": "Profiller",
"proxy": "Proxy'ler",
"group": "Gruplar",
"vpn": "VPN'ler",
"extension": "Eklentiler",
"template": "Şablonlar",
"status": "Durumlar",
"member": "Üyeler"
},
"periods": {
"1": "Son 24 saat",
"7": "Son 7 gün",
"30": "Son 30 gün",
"all": "Tüm zamanlar"
}
},
"teamAccess": {
"title": "Gruba göre erişim",
"description": "Bir üyeyi bazı profil gruplarıyla sınırla. Grup adları bu bilgisayarda kalır; bulut yalnızca grup kimliklerini saklar.",
"allGroups": "Tüm gruplar",
"onlyThese": "Yalnızca bu gruplar",
"ungrouped": "Grubu olmayan profiller",
"change": "Değiştir",
"saved": "Erişim kaydedildi.",
"someGroups_one": "{{count}} grup",
"someGroups_other": "{{count}} grup"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "Vai trò nhóm của bạn có thể mở hồ sơ nhưng không thể thay đổi chúng. Hãy nhờ chủ sở hữu hoặc quản trị viên của nhóm.",
"teamActivityForbidden": "Chỉ chủ sở hữu và quản trị viên của nhóm mới xem được hoạt động của nhóm.",
"teamActivityNotEntitled": "Nhật ký hoạt động cần gói Team hoặc Enterprise đang hoạt động.",
"teamActivityInvalid": "Yêu cầu nhật ký hoạt động không hợp lệ."
"teamActivityInvalid": "Yêu cầu nhật ký hoạt động không hợp lệ.",
"teamActivityExportFailed": "Không thể lưu tệp hoạt động.",
"teamAccessForbidden": "Chỉ chủ sở hữu và quản trị viên của nhóm mới đổi được quyền truy cập.",
"teamAccessInvalid": "Cài đặt quyền truy cập này không hợp lệ.",
"teamMemberNotFound": "Thành viên này không còn trong nhóm.",
"teamGroupAccessDenied": "Bạn không có quyền truy cập nhóm của hồ sơ này.",
"teamAccessNotEntitled": "Quyền truy cập theo nhóm cần gói Team hoặc Enterprise đang hoạt động.",
"replayRecipeNotFound": "Công thức này không còn nữa."
},
"rail": {
"profiles": "Profile",
@@ -2736,7 +2743,8 @@
"account": "Tài khoản",
"selfHosted": "Tự lưu trữ",
"teamUsage": "Mức dùng của nhóm",
"teamActivity": "Hoạt động của nhóm"
"teamActivity": "Hoạt động của nhóm",
"teamAccess": "Quyền truy cập của nhóm"
},
"selfHosted": {
"title": "Máy chủ đồng bộ tự lưu trữ",
@@ -3680,6 +3688,20 @@
"partial": "Xong một phần",
"failed": "Thất bại",
"cancelled": "Đã hủy"
},
"notices": {
"title": "Thông báo",
"email": "Gửi email cho tôi khi một lần chạy thất bại",
"webhook": "URL webhook",
"webhookPlaceholder": "URL webhook (tùy chọn, https)",
"webhookInvalid": "Dùng địa chỉ https không có tên người dùng hoặc mật khẩu.",
"secret": "Khóa ký:",
"copySecret": "Sao chép khóa ký",
"rotate": "Khóa mới",
"test": "Gửi thông báo thử",
"testSent": "Đã gửi thông báo thử.",
"testFailed": "Thông báo thử thất bại (trạng thái webhook {{status}}).",
"secretRotated": "Đã tạo khóa ký mới."
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "đã tạo trạng thái {{target}}",
"statusUpdate": "đã thay đổi trạng thái {{target}}",
"statusDelete": "đã xóa trạng thái {{target}}",
"other": "đã thực hiện {{action}} với {{target}}"
"other": "đã thực hiện {{action}} với {{target}}",
"memberAccessChange": "đã đổi quyền truy cập của {{target}}"
},
"fields": {
"name": "Tên",
@@ -4025,6 +4048,41 @@
"mcp": "bởi một tác tử MCP",
"service": "bằng khóa API",
"remote_vm": "từ trình duyệt đám mây"
},
"exportCsv": "Xuất CSV",
"exported_one": "Đã lưu {{count}} mục.",
"exported_other": "Đã lưu {{count}} mục.",
"filterMember": "Thành viên",
"filterType": "Loại mục",
"filterPeriod": "Khoảng thời gian",
"anyMember": "Tất cả thành viên",
"anyType": "Tất cả mục",
"types": {
"profile": "Hồ sơ",
"proxy": "Proxy",
"group": "Nhóm",
"vpn": "VPN",
"extension": "Tiện ích",
"template": "Mẫu",
"status": "Trạng thái",
"member": "Thành viên"
},
"periods": {
"1": "24 giờ qua",
"7": "7 ngày qua",
"30": "30 ngày qua",
"all": "Mọi thời gian"
}
},
"teamAccess": {
"title": "Quyền truy cập theo nhóm",
"description": "Giới hạn một thành viên trong một số nhóm hồ sơ. Tên nhóm ở lại trên máy tính này; đám mây chỉ giữ ID nhóm.",
"allGroups": "Tất cả nhóm",
"onlyThese": "Chỉ các nhóm này",
"ungrouped": "Hồ sơ không có nhóm",
"change": "Thay đổi",
"saved": "Đã lưu quyền truy cập.",
"someGroups_one": "{{count}} nhóm",
"someGroups_other": "{{count}} nhóm"
}
}
+61 -3
View File
@@ -2646,7 +2646,14 @@
"teamRoleReadOnly": "你的团队角色可以打开配置文件,但不能更改它们。请联系团队所有者或管理员。",
"teamActivityForbidden": "只有团队所有者和管理员可以查看团队活动。",
"teamActivityNotEntitled": "活动日志需要有效的 Team 或 Enterprise 套餐。",
"teamActivityInvalid": "活动日志请求无效。"
"teamActivityInvalid": "活动日志请求无效。",
"teamActivityExportFailed": "无法保存活动文件。",
"teamAccessForbidden": "只有团队所有者和管理员可以更改权限。",
"teamAccessInvalid": "此权限设置无效。",
"teamMemberNotFound": "此成员已不在团队中。",
"teamGroupAccessDenied": "你无权访问此配置文件所在的分组。",
"teamAccessNotEntitled": "按分组的权限需要有效的 Team 或 Enterprise 套餐。",
"replayRecipeNotFound": "此配方已不存在。"
},
"rail": {
"profiles": "配置文件",
@@ -2736,7 +2743,8 @@
"account": "账户",
"selfHosted": "自托管",
"teamUsage": "团队用量",
"teamActivity": "团队活动"
"teamActivity": "团队活动",
"teamAccess": "团队权限"
},
"selfHosted": {
"title": "自托管同步服务器",
@@ -3680,6 +3688,20 @@
"partial": "部分完成",
"failed": "失败",
"cancelled": "已取消"
},
"notices": {
"title": "通知",
"email": "运行失败时给我发邮件",
"webhook": "Webhook URL",
"webhookPlaceholder": "Webhook URL(可选,https)",
"webhookInvalid": "请使用不含用户名或密码的 https 地址。",
"secret": "签名密钥:",
"copySecret": "复制签名密钥",
"rotate": "新密钥",
"test": "发送测试通知",
"testSent": "测试通知已发送。",
"testFailed": "测试通知失败(Webhook 状态 {{status}})。",
"secretRotated": "已生成新的签名密钥。"
}
}
},
@@ -3997,7 +4019,8 @@
"statusCreate": "创建了状态 {{target}}",
"statusUpdate": "更改了状态 {{target}}",
"statusDelete": "删除了状态 {{target}}",
"other": "对 {{target}} 执行了 {{action}}"
"other": "对 {{target}} 执行了 {{action}}",
"memberAccessChange": "更改了 {{target}} 的权限"
},
"fields": {
"name": "名称",
@@ -4025,6 +4048,41 @@
"mcp": "由 MCP 代理",
"service": "使用 API 密钥",
"remote_vm": "来自云端浏览器"
},
"exportCsv": "导出 CSV",
"exported_one": "已保存 {{count}} 条记录。",
"exported_other": "已保存 {{count}} 条记录。",
"filterMember": "成员",
"filterType": "项目类型",
"filterPeriod": "时间范围",
"anyMember": "所有成员",
"anyType": "所有项目",
"types": {
"profile": "配置文件",
"proxy": "代理",
"group": "分组",
"vpn": "VPN",
"extension": "扩展",
"template": "模板",
"status": "状态",
"member": "成员"
},
"periods": {
"1": "过去 24 小时",
"7": "过去 7 天",
"30": "过去 30 天",
"all": "全部时间"
}
},
"teamAccess": {
"title": "按分组的权限",
"description": "将成员限制在部分配置文件分组内。分组名称保留在这台电脑上;云端只保存分组 ID。",
"allGroups": "所有分组",
"onlyThese": "仅这些分组",
"ungrouped": "没有分组的配置文件",
"change": "更改",
"saved": "权限已保存。",
"someGroups_one": "{{count}} 个分组",
"someGroups_other": "{{count}} 个分组"
}
}
+21
View File
@@ -129,6 +129,13 @@ export type BackendErrorCode =
| "TEAM_ACTIVITY_FORBIDDEN"
| "TEAM_ACTIVITY_NOT_ENTITLED"
| "TEAM_ACTIVITY_INVALID"
| "TEAM_ACTIVITY_EXPORT_FAILED"
| "TEAM_ACCESS_FORBIDDEN"
| "TEAM_ACCESS_INVALID"
| "TEAM_MEMBER_NOT_FOUND"
| "TEAM_GROUP_ACCESS_DENIED"
| "TEAM_ACCESS_NOT_ENTITLED"
| "REPLAY_RECIPE_NOT_FOUND"
| "LOCAL_STORAGE_EPHEMERAL"
| "LOCAL_STORAGE_PROFILE_PROTECTED"
| "LOCAL_STORAGE_UNREADABLE"
@@ -694,6 +701,20 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.teamActivityNotEntitled");
case "TEAM_ACTIVITY_INVALID":
return t("backendErrors.teamActivityInvalid");
case "TEAM_ACTIVITY_EXPORT_FAILED":
return t("backendErrors.teamActivityExportFailed");
case "TEAM_ACCESS_FORBIDDEN":
return t("backendErrors.teamAccessForbidden");
case "TEAM_ACCESS_INVALID":
return t("backendErrors.teamAccessInvalid");
case "TEAM_MEMBER_NOT_FOUND":
return t("backendErrors.teamMemberNotFound");
case "TEAM_GROUP_ACCESS_DENIED":
return t("backendErrors.teamGroupAccessDenied");
case "TEAM_ACCESS_NOT_ENTITLED":
return t("backendErrors.teamAccessNotEntitled");
case "REPLAY_RECIPE_NOT_FOUND":
return t("backendErrors.replayRecipeNotFound");
case "MCP_TOOLSET_UNKNOWN":
return t("backendErrors.mcpToolsetUnknown");
case "MCP_AGENT_UNKNOWN":
+25
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isoToLocalInput,
isWebhookUrl,
minuteToTime,
scheduleBody,
timeToMinute,
@@ -48,3 +49,27 @@ test("a schedule sends only the fields its cadence reads", () => {
assert.equal(interval.intervalMinutes, 60);
assert.equal(interval.timezone, undefined);
});
test("a webhook is https without credentials, and an empty one clears it", () => {
assert.equal(isWebhookUrl(""), true);
assert.equal(isWebhookUrl("https://hooks.example.com/donut"), true);
for (const bad of [
"http://hooks.example.com/donut",
"https://user:pass@hooks.example.com/",
"not a url",
]) {
assert.equal(isWebhookUrl(bad), false, bad);
}
const body = scheduleBody({
name: "n",
recipeId: "r",
target: "fleet",
profileIds: ["p"],
cadence: "interval",
intervalMinutes: 60,
notifyEmail: false,
webhookUrl: " ",
});
assert.equal(body.notifyEmail, false);
assert.equal(body.webhookUrl, null);
});
+44
View File
@@ -25,6 +25,12 @@ export interface AgentSchedule {
intervalMinutes: number | null;
nextRunAt: string | null;
lastRunAt: string | null;
/** Email the owner when a run fails, and when it works again. */
notifyEmail: boolean;
/** An https address the cloud posts the same notices to. */
webhookUrl: string | null;
/** Signs each webhook body (`X-Donut-Signature: sha256=<HMAC>`). */
webhookSecret: string | null;
}
export interface AgentScheduleRun {
@@ -47,6 +53,9 @@ export interface ScheduleInput {
runOnceAt?: string;
intervalMinutes?: number;
enabled?: boolean;
notifyEmail?: boolean;
/** Null clears the webhook. */
webhookUrl?: string | null;
}
/** The cloud's own limits, so the form never offers what it refuses. */
@@ -102,6 +111,10 @@ export function scheduleBody(input: ScheduleInput): Record<string, unknown> {
cadence: input.cadence,
};
if (input.enabled !== undefined) body.enabled = input.enabled;
if (input.notifyEmail !== undefined) body.notifyEmail = input.notifyEmail;
if (input.webhookUrl !== undefined) {
body.webhookUrl = input.webhookUrl?.trim() || null;
}
if (input.cadence === "weekly") {
body.daysMask = input.daysMask;
body.runAtMinute = input.runAtMinute;
@@ -141,6 +154,37 @@ export function runScheduleNow(id: string): Promise<unknown> {
return invoke("run_agent_schedule_now", { id });
}
/** Whether `url` is an address the cloud takes for a webhook. It checks again. */
export function isWebhookUrl(url: string): boolean {
const value = url.trim();
if (!value) return true;
try {
const parsed = new URL(value);
return (
parsed.protocol === "https:" &&
!parsed.username &&
!parsed.password &&
value.length <= 2048
);
} catch {
return false;
}
}
export interface NoticeTestResult {
email: "sent" | "off" | "failed";
webhook: "sent" | "off" | "failed";
webhookStatus: number | null;
}
export function testScheduleNotice(id: string): Promise<NoticeTestResult> {
return invoke<NoticeTestResult>("test_agent_schedule_notice", { id });
}
export function rotateWebhookSecret(id: string): Promise<AgentSchedule> {
return updateSchedule(id, { rotateWebhookSecret: true });
}
export function listScheduleRuns(id: string): Promise<AgentScheduleRun[]> {
return invoke<AgentScheduleRun[] | null>("list_agent_schedule_runs", {
id,
+23
View File
@@ -3,7 +3,9 @@ import test from "node:test";
import {
actionKey,
actorKind,
canChangeAccess,
fieldKeys,
sinceFor,
targetLabel,
} from "./team-activity.ts";
@@ -48,3 +50,24 @@ test("an actor kind this build does not know is not shown", () => {
assert.equal(actorKind("mcp"), "mcp");
assert.equal(actorKind("satellite"), null);
});
test("a period reads back that many days, and all reads everything", () => {
const now = new Date("2026-10-12T12:00:00Z");
assert.equal(sinceFor("1", now), "2026-10-11T12:00:00.000Z");
assert.equal(sinceFor("30", now), "2026-09-12T12:00:00.000Z");
assert.equal(sinceFor("all", now), undefined);
});
test("the owner is never limited, and an admin limits only members below it", () => {
const member = (role) => ({
userId: "u",
email: null,
role,
groupAccess: null,
});
assert.equal(canChangeAccess("owner", member("owner")), false);
assert.equal(canChangeAccess("owner", member("admin")), true);
assert.equal(canChangeAccess("admin", member("admin")), false);
assert.equal(canChangeAccess("admin", member("launcher")), true);
assert.equal(canChangeAccess("member", member("launcher")), false);
});
+80
View File
@@ -22,6 +22,7 @@ const ACTION_KEYS: Record<string, string> = {
"member.join": "memberJoin",
"member.remove": "memberRemove",
"member.role_change": "memberRoleChange",
"member.access_change": "memberAccessChange",
"invitation.cancel": "invitationCancel",
"profile.launch": "profileLaunch",
"profile.stop": "profileStop",
@@ -125,11 +126,90 @@ function shortId(id: string): string {
export const ACTIVITY_PAGE = 50;
/** Which part of the log to read. Empty fields read everything. */
export interface ActivityFilter {
actorUserId?: string;
targetType?: string;
/** ISO 8601: the oldest entry to include. */
since?: string;
}
/** The item types the log can be narrowed to, as the cloud names them. */
export const ACTIVITY_TARGET_TYPES = [
"profile",
"proxy",
"group",
"vpn",
"extension",
"template",
"status",
"member",
] as const;
/** How far back the log reads, in days, or every entry. */
export const ACTIVITY_PERIODS = ["1", "7", "30", "all"] as const;
export type ActivityPeriod = (typeof ACTIVITY_PERIODS)[number];
/** The `since` for a period, counted back from `now`. */
export function sinceFor(
period: ActivityPeriod,
now: Date = new Date(),
): string | undefined {
if (period === "all") return undefined;
return new Date(now.getTime() - Number(period) * 86_400_000).toISOString();
}
export function listTeamActivity(
cursor: string | null,
filter: ActivityFilter = {},
): Promise<TeamActivityPage> {
return invoke<TeamActivityPage>("list_team_activity", {
cursor,
limit: ACTIVITY_PAGE,
filter,
});
}
/** Saves the filtered log as CSV at `path`; resolves to the row count. */
export function exportTeamActivityCsv(
path: string,
filter: ActivityFilter = {},
): Promise<number> {
return invoke<number>("export_team_activity_csv", { path, filter });
}
/** A team member, as the cloud lists them. */
export interface TeamMember {
userId: string;
email: string | null;
role: string;
/** The groups this member may reach, or null for every group. */
groupAccess: string[] | null;
}
/** The access entry for profiles in no group. */
export const UNGROUPED_ACCESS = "__ungrouped__";
export function listTeamMembers(): Promise<TeamMember[]> {
return invoke<TeamMember[] | null>("list_team_members").then(
(members) => members ?? [],
);
}
/** Limits a member to `groups`, or gives back every group with null. */
export function setTeamMemberAccess(
userId: string,
groups: string[] | null,
): Promise<TeamMember> {
return invoke<TeamMember>("set_team_member_access", { userId, groups });
}
/** Whether `actor` may change the access of `member`. The cloud checks again. */
export function canChangeAccess(
actorRole: string | undefined,
member: TeamMember,
): boolean {
if (member.role === "owner") return false;
if (actorRole === "owner") return true;
return actorRole === "admin" && member.role !== "admin";
}