mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 22:41:22 +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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}} グループ"
|
||||
}
|
||||
}
|
||||
@@ -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}}개 그룹"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}} группы"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}} 个分组"
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user