feat: team roles and log

This commit is contained in:
zhom committed 2026-10-12 09:54:07 +04:00
1 parent 4d36e07cfe
commit 1e75f179e1
43 files changed
+2705 -86

No files matched your search

+26 -2
View File
@@ -40,14 +40,14 @@ donutbrowser/
│ ├── hooks/ # Event-driven React hooks, table preferences, filtering, and keyboard controls
│ ├── i18n/locales/ # Translations (de, en, es, fr, ja, ko, pt, ru, tr, vi, zh)
│ ├── generated/ # Build-generated third-party license inventory
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ ├── lib/ # Utilities (themes, toast, browser-utils, ui-sounds, schedules, team-activity)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
│ ├── src/
│ │ ├── lib.rs # Tauri command registration (100+ commands)
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
│ │ ├── browser.rs # Browser trait & launch logic
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs), bulk-edit patch (patch.rs), trash
│ │ ├── proxy_manager.rs # Proxy lifecycle, connection testing, per-proxy check history
│ │ ├── proxy_udp.rs # SOCKS5 UDP ASSOCIATE probe (yes/no/unknown UDP verdict)
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
@@ -55,11 +55,16 @@ donutbrowser/
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP tool engine, served over the remote bridge
│ │ ├── mcp_toolsets.rs # Optional MCP toolsets: which tools a client lists (core + opt-in sets)
│ │ ├── mcp_views.rs # Compact MCP views: profile summaries, filters, paging, size caps
│ │ ├── navigation_watch.rs # Whether a CDP action navigated, so a click waits only for a load that comes
│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control)
│ │ ├── mcp_integrations.rs # 20-client MCP installer: remote endpoint with bearer, format-preserving JSONC/TOML edits
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
│ │ ├── agent_console.rs # The person's side of connected MCP agents: activity, questions, notes, take-over, pause
│ │ ├── recipes.rs # Thin client for cloud recipes; storage and replay stay in the cloud
│ │ ├── agent_schedules.rs # Thin client for scheduled recipes (paid plans; the cloud checks the plan)
│ │ ├── cloud_api.rs # Signed-in JSON calls to Donut cloud, with token refresh and error codes
│ │ ├── feedback.rs # Feedback to the Donut team from the dialog and the send_feedback MCP tool, optional redacted logs
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
@@ -73,7 +78,11 @@ donutbrowser/
│ │ ├── system_proxy.rs # The OS proxy Donut's own requests use; named in the error when it is unreachable
│ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── local_storage.rs # Local Storage LevelDB read/write, Playwright storageState import/export
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
│ │ ├── profile_templates.rs # Synced profile templates and bulk create from a template
│ │ ├── profile_statuses.rs # Synced profile statuses (name + color) set on profiles
│ │ ├── totp.rs # RFC 6238 codes; profile_totp.rs keeps sealed per-profile keys (device only)
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
@@ -82,6 +91,8 @@ donutbrowser/
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer (pause/resume, hold a follower out, window layouts)
│ │ ├── legacy_autostart.rs # Removes the autostart entry the removed tray daemon (0.14-0.17) left behind
│ │ ├── team_roles.rs # Launcher seat: refuses team-data commands and MCP tools, read-only sync
│ │ ├── team_activity.rs # Team activity log: batched reports of member edits, owner/admin viewer
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
│ └── Cargo.toml # Rust dependencies
@@ -471,6 +482,19 @@ the PUT or S3 rejects it, hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Team roles and the activity log
- A `launcher` seat opens the team's profiles and keeps what a session changes, never the team's
settings. The sync server enforces it (`donut-sync` `LAUNCHER_WRITABLE`: only a profile's
`files/` and `manifest.json`); the app mirrors it so an edit never appears and then reverts.
- `team_roles.rs` lists every Tauri command and MCP tool that changes team data (profiles,
proxies, groups, VPNs, extensions, templates, statuses). A new command or tool of that kind goes
in the list; the tests check every name is registered. On a launcher seat the sync engine takes
the team's copy of every setting and skips its own uploads and deletes of them.
- `team_activity::record` notes a person's edit of a synced entity, next to the `updated_at` bump.
Only a team seat with team collaboration reports; names leave the app only when E2E encryption
is off. Launches, stops and membership are written by the cloud itself, not reported.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source under the same license. A user attempting to rebrand the project away from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without written permission from the copyright holder.
+37
View File
@@ -92,6 +92,43 @@ describe("AuthGuard", () => {
});
});
it("keeps the team role the backend names for a team member", async () => {
const OWNER_ID = "99999999-8888-7777-6666-555555555555";
const answers = ["launcher", "superuser"];
jest.spyOn(globalThis, "fetch").mockImplementation(async () => {
const role = answers.shift();
return new Response(
JSON.stringify({
ownerId: OWNER_ID,
teamId: "team-1",
teamProfileLimit: 50,
role,
}),
);
});
const guard = () =>
guardWith({
SYNC_JWT_PUBLIC_KEY: signingKeys.publicKey,
BACKEND_INTERNAL_URL: "http://backend.internal",
BACKEND_INTERNAL_KEY: "internal-key",
});
const launcher = contextFor(syncToken());
await expect(guard().canActivate(launcher.context)).resolves.toBe(true);
expect(launcher.request.user).toEqual({
mode: "cloud",
prefix: `users/${OWNER_ID}/`,
profileLimit: 50,
sub: USER_ID,
teamRole: "launcher",
});
// A role this build does not know never widens what the member may do.
const unknown = contextFor(syncToken());
await expect(guard().canActivate(unknown.context)).resolves.toBe(true);
expect(unknown.request.user?.teamRole).toBe("member");
});
it("refuses an account access token signed with the same key", async () => {
const accessToken = sign({
sub: USER_ID,
+17 -1
View File
@@ -18,7 +18,20 @@ function safeEqual(a: string, b: string): boolean {
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
type TeamScope = { ownerId: string; teamId: string; teamProfileLimit: number };
type TeamScope = {
ownerId: string;
teamId: string;
teamProfileLimit: number;
role?: string;
};
const TEAM_ROLES = ["owner", "admin", "member", "launcher"] as const;
type TeamRole = (typeof TEAM_ROLES)[number];
/** A role the backend names, or `member` for one this build does not know. */
function teamRoleOf(role: unknown): TeamRole {
return TEAM_ROLES.includes(role as TeamRole) ? (role as TeamRole) : "member";
}
@Injectable()
export class AuthGuard implements CanActivate {
@@ -139,12 +152,14 @@ export class AuthGuard implements CanActivate {
// backend is the sole authority. On any resolver error we fail CLOSED:
// fall back to the user's own namespace, never widening to a team one.
let effectivePrefix = ownPrefix;
let teamRole: TeamRole | undefined;
let effectiveProfileLimit =
typeof decoded.profileLimit === "number" ? decoded.profileLimit : 0;
try {
const scope = sub ? await this.resolveTeamScope(sub) : null;
if (scope && /^[^/]+$/.test(scope.ownerId)) {
effectivePrefix = `users/${scope.ownerId}/`;
teamRole = teamRoleOf(scope.role);
if (scope.teamProfileLimit > 0) {
effectiveProfileLimit = scope.teamProfileLimit;
}
@@ -162,6 +177,7 @@ export class AuthGuard implements CanActivate {
prefix: effectivePrefix,
profileLimit: effectiveProfileLimit,
sub,
...(teamRole ? { teamRole } : {}),
} satisfies UserContext;
return true;
} catch (err) {
@@ -7,4 +7,8 @@ export interface UserContext {
prefix: string;
profileLimit: number; // 0 for unlimited (self-hosted); effective (team) limit for team members
sub?: string; // the authenticated user id (cloud only)
// The caller's role on the team whose namespace `prefix` is, from the
// backend. A `launcher` may run profiles and sync what a session changed,
// never change a profile's settings, proxies or groups.
teamRole?: "owner" | "admin" | "member" | "launcher";
}
+139
View File
@@ -0,0 +1,139 @@
import { ForbiddenException, Logger } from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
import type { UserContext } from "../auth/user-context.interface.js";
import { SyncService } from "./sync.service.js";
const OWNER_PREFIX = "users/99999999-8888-7777-6666-555555555555/";
function serviceWithStubbedS3(): { service: SyncService; send: jest.Mock } {
const env: Record<string, string> = {
S3_ENDPOINT: "http://s3.invalid:9000",
S3_ACCESS_KEY_ID: "test-access",
S3_SECRET_ACCESS_KEY: "test-secret",
S3_BUCKET: "test-bucket",
};
const config = { get: (key: string) => env[key] } as unknown as ConfigService;
const service = new SyncService(config);
const send = jest.fn().mockResolvedValue({ Contents: [] });
(service as unknown as { s3Client: { send: jest.Mock } }).s3Client = {
send,
};
return { service, send };
}
function teamContext(teamRole: UserContext["teamRole"]): UserContext {
return { mode: "cloud", prefix: OWNER_PREFIX, profileLimit: 0, teamRole };
}
async function refusalCode(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (err) {
expect(err).toBeInstanceOf(ForbiddenException);
return ((err as ForbiddenException).getResponse() as { code?: string })
.code;
}
throw new Error("the request was not refused");
}
describe("SyncService team roles", () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("lets a launcher sync what a session changed", async () => {
const { service } = serviceWithStubbedS3();
const launcher = teamContext("launcher");
for (const key of [
"profiles/p1/files/Default/Cookies",
"profiles/p1/manifest.json",
"teams/t1/profiles/p1/files/Default/Cookies",
"teams/t1/profiles/p1/manifest.json",
]) {
await expect(
service.presignUpload({ key }, launcher),
).resolves.toHaveProperty("url");
}
await expect(
service.presignUploadBatch(
{ items: [{ key: "profiles/p1/files/Default/Preferences" }] },
launcher,
),
).resolves.toHaveProperty("items");
await expect(
service.delete({ key: "profiles/p1/files/Default/old.log" }, launcher),
).resolves.toHaveProperty("deleted", true);
});
it("refuses every other write from a launcher before it reaches S3", async () => {
const { service, send } = serviceWithStubbedS3();
const launcher = teamContext("launcher");
for (const key of [
"profiles/p1/metadata.json",
"proxies/x.json",
"groups/g.json",
"teams/t1/profiles/p1/metadata.json",
"teams/t1/tombstones/profiles/p1.json",
"teams/t1/x/profiles/p1/manifest.json",
"profiles/p1/files",
"profiles/p1/files/",
]) {
await expect(
refusalCode(service.presignUpload({ key }, launcher)),
).resolves.toBe("TEAM_ROLE_READ_ONLY");
}
await expect(
refusalCode(
service.presignUploadBatch(
{
items: [
{ key: "profiles/p1/manifest.json" },
{ key: "profiles/p1/metadata.json" },
],
},
launcher,
),
),
).resolves.toBe("TEAM_ROLE_READ_ONLY");
await expect(
refusalCode(
service.delete(
{
key: "profiles/p1/files/Default/Cookies",
tombstoneKey: "tombstones/profiles/p1.json",
},
launcher,
),
),
).resolves.toBe("TEAM_ROLE_READ_ONLY");
await expect(
refusalCode(service.deletePrefix({ prefix: "profiles/p1/" }, launcher)),
).resolves.toBe("TEAM_ROLE_READ_ONLY");
expect(send).not.toHaveBeenCalled();
});
it("lets a member change profile settings and delete profiles", async () => {
const { service } = serviceWithStubbedS3();
const member = teamContext("member");
await expect(
service.presignUpload({ key: "profiles/p1/metadata.json" }, member),
).resolves.toHaveProperty("url");
await expect(
service.deletePrefix(
{
prefix: "profiles/p1/",
tombstoneKey: "tombstones/profiles/p1.json",
},
member,
),
).resolves.toHaveProperty("deletedCount", 0);
});
});
+37 -9
View File
@@ -78,6 +78,13 @@ function sanitizeMetadata(
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* What a launcher's session may write, relative to the team namespace. Team
* profiles live under `teams/<team id>/`, personal ones at the root.
*/
const LAUNCHER_WRITABLE =
/^(teams\/[^/]+\/)?profiles\/[^/]+\/(files\/.+|manifest\.json)$/;
@Injectable()
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
@@ -361,6 +368,22 @@ export class SyncService implements OnModuleInit {
throw new ForbiddenException("Access denied to this key");
}
/**
* A launcher may write only what a session changes: a profile's files and
* its manifest. Never its settings (`metadata.json`), the team's proxies,
* groups or other shared config, and never a whole profile or a tombstone.
*/
private validateWriteAccess(ctx: UserContext, key: string): void {
this.validateKeyAccess(ctx, key);
if (ctx.teamRole !== "launcher") return;
const relative = key.slice(ctx.prefix.length);
if (LAUNCHER_WRITABLE.test(relative)) return;
throw new ForbiddenException({
code: "TEAM_ROLE_READ_ONLY",
message: "This team role can run profiles but not change them",
});
}
async stat(dto: StatRequestDto, ctx: UserContext): Promise<StatResponseDto> {
const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key);
@@ -400,7 +423,7 @@ export class SyncService implements OnModuleInit {
ctx: UserContext,
): Promise<PresignUploadResponseDto> {
const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key);
this.validateWriteAccess(ctx, key);
// Check profile limit for cloud users
if (ctx.mode === "cloud" && ctx.profileLimit > 0) {
@@ -480,7 +503,11 @@ export class SyncService implements OnModuleInit {
ctx: UserContext,
): Promise<DeleteResponseDto> {
const key = this.scopeKey(ctx, dto.key);
this.validateKeyAccess(ctx, key);
this.validateWriteAccess(ctx, key);
const scopedTombstoneKey = dto.tombstoneKey
? this.scopeKey(ctx, dto.tombstoneKey)
: undefined;
if (scopedTombstoneKey) this.validateWriteAccess(ctx, scopedTombstoneKey);
let deleted = false;
let tombstoneCreated = false;
@@ -497,8 +524,7 @@ export class SyncService implements OnModuleInit {
deleted = false;
}
if (dto.tombstoneKey) {
const scopedTombstoneKey = this.scopeKey(ctx, dto.tombstoneKey);
if (scopedTombstoneKey) {
const tombstoneData = JSON.stringify({
id: key,
deleted_at: dto.deletedAt || new Date().toISOString(),
@@ -590,7 +616,7 @@ export class SyncService implements OnModuleInit {
const items = await Promise.all(
dto.items.map(async (item) => {
const key = this.scopeKey(ctx, item.key);
this.validateKeyAccess(ctx, key);
this.validateWriteAccess(ctx, key);
const command = new PutCmd({
Bucket: this.bucket,
@@ -685,10 +711,14 @@ export class SyncService implements OnModuleInit {
// (one already starting with its own/team scope, which scopeKey returns
// verbatim) could wipe an entire shared namespace. Enforce scope, and
// refuse an empty scoped prefix (which would match the whole scope).
this.validateKeyAccess(ctx, prefix);
this.validateWriteAccess(ctx, prefix);
if (ctx.mode === "cloud" && prefix.length === 0) {
throw new ForbiddenException("Refusing to delete an empty prefix");
}
const scopedTombstoneKey = dto.tombstoneKey
? this.scopeKey(ctx, dto.tombstoneKey)
: undefined;
if (scopedTombstoneKey) this.validateWriteAccess(ctx, scopedTombstoneKey);
let deletedCount = 0;
let tombstoneCreated = false;
let continuationToken: string | undefined;
@@ -729,9 +759,7 @@ export class SyncService implements OnModuleInit {
} while (continuationToken);
// Create tombstone if requested
if (dto.tombstoneKey && deletedCount > 0) {
const scopedTombstoneKey = this.scopeKey(ctx, dto.tombstoneKey);
this.validateKeyAccess(ctx, scopedTombstoneKey);
if (scopedTombstoneKey && deletedCount > 0) {
const tombstoneData = JSON.stringify({
prefix: dto.prefix,
deleted_at: dto.deletedAt || new Date().toISOString(),
+5
View File
@@ -377,6 +377,11 @@ export const commandCoverage = {
"agent_schedules::list_agent_schedule_runs",
],
},
teamActivity: {
suite: "integrations",
level: "contract",
commands: ["team_activity::list_team_activity"],
},
feedback: {
suite: "ui",
level: "integration",
+1 -1
View File
@@ -10,7 +10,7 @@ import { WebDriverClient } from "../lib/webdriver.mjs";
function registeredCommands(source) {
const match = source.match(
/invoke_handler\(tauri::generate_handler!\[(.*?)\]\)/s,
/invoke_handler\(team_roles::guarded\(tauri::generate_handler!\[(.*?)\]\)\)/s,
);
assert.ok(match, "Could not locate Tauri generate_handler! command registry");
const withoutComments = match[1].replace(/\/\/[^\n]*/g, "");
+10
View File
@@ -1607,6 +1607,16 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
// the app can check itself is checked before anything is sent.
const scheduleId = "00000000-0000-0000-0000-000000000000";
assert.match(await app.invokeError("list_agent_schedules"), notSignedIn);
// The team's log is read in the cloud, by owners and admins only.
// Without a team seat there is no log to ask for.
assert.match(
await app.invokeError("list_team_activity", {
cursor: null,
limit: 50,
}),
/"code":"TEAM_ACTIVITY_FORBIDDEN"/,
);
assert.match(
await app.invokeError("create_agent_schedule", {
schedule: {
+178 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { mkdir, realpath, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import test from "node:test";
@@ -3115,6 +3115,183 @@ test("scheduled recipes stay behind the plan, and a schedule is built from the f
);
});
test("a launcher seat opens the team's profiles but cannot change them, and an owner reads the team's log", async () => {
await withApp(
"ui-team-roles",
async (app) => {
const profile = await createUiProfile(app, "Team Profile");
const user = (teamRole) => ({
logged_in_at: new Date().toISOString(),
user: {
id: "ui-team-seat",
email: "seat@example.test",
plan: "team",
planPeriod: "monthly",
subscriptionStatus: "active",
profileLimit: 50,
cloudProfilesUsed: 0,
proxyBandwidthLimitMb: 0,
proxyBandwidthUsedMb: 0,
proxyBandwidthExtraMb: 0,
isPrimaryDevice: true,
teamId: "team-1",
teamName: "Shop team",
teamRole,
},
});
const signIn = async (teamRole) => {
await stubCommand(app, "cloud_get_user", user(teamRole));
await app.invoke("plugin:event|emit", {
event: "cloud-auth-changed",
payload: null,
});
};
// A paid seat opens the one-time welcome; close it when it comes.
const closeWelcome = async () => {
const opened = await app
.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[role="dialog"]'));`,
),
{ timeoutMs: 4000, description: "the paid welcome" },
)
.catch(() => false);
if (!opened) return;
await dismissSurface(app);
await app.waitFor(
() =>
app.execute(`return !document.querySelector('[role="dialog"]');`),
{ description: "the welcome closed" },
);
};
const launcherSeat = path.join(app.root, "donut", "e2e-team-read-only");
await writeFile(launcherSeat, "");
await signIn("launcher");
await closeWelcome();
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-testid="team-read-only-notice"]')?.innerText ?? null;`,
),
{ description: "the launcher notice" },
);
assert.equal(
await app.execute(
`return Boolean(document.querySelector('[data-onborda="create-profile"]'));`,
),
false,
"a launcher is not offered a new profile",
);
await app.capture("team-launcher-profiles");
// The app refuses the edit itself; the profile stays as the team set it.
assert.match(
await app.invokeError("rename_profile", {
profileId: profile.id,
newName: "Renamed by a launcher",
}),
/"code":"TEAM_ROLE_READ_ONLY"/,
);
assert.match(
await app.invokeError("create_profile_group", { name: "Blocked" }),
/"code":"TEAM_ROLE_READ_ONLY"/,
);
const profiles = await app.invoke("list_browser_profiles");
assert.equal(
profiles.find((p) => p.id === profile.id)?.name,
"Team Profile",
);
await stubCommand(app, "list_team_activity", {
items: [
{
id: 3,
at: new Date().toISOString(),
actor: {
userId: "u-admin",
email: "admin@example.test",
kind: "desktop",
},
action: "profile.update",
target: { type: "profile", id: profile.id, name: null },
detail: { fields: ["tags", "dns_server"] },
source: "client",
},
{
id: 2,
at: new Date(Date.now() - 60_000).toISOString(),
actor: {
userId: "u-launcher",
email: "launcher@example.test",
kind: "desktop",
},
action: "profile.launch",
target: { type: "profile", id: profile.id, name: null },
detail: null,
source: "server",
},
{
id: 1,
at: new Date(Date.now() - 120_000).toISOString(),
actor: {
userId: "u-launcher",
email: "launcher@example.test",
kind: "web",
},
action: "member.join",
target: { type: "member", id: "u-launcher", name: null },
detail: null,
source: "server",
},
],
nextCursor: null,
});
await rm(launcherSeat);
await signIn("owner");
await closeWelcome();
await app.clickSelector(`[aria-label="${en.rail.account}"]`);
await app.clickText(en.account.tabs.teamActivity, { roles: ["tab"] });
await app.waitFor(
() =>
app.execute(
`return document.querySelectorAll('[data-testid="team-activity"] li').length === 3;`,
),
{ description: "the team's log" },
);
const rows = await app.execute(
`return [...document.querySelectorAll('[data-testid="team-activity"] li')].map((row) => row.innerText);`,
);
// An id in the log reads as the name this computer has for it.
assert.match(
rows[0],
new RegExp(
en.teamActivity.actions.profileUpdate.replace(
"{{target}}",
"Team Profile",
),
),
);
assert.match(rows[0], new RegExp(en.teamActivity.fields.dnsServer));
assert.match(rows[0], new RegExp(en.teamActivity.via.desktop));
assert.match(
rows[1],
new RegExp(
en.teamActivity.actions.profileLaunch.replace(
"{{target}}",
"Team Profile",
),
),
);
assert.match(rows[2], new RegExp(en.teamActivity.actions.memberJoin));
await app.capture("team-activity-log");
await dismissSurface(app);
},
{ seedDownloadedBrowser: true },
);
});
test("the agent page guides setup, then shows agents, requests, notes, take-overs and the pause live", async () => {
await withApp(
"ui-agent",
+2 -1
View File
@@ -10,7 +10,7 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:team-activity && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:tips": "node --test src/lib/tips.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
@@ -27,6 +27,7 @@
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
"test:ui-sounds": "node --test src/lib/ui-sounds.test.mjs",
"test:schedules": "node --test src/lib/schedules.test.mjs",
"test:team-activity": "node --test src/lib/team-activity.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
+9
View File
@@ -423,12 +423,19 @@ pub struct CloudAuthManager {
wayfern_device_refusal: std::sync::Mutex<Option<String>>,
}
/// Tell what depends on the team seat that the signed-in account changed.
fn observe_account(user: Option<&CloudUser>) {
crate::team_roles::observe(user);
crate::team_activity::observe(user);
}
pub static CLOUD_AUTH: std::sync::LazyLock<CloudAuthManager> =
std::sync::LazyLock::new(CloudAuthManager::new);
impl CloudAuthManager {
fn new() -> Self {
let state = Self::load_auth_state_from_disk();
observe_account(state.as_ref().map(|auth| &auth.user));
// Bound every cloud API call so no single slow / hung request can stall
// the startup chain (sync-token → proxy-config → wayfern-token), which
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
@@ -511,6 +518,7 @@ impl CloudAuthManager {
}
fn store_auth_state(state: &CloudAuthState) -> Result<(), String> {
observe_account(Some(&state.user));
let path = Self::get_settings_dir().join("cloud_auth_state.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
@@ -1352,6 +1360,7 @@ impl CloudAuthManager {
async fn clear_auth(&self) {
let mut state = self.state.lock().await;
*state = None;
observe_account(None);
Self::delete_all_cloud_files();
crate::cookie_bot::forget_enrolments();
}
+23
View File
@@ -760,6 +760,7 @@ impl ExtensionManager {
}
if ext.sync_enabled {
crate::team_activity::record("extension.add", &ext.id, Some(&ext.name), None);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = ext.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1045,6 +1046,7 @@ impl ExtensionManager {
}
if ext.sync_enabled {
crate::team_activity::record("extension.update", &ext.id, Some(&ext.name), None);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let eid = ext.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1080,6 +1082,7 @@ impl ExtensionManager {
}
if ext.sync_enabled {
crate::team_activity::record("extension.delete", &ext.id, Some(&ext.name), None);
let ext_id = id.to_string();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
@@ -1157,6 +1160,7 @@ impl ExtensionManager {
}
if group.sync_enabled {
crate::team_activity::record("extension.group_create", &group.id, Some(&group.name), None);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = group.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1230,6 +1234,12 @@ impl ExtensionManager {
}
if updated.sync_enabled {
crate::team_activity::record(
"extension.group_update",
&updated.id,
Some(&updated.name),
None,
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let gid = updated.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1274,6 +1284,7 @@ impl ExtensionManager {
}
if was_sync_enabled {
crate::team_activity::record("extension.group_delete", id, None, None);
let group_id_owned = id.to_string();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
@@ -1329,6 +1340,12 @@ impl ExtensionManager {
}
if updated.sync_enabled {
crate::team_activity::record(
"extension.group_update",
&updated.id,
Some(&updated.name),
crate::team_activity::fields(&["extensions"]),
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let gid = updated.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1363,6 +1380,12 @@ impl ExtensionManager {
}
if updated.sync_enabled {
crate::team_activity::record(
"extension.group_update",
&updated.id,
Some(&updated.name),
crate::team_activity::fields(&["extensions"]),
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let gid = updated.id.clone();
tauri::async_runtime::spawn(async move {
+14
View File
@@ -126,6 +126,7 @@ impl GroupManager {
}
if group.sync_enabled {
crate::team_activity::record("group.create", &group.id, Some(&group.name), None);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = group.id.clone();
tauri::async_runtime::spawn(async move {
@@ -184,6 +185,12 @@ impl GroupManager {
}
if updated_group.sync_enabled {
crate::team_activity::record(
"group.update",
&updated_group.id,
Some(&updated_group.name),
None,
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_group.id.clone();
tauri::async_runtime::spawn(async move {
@@ -278,6 +285,7 @@ impl GroupManager {
// If sync was enabled, also delete from S3
if was_sync_enabled {
crate::team_activity::record("group.delete", &id, None, None);
let group_id_owned = id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
@@ -336,6 +344,12 @@ impl GroupManager {
}
if updated_group.sync_enabled {
crate::team_activity::record(
"group.update",
&updated_group.id,
Some(&updated_group.name),
crate::team_activity::fields(&["bookmarks"]),
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_group.id.clone();
tauri::async_runtime::spawn(async move {
+9 -2
View File
@@ -158,7 +158,9 @@ mod mcp_toolsets;
mod mcp_views;
mod navigation_watch;
mod tag_manager;
pub mod team_activity;
mod team_lock;
pub mod team_roles;
mod totp;
mod vault;
mod version_updater;
@@ -1492,6 +1494,7 @@ async fn import_vpn_config(
match storage.import_config(&content, &filename, name.clone()) {
Ok(config) => {
if config.sync_enabled {
crate::team_activity::record("vpn.create", &config.id, Some(&config.name), None);
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1568,6 +1571,7 @@ async fn delete_vpn_config(app_handle: tauri::AppHandle, vpn_id: String) -> Resu
// If sync was enabled, also delete from remote
if was_sync_enabled {
crate::team_activity::record("vpn.delete", &vpn_id, None, None);
let vpn_id_clone = vpn_id.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
@@ -1608,6 +1612,7 @@ async fn create_vpn_config_manual(
};
if config.sync_enabled {
crate::team_activity::record("vpn.create", &config.id, Some(&config.name), None);
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1632,6 +1637,7 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
};
if config.sync_enabled {
crate::team_activity::record("vpn.update", &config.id, Some(&config.name), None);
if let Some(scheduler) = sync::get_global_scheduler() {
let id = config.id.clone();
tauri::async_runtime::spawn(async move {
@@ -3341,7 +3347,7 @@ pub fn run_with_builder(
Ok(())
})
.invoke_handler(tauri::generate_handler![
.invoke_handler(team_roles::guarded(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
@@ -3651,7 +3657,8 @@ pub fn run_with_builder(
unlock_profile,
lock_profile,
is_profile_locked,
])
team_activity::list_team_activity,
]))
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, _event| {
+9 -1
View File
@@ -2053,7 +2053,7 @@ fn typing_report(prefix: &str, inscription: &vellum::Inscription) -> String {
}
impl McpServer {
fn new() -> Self {
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(AsyncMutex::new(McpServerInner {
app_handle: None,
@@ -4542,6 +4542,14 @@ impl McpServer {
});
}
if let Some(refused) = crate::team_roles::tool_refusal(tool_name) {
return Err(McpError {
code: -32000,
message: refused,
data: None,
});
}
let human_tool = crate::agent_console::HUMAN_TOOLS.contains(&tool_name);
if let Err(refusal) =
crate::agent_console::check_call(Self::is_passive_tool(tool_name), &arguments)
+38
View File
@@ -144,6 +144,19 @@ fn merge_submitted_wayfern_config(
config
}
/// Note a person's change to a team profile in the team activity log.
/// Profiles that do not sync stay out of it: they are not the team's.
fn report_team_edit(profile: &BrowserProfile, action: &str, fields: &[&str]) {
if profile.is_sync_enabled() {
crate::team_activity::record(
action,
&profile.id.to_string(),
Some(&profile.name),
crate::team_activity::fields(fields),
);
}
}
impl ProfileManager {
fn new() -> Self {
Self {
@@ -690,6 +703,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.rename", &["name"]);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -805,7 +819,11 @@ impl ProfileManager {
emit_events: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let started = std::time::Instant::now();
let removed = self.find_profile(profile_id).ok();
let outcome = self.remove_profile_unlogged(app_handle, profile_id, permanent, emit_events)?;
if let Some(profile) = &removed {
report_team_edit(profile, "profile.delete", &[]);
}
log::info!(
"Profile deleted profile={profile_id} {outcome} elapsed_ms={}",
started.elapsed().as_millis()
@@ -985,6 +1003,9 @@ impl ProfileManager {
)?;
// The normal save path, so tag suggestions pick the profile up again.
self.save_profile(&profile)?;
if sync_mode != SyncMode::Disabled {
crate::team_activity::record("profile.restore", profile_id, Some(&profile.name), None);
}
log::info!("Profile restored from trash profile={profile_id}");
if let Err(e) = events::emit_empty("profiles-changed") {
@@ -1178,6 +1199,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["group"]);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
@@ -1235,6 +1257,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["tags"]);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -1272,6 +1295,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["note"]);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
@@ -1311,6 +1335,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["clear_on_close"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
@@ -1344,6 +1369,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["window_color"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
@@ -1371,6 +1397,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["launch_hook"]);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Event emit failed event=profile-updated err=\"{e}\"");
@@ -1403,6 +1430,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["proxy_bypass_rules"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
@@ -1430,6 +1458,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["dns_blocklist"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
@@ -1457,6 +1486,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["dns_server"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
@@ -1480,6 +1510,7 @@ impl ProfileManager {
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["start_urls"]);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
}
@@ -1514,6 +1545,7 @@ impl ProfileManager {
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.bulk_update", &[]);
any_synced |= profile.is_sync_enabled();
changed += 1;
}
@@ -1565,6 +1597,7 @@ impl ProfileManager {
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.create", &[]);
if profile.is_sync_enabled() {
Self::sync_patched_groups(patch);
}
@@ -1593,6 +1626,7 @@ impl ProfileManager {
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["status"]);
}
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
@@ -1848,6 +1882,7 @@ impl ProfileManager {
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["fingerprint"]);
log::info!("Wayfern config updated profile={profile_id}");
@@ -1902,6 +1937,7 @@ impl ProfileManager {
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["proxy"]);
// The cookie bot refuses a run on a profile with no exit node, using the
// copy of that fact the desktop last declared. Detaching a proxy has to
@@ -1970,6 +2006,7 @@ impl ProfileManager {
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["vpn"]);
// Same reason as the proxy path: a VPN is the profile's exit node too, and
// the server only knows what this machine last told it.
@@ -2014,6 +2051,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
report_team_edit(&profile, "profile.update", &["extension_group"]);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
+3
View File
@@ -97,6 +97,7 @@ pub fn create(name: &str, color: &str) -> Result<ProfileStatus, String> {
Ok(status)
})?;
config_entities::changed_locally::<ProfileStatus>(&status.id);
crate::team_activity::record("status.create", &status.id, Some(&status.name), None);
Ok(status)
}
@@ -122,6 +123,7 @@ pub fn update(status_id: &str, name: &str, color: &str) -> Result<ProfileStatus,
Ok(status.clone())
})?;
config_entities::changed_locally::<ProfileStatus>(&status.id);
crate::team_activity::record("status.update", &status.id, Some(&status.name), None);
Ok(status)
}
@@ -139,6 +141,7 @@ pub fn delete(status_id: &str) -> Result<(), String> {
.clear_status_references(status_id)
.map_err(|e| crate::wrap_backend_error(e, "Failed to clear the status from profiles"))?;
config_entities::deleted_locally::<ProfileStatus>(status_id);
crate::team_activity::record("status.delete", status_id, None, None);
Ok(())
}
+3
View File
@@ -265,6 +265,7 @@ pub async fn create(name: &str, settings: TemplateSettings) -> Result<ProfileTem
Ok(template)
})?;
config_entities::changed_locally::<ProfileTemplate>(&template.id);
crate::team_activity::record("template.create", &template.id, Some(&template.name), None);
log::info!("Template created template={}", template.id);
Ok(template)
}
@@ -293,6 +294,7 @@ pub async fn update(
Ok(template.clone())
})?;
config_entities::changed_locally::<ProfileTemplate>(&template.id);
crate::team_activity::record("template.update", &template.id, Some(&template.name), None);
Ok(template)
}
@@ -306,6 +308,7 @@ pub fn delete(template_id: &str) -> Result<(), String> {
return Err(code("TEMPLATE_NOT_FOUND"));
}
config_entities::deleted_locally::<ProfileTemplate>(template_id);
crate::team_activity::record("template.delete", template_id, None, None);
log::info!("Template deleted template={template_id}");
Ok(())
}
+13
View File
@@ -661,6 +661,12 @@ impl ProxyManager {
}
if stored_proxy.sync_enabled {
crate::team_activity::record(
"proxy.create",
&stored_proxy.id,
Some(&stored_proxy.name),
None,
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = stored_proxy.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1093,6 +1099,12 @@ impl ProxyManager {
}
if updated_proxy.sync_enabled {
crate::team_activity::record(
"proxy.update",
&updated_proxy.id,
Some(&updated_proxy.name),
None,
);
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let id = updated_proxy.id.clone();
tauri::async_runtime::spawn(async move {
@@ -1216,6 +1228,7 @@ impl ProxyManager {
// If sync was enabled, also delete from S3
if was_sync_enabled {
crate::team_activity::record("proxy.delete", proxy_id, None, None);
let proxy_id_owned = proxy_id.to_string();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
+4 -1
View File
@@ -282,7 +282,7 @@ impl SyncEngine {
match (local, stat.exists) {
(Some(item), true) => {
let remote_updated_at = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated_at > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated_at) {
self.download_config::<T>(id).await?;
} else if local_updated_at > remote_updated_at {
self.upload_config(&item).await?;
@@ -329,6 +329,9 @@ impl SyncEngine {
pub async fn delete_config(&self, kind: ConfigKind, id: &str) -> SyncResult<()> {
let folder = kind.folder();
if self.skips_remote_write(&format!("key={folder}/{id}.json")) {
return Ok(());
}
self
.client
.delete(
+99 -13
View File
@@ -525,15 +525,34 @@ pub fn is_sync_configured() -> bool {
pub struct SyncEngine {
pub(super) client: SyncClient,
/// A team launcher's engine: the team's copy of every setting wins, and
/// only what a session changed goes up (`team_roles.rs`).
pub(super) read_only: bool,
}
impl SyncEngine {
pub fn new(server_url: String, token: String) -> Self {
Self {
client: SyncClient::new(server_url, token),
read_only: false,
}
}
/// Whether the remote copy replaces the local one when both exist. On a
/// read-only engine any difference does, so a local change never sticks.
pub(super) fn remote_wins(&self, local_updated_at: u64, remote_updated_at: u64) -> bool {
remote_updated_at > local_updated_at
|| (self.read_only && local_updated_at != remote_updated_at)
}
/// Whether this engine must leave the remote copy of `what` alone.
pub(super) fn skips_remote_write(&self, what: &str) -> bool {
if self.read_only {
log::debug!("Sync write skipped, team role is read-only {what}");
}
self.read_only
}
pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result<Self, String> {
// Cloud auth takes priority
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
@@ -543,7 +562,9 @@ impl SyncEngine {
.await
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
.ok_or_else(|| "Cloud sync token not available".to_string())?;
return Ok(Self::new(url, token));
let mut engine = Self::new(url, token);
engine.read_only = crate::team_roles::is_read_only();
return Ok(engine);
}
// Fall back to self-hosted settings
@@ -623,6 +644,9 @@ impl SyncEngine {
json: &str,
updated_at: u64,
) -> SyncResult<()> {
if self.skips_remote_write(&format!("key={remote_key}")) {
return Ok(());
}
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
let mut meta = HashMap::new();
@@ -899,6 +923,11 @@ impl SyncEngine {
"remote manifest is not available yet",
));
}
if self.read_only && remote_manifest.is_none() {
return Ok(ProfileSyncOutcome::Skipped(
"the team role cannot add profiles",
));
}
// Compute diff
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
@@ -1225,13 +1254,12 @@ impl SyncEngine {
let local_updated = profile.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if local_updated > remote_updated {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
if remote_updated <= local_updated {
if !self.remote_wins(local_updated, remote_updated) {
if local_updated > remote_updated {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
}
return Ok(profile.clone());
}
@@ -1864,7 +1892,7 @@ impl SyncEngine {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated) {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated_at > remote_updated {
self.upload_proxy(&proxy).await?;
@@ -2018,7 +2046,7 @@ impl SyncEngine {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated) {
self.download_group(group_id, app_handle).await?;
} else if local_updated_at > remote_updated {
self.upload_group(&group).await?;
@@ -2141,6 +2169,9 @@ impl SyncEngine {
}
pub async fn delete_profile(&self, profile_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("profile={profile_id}")) {
return Ok(());
}
let prefix = format!("profiles/{}/", profile_id);
let tombstone_key = format!("tombstones/profiles/{}.json", profile_id);
@@ -2196,6 +2227,9 @@ impl SyncEngine {
}
async fn delete_profile_tombstones(&self, profile: &BrowserProfile) -> SyncResult<()> {
if self.skips_remote_write(&format!("tombstone profile={}", profile.id)) {
return Ok(());
}
for key in Self::profile_tombstone_keys(profile).await {
self.client.delete(&key, None).await?;
}
@@ -2213,6 +2247,9 @@ impl SyncEngine {
}
pub async fn delete_proxy(&self, proxy_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("proxy={proxy_id}")) {
return Ok(());
}
let remote_key = format!("proxies/{}.json", proxy_id);
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
@@ -2226,6 +2263,9 @@ impl SyncEngine {
}
pub async fn delete_group(&self, group_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("group={group_id}")) {
return Ok(());
}
let remote_key = format!("groups/{}.json", group_id);
let tombstone_key = format!("tombstones/groups/{}.json", group_id);
@@ -2260,7 +2300,7 @@ impl SyncEngine {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated) {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated_at > remote_updated {
self.upload_vpn(&vpn).await?;
@@ -2372,6 +2412,9 @@ impl SyncEngine {
}
pub async fn delete_vpn(&self, vpn_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("vpn={vpn_id}")) {
return Ok(());
}
let remote_key = format!("vpns/{}.json", vpn_id);
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
@@ -2419,7 +2462,7 @@ impl SyncEngine {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated) {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated_at > remote_updated {
self.upload_extension(&ext).await?;
@@ -2585,6 +2628,9 @@ impl SyncEngine {
}
pub async fn delete_extension(&self, ext_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("extension={ext_id}")) {
return Ok(());
}
let remote_key = format!("extensions/{}.json", ext_id);
let file_prefix = format!("extensions/{}/file/", ext_id);
let tombstone_key = format!("tombstones/extensions/{}.json", ext_id);
@@ -2630,7 +2676,7 @@ impl SyncEngine {
// Both exist - resolve by user-edit timestamp (last-write-wins).
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_updated > local_updated_at {
if self.remote_wins(local_updated_at, remote_updated) {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated_at > remote_updated {
self.upload_extension_group(&group).await?;
@@ -2736,6 +2782,9 @@ impl SyncEngine {
}
pub async fn delete_extension_group(&self, group_id: &str) -> SyncResult<()> {
if self.skips_remote_write(&format!("extension_group={group_id}")) {
return Ok(());
}
let remote_key = format!("extension_groups/{}.json", group_id);
let tombstone_key = format!("tombstones/extension_groups/{}.json", group_id);
@@ -3060,6 +3109,9 @@ impl SyncEngine {
false
};
if has_personal_tombstone || has_team_tombstone {
if self.skips_remote_write(&format!("leftovers profile={profile_id}")) {
continue;
}
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
match self.client.delete_prefix(&prefix, None).await {
Ok(_) => {
@@ -3707,6 +3759,20 @@ async fn apply_profile_sync_mode(
// copy this machine declared; without this, an enrolment keeps claiming a
// syncable profile every night after the user turned sync off.
crate::cookie_bot::report_profile_state(&profile);
if (old_mode == SyncMode::Disabled) == enabling {
// Turning sync on puts a profile in the team's hands; turning it off takes
// it out. Either is worth a line in the team's log.
crate::team_activity::record(
if enabling {
"profile.sync_on"
} else {
"profile.sync_off"
},
&profile_id,
Some(&profile.name),
None,
);
}
let _ = events::emit("profiles-changed", ());
@@ -4593,6 +4659,26 @@ pub async fn rollover_encryption_for_all_entities(
mod tests {
use super::*;
#[tokio::test]
async fn a_read_only_engine_takes_the_team_copy_of_any_change() {
let mut engine = SyncEngine::new("http://127.0.0.1:9".into(), "token".into());
assert!(engine.remote_wins(1, 2));
assert!(!engine.remote_wins(2, 1));
assert!(!engine.remote_wins(2, 2));
engine.read_only = true;
assert!(engine.remote_wins(2, 1));
assert!(!engine.remote_wins(2, 2));
// Nothing reaches the server: these would fail against the closed port.
let json = r#"{"id":"p1"}"#;
assert!(engine
.upload_config_json("proxies/p1.json", json, 2)
.await
.is_ok());
assert!(engine.delete_proxy("p1").await.is_ok());
assert!(engine.delete_profile("p1").await.is_ok());
}
#[test]
fn sync_calls_share_a_lock_only_for_the_same_profile() {
let first = profile_sync_lock("sync-lock-profile-1")
+277
View File
@@ -0,0 +1,277 @@
//! This computer's share of the team activity log: what a member changed
//! here (profiles, proxies, groups, VPNs, extensions, templates, statuses).
//! The cloud records sign-ins, membership changes and profile launches itself.
//!
//! Only a team seat whose plan includes team collaboration reports. Events
//! wait in memory, never on disk, and leave in batches. A batch that fails is
//! tried again a few times, then dropped: the log is a record, not a sync.
//! Names leave this computer only when the team's data is not end-to-end
//! encrypted, because the cloud then holds those names already; otherwise the
//! log carries ids, and a viewer shows its own local names for them.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use serde_json::{json, Value};
use crate::cloud_auth::CloudUser;
use crate::cloud_errors::FailureCodes;
use crate::log_streak::Streak;
/// The cloud's limits: events per report, and the longest id and name.
const BATCH: usize = 50;
const MAX_ID_CHARS: usize = 128;
const MAX_NAME_CHARS: usize = 200;
/// Events kept while the cloud cannot be reached. Older ones drop first.
const MAX_QUEUED: usize = 500;
const ATTEMPTS: u8 = 3;
const SETTLE: Duration = Duration::from_secs(5);
const RETRY_AFTER: Duration = Duration::from_secs(60);
const ACTIVITY_CODES: FailureCodes = FailureCodes {
bad_request: "TEAM_ACTIVITY_INVALID",
forbidden: "TEAM_ACTIVITY_FORBIDDEN",
not_found: "TEAM_ACTIVITY_FORBIDDEN",
conflict: "TEAM_ACTIVITY_INVALID",
};
static ENABLED: AtomicBool = AtomicBool::new(false);
static FLUSHING: AtomicBool = AtomicBool::new(false);
static QUEUE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
static REPORT: Streak = Streak::new(module_path!(), "Team activity report");
/// Record the signed-in account, or `None` once signed out.
pub fn observe(user: Option<&CloudUser>) {
let enabled =
user.is_some_and(|user| user.team_id.is_some() && user.entitlements().team_collaboration);
if !enabled {
queue().clear();
}
ENABLED.store(enabled, Ordering::Relaxed);
}
fn queue() -> std::sync::MutexGuard<'static, VecDeque<Value>> {
QUEUE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn clip(value: &str, max: usize) -> String {
value.chars().take(max).collect()
}
/// One event, as the cloud takes it. `action` is `<target type>.<verb>`.
fn event(action: &str, id: &str, name: Option<&str>, detail: Option<Value>, names: bool) -> Value {
let target_type = action.split('.').next().unwrap_or_default();
let mut event = json!({
"action": action,
"targetType": target_type,
"targetId": clip(id, MAX_ID_CHARS),
});
if let Some(name) = name.filter(|name| names && !name.trim().is_empty()) {
event["targetName"] = json!(clip(name.trim(), MAX_NAME_CHARS));
}
if let Some(detail) = detail.filter(|detail| detail.as_object().is_some_and(|o| !o.is_empty())) {
event["detail"] = detail;
}
event
}
/// Note that the person changed `id`. Free when this seat does not report.
pub fn record(action: &str, id: &str, name: Option<&str>, detail: Option<Value>) {
if !ENABLED.load(Ordering::Relaxed) {
return;
}
let names = !crate::sync::encryption::has_e2e_password();
{
let mut queue = queue();
if queue.len() >= MAX_QUEUED {
queue.pop_front();
}
queue.push_back(event(action, id, name, detail, names));
}
if !FLUSHING.swap(true, Ordering::AcqRel) {
tauri::async_runtime::spawn(flush());
}
}
/// The fields an edit changed, as the detail the log shows.
pub fn fields(fields: &[&str]) -> Option<Value> {
(!fields.is_empty()).then(|| json!({ "fields": fields }))
}
async fn flush() {
tokio::time::sleep(SETTLE).await;
let mut attempts = 0u8;
loop {
let batch: Vec<Value> = {
let mut queue = queue();
let take = queue.len().min(BATCH);
queue.drain(..take).collect()
};
if batch.is_empty() {
FLUSHING.store(false, Ordering::Release);
// An event recorded between the drain and the flag store would wait for
// the next one; take it now instead.
if queue().is_empty() || FLUSHING.swap(true, Ordering::AcqRel) {
return;
}
continue;
}
let count = batch.len();
let url = format!("{}/api/teams/activity", crate::cloud_auth::CLOUD_API_URL);
let sent = crate::cloud_api::call::<Option<Value>>(
reqwest::Method::POST,
url,
Some(json!({ "events": batch })),
ACTIVITY_CODES,
)
.await;
match sent {
Ok(_) => {
REPORT.succeeded();
attempts = 0;
log::debug!("Team activity reported events={count}");
}
Err(err) if err.status == 400 || err.status == 403 => {
// Not entitled any more, or events this build cannot shape: sending
// them again changes nothing.
log::warn!(
"Team activity dropped events={count} code={} status={}",
err.code,
err.status
);
}
Err(err) => {
REPORT.failed(format!("code={} status={}", err.code, err.status));
attempts += 1;
if attempts >= ATTEMPTS {
log::warn!("Team activity dropped events={count} attempts={attempts}");
attempts = 0;
} else {
let mut queue = queue();
for event in batch.into_iter().rev() {
queue.push_front(event);
}
queue.truncate(MAX_QUEUED);
}
tokio::time::sleep(RETRY_AFTER).await;
}
}
}
}
/// A page of the team's log, newest first. Owners and admins only; the cloud
/// checks the role and the plan.
#[tauri::command]
pub async fn list_team_activity(
cursor: Option<String>,
limit: Option<u32>,
action: Option<String>,
) -> Result<Value, String> {
let Some(team_id) = crate::cloud_auth::CLOUD_AUTH
.get_user()
.await
.and_then(|auth| auth.user.team_id)
else {
return Err(crate::backend_error("TEAM_ACTIVITY_FORBIDDEN"));
};
let url = activity_url(&team_id, cursor.as_deref(), limit, action.as_deref())?;
crate::cloud_api::call::<Value>(reqwest::Method::GET, url, None, ACTIVITY_CODES)
.await
.map_err(|err| {
log::warn!(
"Team activity request failed code={} status={}",
err.code,
err.status
);
err.to_error_json()
})
}
fn activity_url(
team_id: &str,
cursor: Option<&str>,
limit: Option<u32>,
action: Option<&str>,
) -> Result<String, String> {
let mut query = Vec::new();
if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) {
if cursor.len() > 15 || !cursor.bytes().all(|b| b.is_ascii_digit()) {
return Err(crate::backend_error("TEAM_ACTIVITY_INVALID"));
}
query.push(format!("cursor={cursor}"));
}
if let Some(limit) = limit {
query.push(format!("limit={}", limit.clamp(1, 200)));
}
if let Some(action) = action.filter(|action| !action.is_empty()) {
query.push(format!("action={}", urlencoding::encode(&clip(action, 64))));
}
let mut url = format!(
"{}/api/teams/{}/activity",
crate::cloud_auth::CLOUD_API_URL,
urlencoding::encode(team_id)
);
if !query.is_empty() {
url.push('?');
url.push_str(&query.join("&"));
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_event_names_its_target_only_when_allowed() {
let named = event(
"profile.rename",
"p1",
Some(" Shop "),
fields(&["name"]),
true,
);
assert_eq!(named["targetType"], "profile");
assert_eq!(named["targetName"], "Shop");
assert_eq!(named["detail"], json!({ "fields": ["name"] }));
let sealed = event("profile.rename", "p1", Some("Shop"), None, false);
assert!(sealed.get("targetName").is_none());
assert!(sealed.get("detail").is_none());
}
#[test]
fn an_event_fits_the_cloud_limits() {
let long = "x".repeat(300);
let clipped = event("proxy.update", &long, Some(&long), None, true);
assert_eq!(clipped["targetId"].as_str().unwrap().len(), MAX_ID_CHARS);
assert_eq!(
clipped["targetName"].as_str().unwrap().len(),
MAX_NAME_CHARS
);
}
#[test]
fn nothing_queues_for_a_seat_that_does_not_report() {
observe(None);
record("profile.delete", "p1", None, None);
assert!(queue().is_empty());
}
#[test]
fn the_activity_url_keeps_the_team_id_one_segment() {
let url = activity_url("a/b", Some("42"), Some(500), Some("profile.delete")).unwrap();
assert!(url.contains("/api/teams/a%2Fb/activity?"), "{url}");
assert!(url.contains("cursor=42"));
assert!(url.contains("limit=200"));
assert!(url.contains("action=profile.delete"));
assert!(activity_url("t1", None, None, None)
.unwrap()
.ends_with("/api/teams/t1/activity"));
assert!(activity_url("t1", Some("1;drop"), None, None).is_err());
}
}
+278
View File
@@ -0,0 +1,278 @@
//! What the signed-in team role may change on this computer.
//!
//! A `launcher` may open the team's profiles and keep what a session changes
//! (cookies, history, Local Storage), never change a profile's settings or the
//! team's proxies, groups, VPNs, extensions, templates or statuses. The sync
//! server refuses those writes whatever this side does; this mirror refuses the
//! edit up front, so the change does not appear here and then revert.
use std::sync::atomic::{AtomicBool, Ordering};
use crate::cloud_auth::CloudUser;
static READ_ONLY: AtomicBool = AtomicBool::new(false);
/// The commands that change team data. Session commands (launch, kill, open a
/// URL, cookie and storage-state import, proxy IP rotation) stay open.
const TEAM_DATA_COMMANDS: &[&str] = &[
// Profiles
"create_browser_profile_new",
"clone_profile",
"rename_profile",
"delete_profile",
"delete_selected_profiles",
"restore_trashed_profile",
"purge_trashed_profile",
"empty_trash",
"update_profile_proxy",
"update_profile_vpn",
"update_profile_tags",
"update_profile_note",
"update_profile_clear_on_close",
"update_profile_launch_hook",
"update_profile_window_color",
"update_profile_proxy_bypass_rules",
"update_profile_dns_blocklist",
"update_profile_dns_server",
"update_profile_start_urls",
"update_wayfern_config",
"match_profile_fingerprint_to_exit",
"bulk_update_profiles",
"bulk_create_profiles",
"import_browser_profiles",
"import_profile_archive",
"distribute_proxies_to_profiles",
"assign_profiles_to_group",
"assign_extension_group_to_profile",
"apply_group_bookmarks_to_profile",
"set_profile_sync_mode",
"set_profiles_status",
"add_profile_totp",
"delete_profile_totp",
"set_profile_password",
"change_profile_password",
"remove_profile_password",
// Proxies
"create_stored_proxy",
"update_stored_proxy",
"delete_stored_proxy",
"import_proxies_json",
"import_proxies_from_parsed",
"set_proxy_rotation_url",
"set_proxy_sync_enabled",
"create_cloud_location_proxy",
// Groups
"create_profile_group",
"update_profile_group",
"delete_profile_group",
"set_group_bookmarks",
"set_group_sync_enabled",
// VPNs
"import_vpn_config",
"create_vpn_config_manual",
"update_vpn_config",
"delete_vpn_config",
"set_vpn_sync_enabled",
// Extensions
"add_extension",
"add_unpacked_extension",
"update_extension",
"update_extension_from_path",
"delete_extension",
"create_extension_group",
"update_extension_group",
"delete_extension_group",
"add_extension_to_group",
"remove_extension_from_group",
"set_extension_sync_enabled",
"set_extension_group_sync_enabled",
// Templates and statuses
"create_profile_template",
"update_profile_template",
"delete_profile_template",
"save_profile_template_from_profile",
"create_profile_status",
"update_profile_status",
"delete_profile_status",
];
/// The MCP tools that change team data, refused the same way.
const TEAM_DATA_TOOLS: &[&str] = &[
"create_profile",
"import_browser_profiles",
"update_profile",
"delete_profile",
"bulk_update_profiles",
"create_profiles_from_template",
"update_profile_fingerprint",
"update_profile_proxy_bypass_rules",
"update_profile_dns_blocklist",
"assign_profiles_to_group",
"assign_extension_group_to_profile",
"distribute_proxies",
"create_group",
"update_group",
"delete_group",
"create_proxy",
"update_proxy",
"delete_proxy",
"import_proxies",
"import_vpn",
"delete_vpn",
"add_extension",
"update_extension",
"delete_extension",
"create_extension_group",
"update_extension_group",
"delete_extension_group",
"add_extension_to_group",
"remove_extension_from_group",
];
/// Whether `user` holds a seat that may run the team's profiles but not change
/// them.
pub fn is_launcher(user: &CloudUser) -> bool {
user.team_id.is_some() && user.team_role.as_deref() == Some("launcher")
}
/// Record the signed-in account, or `None` once signed out.
pub fn observe(user: Option<&CloudUser>) {
let read_only = user.is_some_and(is_launcher);
if READ_ONLY.swap(read_only, Ordering::Relaxed) != read_only {
log::info!("Team role changed read_only={read_only}");
}
}
/// Whether this computer may only read the team's data.
pub fn is_read_only() -> bool {
// The suites cannot sign in to a real team. A marker file in the session's
// data root stands in for the launcher seat while it exists.
#[cfg(feature = "e2e")]
if std::env::var_os("DONUTBROWSER_DATA_ROOT").is_some_and(|root| {
std::path::Path::new(&root)
.join("e2e-team-read-only")
.exists()
}) {
return true;
}
std::sync::LazyLock::force(&crate::cloud_auth::CLOUD_AUTH);
READ_ONLY.load(Ordering::Relaxed)
}
/// The refusal for `command`, when the signed-in role may not run it.
pub fn refusal(command: &str) -> Option<String> {
if !TEAM_DATA_COMMANDS.contains(&command) || !is_read_only() {
return None;
}
log::info!("Edit refused, team role is read-only command={command}");
Some(crate::backend_error("TEAM_ROLE_READ_ONLY"))
}
/// `handler`, refusing the team-data commands while the role is read-only.
pub fn guarded<R: tauri::Runtime>(
handler: impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static,
) -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static {
move |invoke| match refusal(invoke.message.command()) {
Some(refused) => {
invoke.resolver.reject(refused);
true
}
None => handler(invoke),
}
}
/// The refusal for the MCP tool `tool`, when the signed-in role may not run it.
pub fn tool_refusal(tool: &str) -> Option<String> {
if !TEAM_DATA_TOOLS.contains(&tool) || !is_read_only() {
return None;
}
log::info!("MCP tool refused, team role is read-only tool={tool}");
Some(crate::backend_error("TEAM_ROLE_READ_ONLY"))
}
#[cfg(test)]
pub(crate) fn team_data_commands() -> &'static [&'static str] {
TEAM_DATA_COMMANDS
}
#[cfg(test)]
mod tests {
use super::*;
fn user(team_id: Option<&str>, role: Option<&str>) -> CloudUser {
serde_json::from_value(serde_json::json!({
"id": "u1",
"email": "member@example.com",
"plan": "free",
"subscriptionStatus": "active",
"profileLimit": 0,
"cloudProfilesUsed": 0,
"proxyBandwidthLimitMb": 0,
"proxyBandwidthUsedMb": 0,
"teamId": team_id,
"teamRole": role,
}))
.unwrap()
}
#[test]
fn only_a_team_launcher_is_read_only() {
assert!(is_launcher(&user(Some("t1"), Some("launcher"))));
assert!(!is_launcher(&user(Some("t1"), Some("member"))));
assert!(!is_launcher(&user(Some("t1"), None)));
// A stale role from a team the account has left gives no restriction.
assert!(!is_launcher(&user(None, Some("launcher"))));
}
#[test]
fn every_guarded_command_is_registered() {
let lib = include_str!("lib.rs");
let start = lib.find("tauri::generate_handler![").unwrap();
let registry = &lib[start..start + lib[start..].find("])").unwrap()];
let registered: Vec<&str> = registry
.lines()
.map(|line| line.trim().trim_end_matches(','))
.map(|line| line.rsplit("::").next().unwrap_or(line))
.collect();
for command in team_data_commands() {
assert!(registered.contains(command), "{command} is not registered");
}
}
#[test]
fn every_guarded_tool_exists() {
let tools: Vec<String> = crate::mcp_server::McpServer::new()
.get_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
for tool in TEAM_DATA_TOOLS {
assert!(
tools.iter().any(|name| name == tool),
"{tool} is not an MCP tool"
);
}
for tool in [
"run_profile",
"kill_profile",
"navigate",
"import_profile_cookies",
] {
assert!(!TEAM_DATA_TOOLS.contains(&tool), "{tool}");
}
}
#[test]
fn session_commands_stay_open() {
for command in [
"launch_browser_profile",
"kill_browser_profile",
"open_url_with_profile",
"import_pasted_cookies",
"import_profile_storage_state",
"rotate_proxy_ip",
] {
assert!(!team_data_commands().contains(&command), "{command}");
}
}
}
+28 -8
View File
@@ -88,6 +88,7 @@ import {
canUseCookieBot,
canUseRemoteControl,
getEntitlements,
isTeamLauncher,
} from "@/lib/entitlements";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
@@ -457,6 +458,18 @@ export default function Home() {
const [agentInitialTab, setAgentInitialTab] =
useState<AgentTab>("conversation");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const teamReadOnly = isTeamLauncher(cloudUser);
const teamReadOnlyRef = useRef(teamReadOnly);
teamReadOnlyRef.current = teamReadOnly;
// A launcher's new profile would be refused; say so before the form is
// filled in, not after.
const openCreateProfile = useCallback(() => {
if (teamReadOnlyRef.current) {
showErrorToast(t("backendErrors.teamRoleReadOnly"));
return;
}
setCreateProfileDialogOpen(true);
}, [t]);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
// A settings section to land on, set by a tip's action for one opening.
const [settingsInitialSection, setSettingsInitialSection] = useState<
@@ -1102,7 +1115,7 @@ export default function Home() {
event.payload,
);
showErrorToast(t("errors.noProfilesForUrl"));
setCreateProfileDialogOpen(true);
if (!teamReadOnlyRef.current) setCreateProfileDialogOpen(true);
}),
);
@@ -2329,7 +2342,10 @@ export default function Home() {
<div className="flex h-dvh flex-col bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onCreateProfileDialogOpen={(open) =>
open ? openCreateProfile() : setCreateProfileDialogOpen(false)
}
canCreateProfiles={!teamReadOnly}
searchQuery={searchQuery}
onSearchQueryChange={changeProfileSearch}
groups={groupsData}
@@ -2362,6 +2378,14 @@ export default function Home() {
sessions={syncSessions}
onSessionChanged={applySession}
/>
{teamReadOnly && (
<p
className="mb-2 rounded-md bg-foreground/5 px-3 py-2 text-xs text-muted-foreground"
data-testid="team-read-only-notice"
>
{t("teamRoles.readOnlyNotice")}
</p>
)}
<ProfilesDataTable
profileStatuses={profileStatuses}
isLoading={isLoading && profiles.length === 0}
@@ -2424,9 +2448,7 @@ export default function Home() {
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
onCreateProfile={() => {
setCreateProfileDialogOpen(true);
}}
onCreateProfile={openCreateProfile}
onImportProfiles={() => {
handleRailNavigate("import");
}}
@@ -2623,9 +2645,7 @@ export default function Home() {
setProfileInfoOpenMethod("keyboard");
setProfileInfoDialog(profile);
}}
onCreateProfile={() => {
setCreateProfileDialogOpen(true);
}}
onCreateProfile={openCreateProfile}
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
+22 -3
View File
@@ -21,6 +21,7 @@ import {
RemoteHoursMeter,
} from "@/components/cookie-bot-shared";
import { LoadingButton } from "@/components/loading-button";
import { TeamActivityLog } from "@/components/team-activity-log";
import { TeamUsagePanel } from "@/components/team-usage-panel";
import {
AnimatedTabs,
@@ -45,6 +46,7 @@ import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { translateBackendError } from "@/lib/backend-errors";
import {
canUseCookieBot,
canViewTeamActivity,
effectivePlanOf,
getEntitlements,
isTeamOwner,
@@ -85,6 +87,7 @@ export function AccountPage({
// a refused launch.
const remoteHoursVisible = isLoggedIn && canUseCookieBot(user);
const showTeamUsage = remoteHoursVisible && isTeamOwner(user);
const showTeamActivity = isLoggedIn && canViewTeamActivity(user);
// A member's own row says "free" because the owner pays. The plan the seat
// is served under is the one the customer expects to read here, and the
// billing period slot names the seat instead, since a seat has no period.
@@ -95,7 +98,9 @@ export function AccountPage({
? t("sync.team.roleOwner")
: user?.teamRole === "admin"
? t("sync.team.roleAdmin")
: t("sync.team.roleMember");
: user?.teamRole === "launcher"
? t("sync.team.roleLauncher")
: t("sync.team.roleMember");
const seatLabel = user?.teamName
? t("account.teamSeat", { role: seatRole, team: user.teamName })
: t("account.teamSeatUnnamed", { role: seatRole });
@@ -110,7 +115,10 @@ export function AccountPage({
// click back to.
useEffect(() => {
if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account");
}, [showTeamUsage, activeTab]);
if (!showTeamActivity && activeTab === "team-activity") {
setActiveTab("account");
}
}, [showTeamUsage, showTeamActivity, activeTab]);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
@@ -283,7 +291,7 @@ export function AccountPage({
details.push({
id: "teamRole",
label: t("account.fields.teamRole"),
value: user.teamRole,
value: seatRole,
});
}
if (user.planPeriod) {
@@ -333,6 +341,11 @@ export function AccountPage({
{t("account.tabs.teamUsage")}
</AnimatedTabsTrigger>
)}
{showTeamActivity && (
<AnimatedTabsTrigger value="team-activity">
{t("account.tabs.teamActivity")}
</AnimatedTabsTrigger>
)}
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
@@ -544,6 +557,12 @@ export function AccountPage({
</AnimatedTabsContent>
)}
{showTeamActivity && (
<AnimatedTabsContent value="team-activity" className="mt-4">
<TeamActivityLog />
</AnimatedTabsContent>
)}
<AnimatedTabsContent
value="self-hosted"
className="@container mt-4"
+4 -1
View File
@@ -41,6 +41,8 @@ const ALL_FILTER_ID = "__all__";
interface Props {
onCreateProfileDialogOpen: (open: boolean) => void;
/** False for a team seat that may not add profiles. */
canCreateProfiles?: boolean;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
@@ -149,6 +151,7 @@ const SearchSyntaxHelp = () => {
const HomeHeader = ({
onCreateProfileDialogOpen,
canCreateProfiles = true,
searchQuery,
onSearchQueryChange,
groups,
@@ -438,7 +441,7 @@ const HomeHeader = ({
{showProfileToolbar && <SearchSyntaxHelp />}
{showProfileToolbar && (
{showProfileToolbar && canCreateProfiles && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0">
+232
View File
@@ -0,0 +1,232 @@
"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 { Skeleton } from "@/components/ui/skeleton";
import { translateBackendError } from "@/lib/backend-errors";
import {
actionKey,
actorKind,
fieldKeys,
listTeamActivity,
type TeamActivityItem,
targetLabel,
} from "@/lib/team-activity";
interface Named {
id: string;
name: string;
}
/**
* The names this computer has for the team's items. The log may carry ids
* only (the team's data is end-to-end encrypted), and a local name is also
* newer than the one written at the time.
*/
async function loadLocalNames(): Promise<Map<string, string>> {
const lists = await Promise.allSettled([
invoke<Named[]>("list_browser_profiles"),
invoke<Named[]>("get_stored_proxies"),
invoke<Named[]>("get_profile_groups"),
invoke<Named[]>("list_vpn_configs"),
invoke<Named[]>("list_extensions"),
invoke<Named[]>("list_extension_groups"),
invoke<Named[]>("list_profile_templates"),
invoke<Named[]>("list_profile_statuses"),
]);
const names = new Map<string, string>();
for (const list of lists) {
if (list.status !== "fulfilled" || !Array.isArray(list.value)) continue;
for (const item of list.value) {
if (item?.id && item.name) names.set(item.id, item.name);
}
}
return names;
}
export function TeamActivityLog() {
const { t, i18n } = useTranslation();
const [items, setItems] = React.useState<TeamActivityItem[]>([]);
const [nextCursor, setNextCursor] = React.useState<string | null>(null);
const [localNames, setLocalNames] = React.useState<Map<string, string>>(
() => new Map(),
);
const [isLoading, setIsLoading] = React.useState(true);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [page, names] = await Promise.all([
listTeamActivity(null),
loadLocalNames(),
]);
setItems(page.items);
setNextCursor(page.nextCursor);
setLocalNames(names);
} catch (err) {
setError(translateBackendError(t, err));
} finally {
setIsLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
}, [load]);
const loadMore = async () => {
if (!nextCursor) return;
setIsLoadingMore(true);
try {
const page = await listTeamActivity(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (err) {
setError(translateBackendError(t, err));
} finally {
setIsLoadingMore(false);
}
};
const formatAt = (at: string) => {
const date = new Date(at);
return Number.isNaN(date.getTime())
? at
: date.toLocaleString(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
});
};
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");
/** "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" &&
typeof from === "string" &&
typeof to === "string"
? `${roleLabel(from)} → ${roleLabel(to)}`
: null;
};
const describe = (item: TeamActivityItem) => {
const target =
targetLabel(item.target, localNames) ?? t("teamActivity.noTarget");
const key = actionKey(item.action);
return key
? t(`teamActivity.actions.${key}`, { target })
: t("teamActivity.actions.other", { action: item.action, target });
};
return (
<div className="flex flex-col gap-3" data-testid="team-activity">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-medium">{t("teamActivity.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("teamActivity.description")}
</p>
</div>
<Button
variant="soft"
size="sm"
onClick={() => void load()}
disabled={isLoading}
>
{t("common.buttons.refresh")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : error && items.length === 0 ? (
<p className="py-6 text-center text-xs text-destructive-text">
{error}
</p>
) : items.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
{t("teamActivity.empty")}
</p>
) : (
<ol className="flex flex-col gap-0.5">
{items.map((item) => {
const fields = fieldKeys(item.detail);
const kind = actorKind(item.actor.kind);
const notes = [
roleChange(item),
fields.length > 0 &&
t("teamActivity.changedFields", {
fields: fields
.map((field) => t(`teamActivity.fields.${field}`))
.join(", "),
}),
kind && kind !== "system" && t(`teamActivity.via.${kind}`),
].filter(Boolean);
return (
<li
key={item.id}
className="rounded-md px-2 py-1.5 hover:bg-foreground/5"
data-action={item.action}
>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate">
<span className="font-medium">
{item.actor.email ??
(item.actor.kind === "system"
? t("teamActivity.system")
: t("teamActivity.formerMember"))}
</span>{" "}
{describe(item)}
</span>
<time
className="shrink-0 text-xs tabular-nums text-muted-foreground"
dateTime={item.at}
>
{formatAt(item.at)}
</time>
</div>
{notes.length > 0 && (
<p className="truncate text-xs text-muted-foreground">
{notes.join(" · ")}
</p>
)}
</li>
);
})}
</ol>
)}
{error && items.length > 0 && (
<p className="text-xs text-destructive-text">{error}</p>
)}
{nextCursor && !isLoading && (
<Button
variant="subtle"
size="sm"
className="self-center"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{t("teamActivity.loadMore")}
</Button>
)}
</div>
);
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "Wird von {{email}} verwendet",
"profileLockedShort": "In Verwendung",
"cannotLaunchLocked": "Start nicht möglich: Das Profil wird von {{email}} verwendet",
"createdBy": "Erstellt von {{email}}"
"createdBy": "Erstellt von {{email}}",
"roleLauncher": "Starter"
},
"disabled": "Deaktiviert",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "Eine Remote-Sitzung verwendet dieses Profil. Warten Sie, bis ihre Arbeit wieder hier ist.",
"scheduledAutomationNotEntitled": "Geplante Rezepte sind Teil von Solo, Pro, Team und Enterprise.",
"agentScheduleInvalid": "Donut Cloud hat diesen Zeitplan nicht angenommen. Prüfen Sie die Tage, die Uhrzeit und die Profile.",
"agentScheduleNotFound": "Dieser Zeitplan existiert nicht mehr."
"agentScheduleNotFound": "Dieser Zeitplan existiert nicht mehr.",
"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."
},
"rail": {
"profiles": "Profile",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "Konto",
"selfHosted": "Selbst gehostet",
"teamUsage": "Team-Nutzung"
"teamUsage": "Team-Nutzung",
"teamActivity": "Teamaktivität"
},
"selfHosted": {
"title": "Selbst gehosteter Sync-Server",
@@ -3941,5 +3947,84 @@
"reply": "Wenn wir mehr Details brauchen, schreiben wir an {{email}}.",
"done": "Fertig"
}
},
"teamRoles": {
"readOnlyNotice": "Deine Teamrolle ist Starter: Du kannst die Profile des Teams öffnen, und was eine Sitzung ändert, bleibt erhalten. Einstellungen, Proxys und Gruppen bleiben so, wie das Team sie festgelegt hat."
},
"teamActivity": {
"title": "Teamaktivität",
"description": "Was Mitglieder mit den Profilen und geteilten Elementen des Teams getan haben, das Neueste zuerst. 90 Tage aufbewahrt, mit Enterprise 365 Tage.",
"empty": "Noch keine Aktivität.",
"loadMore": "Mehr laden",
"system": "Donut",
"formerMember": "Ein ehemaliges Mitglied",
"noTarget": "ein Element",
"changedFields": "Geändert: {{fields}}",
"actions": {
"memberInvite": "hat {{target}} eingeladen",
"memberJoin": "ist dem Team beigetreten",
"memberRemove": "hat {{target}} aus dem Team entfernt",
"memberRoleChange": "hat die Rolle von {{target}} geändert",
"invitationCancel": "hat die Einladung für {{target}} zurückgezogen",
"profileLaunch": "hat {{target}} geöffnet",
"profileStop": "hat {{target}} geschlossen",
"profileCreate": "hat {{target}} erstellt",
"profileRename": "hat {{target}} umbenannt",
"profileUpdate": "hat {{target}} geändert",
"profileBulkUpdate": "hat {{target}} in einer Sammelbearbeitung geändert",
"profileDelete": "hat {{target}} gelöscht",
"profileRestore": "hat {{target}} aus dem Papierkorb wiederhergestellt",
"profileSyncOn": "hat die Synchronisierung für {{target}} eingeschaltet",
"profileSyncOff": "hat die Synchronisierung für {{target}} ausgeschaltet",
"proxyCreate": "hat den Proxy {{target}} hinzugefügt",
"proxyUpdate": "hat den Proxy {{target}} geändert",
"proxyDelete": "hat den Proxy {{target}} gelöscht",
"groupCreate": "hat die Gruppe {{target}} erstellt",
"groupUpdate": "hat die Gruppe {{target}} geändert",
"groupDelete": "hat die Gruppe {{target}} gelöscht",
"vpnCreate": "hat das VPN {{target}} hinzugefügt",
"vpnUpdate": "hat das VPN {{target}} geändert",
"vpnDelete": "hat das VPN {{target}} gelöscht",
"extensionAdd": "hat die Erweiterung {{target}} hinzugefügt",
"extensionUpdate": "hat die Erweiterung {{target}} aktualisiert",
"extensionDelete": "hat die Erweiterung {{target}} gelöscht",
"extensionGroupCreate": "hat die Erweiterungsgruppe {{target}} erstellt",
"extensionGroupUpdate": "hat die Erweiterungsgruppe {{target}} geändert",
"extensionGroupDelete": "hat die Erweiterungsgruppe {{target}} gelöscht",
"templateCreate": "hat die Vorlage {{target}} erstellt",
"templateUpdate": "hat die Vorlage {{target}} geändert",
"templateDelete": "hat die Vorlage {{target}} gelöscht",
"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"
},
"fields": {
"name": "Name",
"group": "Gruppe",
"tags": "Tags",
"note": "Notiz",
"clearOnClose": "Beim Schließen löschen",
"windowColor": "Fensterfarbe",
"launchHook": "Start-Hook",
"proxyBypassRules": "Proxy-Ausnahmen",
"dnsBlocklist": "DNS-Sperrliste",
"dnsServer": "DNS-Server",
"startUrls": "Startseiten",
"status": "Status",
"fingerprint": "Fingerabdruck",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Erweiterungsgruppe",
"bookmarks": "Lesezeichen",
"extensions": "Erweiterungen"
},
"via": {
"desktop": "in der Desktop-App",
"web": "im Web",
"mcp": "durch einen MCP-Agenten",
"service": "mit einem API-Schlüssel",
"remote_vm": "in einem Cloud-Browser"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "In use by {{email}}",
"profileLockedShort": "In use",
"cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}",
"createdBy": "Created by {{email}}"
"createdBy": "Created by {{email}}",
"roleLauncher": "Launcher"
},
"disabled": "Disabled",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "A remote session owns this profile. Wait until its work is back here.",
"scheduledAutomationNotEntitled": "Scheduled recipes are part of Solo, Pro, Team and Enterprise.",
"agentScheduleInvalid": "Donut cloud did not accept this schedule. Check the days, the time and the profiles.",
"agentScheduleNotFound": "This schedule no longer exists."
"agentScheduleNotFound": "This schedule no longer exists.",
"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."
},
"rail": {
"profiles": "Profiles",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "Account",
"selfHosted": "Self-hosted",
"teamUsage": "Team usage"
"teamUsage": "Team usage",
"teamActivity": "Team activity"
},
"selfHosted": {
"title": "Self-hosted sync server",
@@ -3941,5 +3947,84 @@
"reply": "If we need more details, we will write to {{email}}.",
"done": "Done"
}
},
"teamRoles": {
"readOnlyNotice": "Your team role is Launcher: you can open the team's profiles, and what a session changes is kept. Settings, proxies and groups stay as the team set them."
},
"teamActivity": {
"title": "Team activity",
"description": "What members did with the team's profiles and shared items, newest first. Kept for 90 days, or 365 days on Enterprise.",
"empty": "No activity yet.",
"loadMore": "Load more",
"system": "Donut",
"formerMember": "A former member",
"noTarget": "an item",
"changedFields": "Changed: {{fields}}",
"actions": {
"memberInvite": "invited {{target}}",
"memberJoin": "joined the team",
"memberRemove": "removed {{target}} from the team",
"memberRoleChange": "changed the role of {{target}}",
"invitationCancel": "cancelled the invitation for {{target}}",
"profileLaunch": "opened {{target}}",
"profileStop": "closed {{target}}",
"profileCreate": "created {{target}}",
"profileRename": "renamed {{target}}",
"profileUpdate": "changed {{target}}",
"profileBulkUpdate": "changed {{target}} in a bulk edit",
"profileDelete": "deleted {{target}}",
"profileRestore": "restored {{target}} from the trash",
"profileSyncOn": "turned on sync for {{target}}",
"profileSyncOff": "turned off sync for {{target}}",
"proxyCreate": "added the proxy {{target}}",
"proxyUpdate": "changed the proxy {{target}}",
"proxyDelete": "deleted the proxy {{target}}",
"groupCreate": "created the group {{target}}",
"groupUpdate": "changed the group {{target}}",
"groupDelete": "deleted the group {{target}}",
"vpnCreate": "added the VPN {{target}}",
"vpnUpdate": "changed the VPN {{target}}",
"vpnDelete": "deleted the VPN {{target}}",
"extensionAdd": "added the extension {{target}}",
"extensionUpdate": "updated the extension {{target}}",
"extensionDelete": "deleted the extension {{target}}",
"extensionGroupCreate": "created the extension group {{target}}",
"extensionGroupUpdate": "changed the extension group {{target}}",
"extensionGroupDelete": "deleted the extension group {{target}}",
"templateCreate": "created the template {{target}}",
"templateUpdate": "changed the template {{target}}",
"templateDelete": "deleted the template {{target}}",
"statusCreate": "created the status {{target}}",
"statusUpdate": "changed the status {{target}}",
"statusDelete": "deleted the status {{target}}",
"other": "did {{action}} on {{target}}"
},
"fields": {
"name": "Name",
"group": "Group",
"tags": "Tags",
"note": "Note",
"clearOnClose": "Clear on close",
"windowColor": "Window color",
"launchHook": "Launch hook",
"proxyBypassRules": "Proxy bypass rules",
"dnsBlocklist": "DNS blocklist",
"dnsServer": "DNS server",
"startUrls": "Start pages",
"status": "Status",
"fingerprint": "Fingerprint",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Extension group",
"bookmarks": "Bookmarks",
"extensions": "Extensions"
},
"via": {
"desktop": "from the desktop app",
"web": "from the web",
"mcp": "by an MCP agent",
"service": "with an API key",
"remote_vm": "from a cloud browser"
}
}
}
+88 -3
View File
@@ -843,7 +843,8 @@
"profileLocked": "En uso por {{email}}",
"profileLockedShort": "En uso",
"cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}",
"createdBy": "Creado por {{email}}"
"createdBy": "Creado por {{email}}",
"roleLauncher": "Lanzador"
},
"disabled": "Desactivada",
"toast": {
@@ -2661,7 +2662,11 @@
"localStorageRemoteSession": "Una sesión remota usa este perfil. Espera a que su trabajo vuelva aquí.",
"scheduledAutomationNotEntitled": "Las recetas programadas forman parte de Solo, Pro, Team y Enterprise.",
"agentScheduleInvalid": "Donut cloud no aceptó esta programación. Revisa los días, la hora y los perfiles.",
"agentScheduleNotFound": "Esta programación ya no existe."
"agentScheduleNotFound": "Esta programación ya no existe.",
"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."
},
"rail": {
"profiles": "Perfiles",
@@ -2750,7 +2755,8 @@
"tabs": {
"account": "Cuenta",
"selfHosted": "Autoalojado",
"teamUsage": "Uso del equipo"
"teamUsage": "Uso del equipo",
"teamActivity": "Actividad del equipo"
},
"selfHosted": {
"title": "Servidor de sincronización autoalojado",
@@ -3984,5 +3990,84 @@
"reply": "Si necesitamos más detalles, te escribiremos a {{email}}.",
"done": "Listo"
}
},
"teamRoles": {
"readOnlyNotice": "Tu rol en el equipo es Lanzador: puedes abrir los perfiles del equipo y se guarda lo que cambia una sesión. Los ajustes, proxies y grupos se quedan como el equipo los dejó."
},
"teamActivity": {
"title": "Actividad del equipo",
"description": "Lo que los miembros hicieron con los perfiles y elementos compartidos del equipo, lo más reciente primero. Se guarda 90 días, o 365 días en Enterprise.",
"empty": "Aún no hay actividad.",
"loadMore": "Cargar más",
"system": "Donut",
"formerMember": "Un antiguo miembro",
"noTarget": "un elemento",
"changedFields": "Cambiado: {{fields}}",
"actions": {
"memberInvite": "invitó a {{target}}",
"memberJoin": "se unió al equipo",
"memberRemove": "quitó a {{target}} del equipo",
"memberRoleChange": "cambió el rol de {{target}}",
"invitationCancel": "canceló la invitación de {{target}}",
"profileLaunch": "abrió {{target}}",
"profileStop": "cerró {{target}}",
"profileCreate": "creó {{target}}",
"profileRename": "renombró {{target}}",
"profileUpdate": "cambió {{target}}",
"profileBulkUpdate": "cambió {{target}} en una edición masiva",
"profileDelete": "eliminó {{target}}",
"profileRestore": "restauró {{target}} de la papelera",
"profileSyncOn": "activó la sincronización de {{target}}",
"profileSyncOff": "desactivó la sincronización de {{target}}",
"proxyCreate": "añadió el proxy {{target}}",
"proxyUpdate": "cambió el proxy {{target}}",
"proxyDelete": "eliminó el proxy {{target}}",
"groupCreate": "creó el grupo {{target}}",
"groupUpdate": "cambió el grupo {{target}}",
"groupDelete": "eliminó el grupo {{target}}",
"vpnCreate": "añadió la VPN {{target}}",
"vpnUpdate": "cambió la VPN {{target}}",
"vpnDelete": "eliminó la VPN {{target}}",
"extensionAdd": "añadió la extensión {{target}}",
"extensionUpdate": "actualizó la extensión {{target}}",
"extensionDelete": "eliminó la extensión {{target}}",
"extensionGroupCreate": "creó el grupo de extensiones {{target}}",
"extensionGroupUpdate": "cambió el grupo de extensiones {{target}}",
"extensionGroupDelete": "eliminó el grupo de extensiones {{target}}",
"templateCreate": "creó la plantilla {{target}}",
"templateUpdate": "cambió la plantilla {{target}}",
"templateDelete": "eliminó la plantilla {{target}}",
"statusCreate": "creó el estado {{target}}",
"statusUpdate": "cambió el estado {{target}}",
"statusDelete": "eliminó el estado {{target}}",
"other": "hizo {{action}} en {{target}}"
},
"fields": {
"name": "Nombre",
"group": "Grupo",
"tags": "Etiquetas",
"note": "Nota",
"clearOnClose": "Borrar al cerrar",
"windowColor": "Color de ventana",
"launchHook": "Hook de inicio",
"proxyBypassRules": "Excepciones del proxy",
"dnsBlocklist": "Lista de bloqueo DNS",
"dnsServer": "Servidor DNS",
"startUrls": "Páginas de inicio",
"status": "Estado",
"fingerprint": "Huella digital",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Grupo de extensiones",
"bookmarks": "Marcadores",
"extensions": "Extensiones"
},
"via": {
"desktop": "desde la app de escritorio",
"web": "desde la web",
"mcp": "mediante un agente MCP",
"service": "con una clave de API",
"remote_vm": "desde un navegador en la nube"
}
}
}
+88 -3
View File
@@ -843,7 +843,8 @@
"profileLocked": "Utilisé par {{email}}",
"profileLockedShort": "En cours d'utilisation",
"cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}",
"createdBy": "Créé par {{email}}"
"createdBy": "Créé par {{email}}",
"roleLauncher": "Lanceur"
},
"disabled": "Désactivée",
"toast": {
@@ -2661,7 +2662,11 @@
"localStorageRemoteSession": "Une session distante utilise ce profil. Attendez que son travail soit revenu ici.",
"scheduledAutomationNotEntitled": "Les recettes planifiées font partie de Solo, Pro, Team et Enterprise.",
"agentScheduleInvalid": "Donut cloud n'a pas accepté cette planification. Vérifiez les jours, l'heure et les profils.",
"agentScheduleNotFound": "Cette planification n'existe plus."
"agentScheduleNotFound": "Cette planification n'existe plus.",
"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."
},
"rail": {
"profiles": "Profils",
@@ -2750,7 +2755,8 @@
"tabs": {
"account": "Compte",
"selfHosted": "Auto-hébergé",
"teamUsage": "Utilisation de l'équipe"
"teamUsage": "Utilisation de l'équipe",
"teamActivity": "Activité de l’équipe"
},
"selfHosted": {
"title": "Serveur de synchronisation auto-hébergé",
@@ -3984,5 +3990,84 @@
"reply": "Si nous avons besoin de détails, nous vous écrirons à {{email}}.",
"done": "Terminé"
}
},
"teamRoles": {
"readOnlyNotice": "Votre rôle dans l’équipe est Lanceur : vous pouvez ouvrir les profils de l’équipe, et ce qu’une session modifie est conservé. Les réglages, proxys et groupes restent tels que l’équipe les a définis."
},
"teamActivity": {
"title": "Activité de l’équipe",
"description": "Ce que les membres ont fait avec les profils et éléments partagés de l’équipe, du plus récent au plus ancien. Conservé 90 jours, ou 365 jours avec Enterprise.",
"empty": "Aucune activité pour l’instant.",
"loadMore": "Charger plus",
"system": "Donut",
"formerMember": "Un ancien membre",
"noTarget": "un élément",
"changedFields": "Modifié : {{fields}}",
"actions": {
"memberInvite": "a invité {{target}}",
"memberJoin": "a rejoint l’équipe",
"memberRemove": "a retiré {{target}} de l’équipe",
"memberRoleChange": "a changé le rôle de {{target}}",
"invitationCancel": "a annulé l’invitation de {{target}}",
"profileLaunch": "a ouvert {{target}}",
"profileStop": "a fermé {{target}}",
"profileCreate": "a créé {{target}}",
"profileRename": "a renommé {{target}}",
"profileUpdate": "a modifié {{target}}",
"profileBulkUpdate": "a modifié {{target}} lors d’une modification groupée",
"profileDelete": "a supprimé {{target}}",
"profileRestore": "a restauré {{target}} depuis la corbeille",
"profileSyncOn": "a activé la synchronisation de {{target}}",
"profileSyncOff": "a désactivé la synchronisation de {{target}}",
"proxyCreate": "a ajouté le proxy {{target}}",
"proxyUpdate": "a modifié le proxy {{target}}",
"proxyDelete": "a supprimé le proxy {{target}}",
"groupCreate": "a créé le groupe {{target}}",
"groupUpdate": "a modifié le groupe {{target}}",
"groupDelete": "a supprimé le groupe {{target}}",
"vpnCreate": "a ajouté le VPN {{target}}",
"vpnUpdate": "a modifié le VPN {{target}}",
"vpnDelete": "a supprimé le VPN {{target}}",
"extensionAdd": "a ajouté l’extension {{target}}",
"extensionUpdate": "a mis à jour l’extension {{target}}",
"extensionDelete": "a supprimé l’extension {{target}}",
"extensionGroupCreate": "a créé le groupe d’extensions {{target}}",
"extensionGroupUpdate": "a modifié le groupe d’extensions {{target}}",
"extensionGroupDelete": "a supprimé le groupe d’extensions {{target}}",
"templateCreate": "a créé le modèle {{target}}",
"templateUpdate": "a modifié le modèle {{target}}",
"templateDelete": "a supprimé le modèle {{target}}",
"statusCreate": "a créé le statut {{target}}",
"statusUpdate": "a modifié le statut {{target}}",
"statusDelete": "a supprimé le statut {{target}}",
"other": "a effectué {{action}} sur {{target}}"
},
"fields": {
"name": "Nom",
"group": "Groupe",
"tags": "Étiquettes",
"note": "Note",
"clearOnClose": "Effacer à la fermeture",
"windowColor": "Couleur de la fenêtre",
"launchHook": "Hook de lancement",
"proxyBypassRules": "Exceptions du proxy",
"dnsBlocklist": "Liste de blocage DNS",
"dnsServer": "Serveur DNS",
"startUrls": "Pages de démarrage",
"status": "Statut",
"fingerprint": "Empreinte",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Groupe d’extensions",
"bookmarks": "Favoris",
"extensions": "Extensions"
},
"via": {
"desktop": "depuis l’app de bureau",
"web": "depuis le web",
"mcp": "par un agent MCP",
"service": "avec une clé d’API",
"remote_vm": "depuis un navigateur cloud"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "{{email}} が使用中",
"profileLockedShort": "使用中",
"cannotLaunchLocked": "起動できません — {{email}} がプロファイルを使用中です",
"createdBy": "{{email}} が作成"
"createdBy": "{{email}} が作成",
"roleLauncher": "ランチャー"
},
"disabled": "無効",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "リモートセッションがこのプロファイルを使用しています。作業がここに戻るまでお待ちください。",
"scheduledAutomationNotEntitled": "スケジュールされたレシピは Solo、Pro、Team、Enterprise に含まれます。",
"agentScheduleInvalid": "Donut クラウドがこのスケジュールを受け付けませんでした。曜日、時刻、プロファイルを確認してください。",
"agentScheduleNotFound": "このスケジュールはもう存在しません。"
"agentScheduleNotFound": "このスケジュールはもう存在しません。",
"teamRoleReadOnly": "あなたのチームロールはプロファイルを開けますが、変更はできません。チームのオーナーか管理者に依頼してください。",
"teamActivityForbidden": "チームのアクティビティを見られるのは、オーナーと管理者だけです。",
"teamActivityNotEntitled": "アクティビティログには、有効な Team または Enterprise プランが必要です。",
"teamActivityInvalid": "アクティビティログのリクエストが無効でした。"
},
"rail": {
"profiles": "プロファイル",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "アカウント",
"selfHosted": "セルフホスト",
"teamUsage": "チームの使用状況"
"teamUsage": "チームの使用状況",
"teamActivity": "チームのアクティビティ"
},
"selfHosted": {
"title": "セルフホスト同期サーバー",
@@ -3941,5 +3947,84 @@
"reply": "詳細が必要な場合は {{email}} にご連絡します。",
"done": "完了"
}
},
"teamRoles": {
"readOnlyNotice": "あなたのチームロールはランチャーです。チームのプロファイルを開くことができ、セッションでの変更は保存されます。設定、プロキシ、グループはチームが決めたままです。"
},
"teamActivity": {
"title": "チームのアクティビティ",
"description": "メンバーがチームのプロファイルと共有項目に対して行った操作です(新しい順)。90 日間、Enterprise では 365 日間保存されます。",
"empty": "まだアクティビティはありません。",
"loadMore": "さらに読み込む",
"system": "Donut",
"formerMember": "以前のメンバー",
"noTarget": "項目",
"changedFields": "変更: {{fields}}",
"actions": {
"memberInvite": "が {{target}} を招待しました",
"memberJoin": "がチームに参加しました",
"memberRemove": "が {{target}} をチームから外しました",
"memberRoleChange": "が {{target}} のロールを変更しました",
"invitationCancel": "が {{target}} への招待を取り消しました",
"profileLaunch": "が {{target}} を開きました",
"profileStop": "が {{target}} を閉じました",
"profileCreate": "が {{target}} を作成しました",
"profileRename": "が {{target}} の名前を変更しました",
"profileUpdate": "が {{target}} を変更しました",
"profileBulkUpdate": "が一括編集で {{target}} を変更しました",
"profileDelete": "が {{target}} を削除しました",
"profileRestore": "が {{target}} をゴミ箱から復元しました",
"profileSyncOn": "が {{target}} の同期をオンにしました",
"profileSyncOff": "が {{target}} の同期をオフにしました",
"proxyCreate": "がプロキシ {{target}} を追加しました",
"proxyUpdate": "がプロキシ {{target}} を変更しました",
"proxyDelete": "がプロキシ {{target}} を削除しました",
"groupCreate": "がグループ {{target}} を作成しました",
"groupUpdate": "がグループ {{target}} を変更しました",
"groupDelete": "がグループ {{target}} を削除しました",
"vpnCreate": "が VPN {{target}} を追加しました",
"vpnUpdate": "が VPN {{target}} を変更しました",
"vpnDelete": "が VPN {{target}} を削除しました",
"extensionAdd": "が拡張機能 {{target}} を追加しました",
"extensionUpdate": "が拡張機能 {{target}} を更新しました",
"extensionDelete": "が拡張機能 {{target}} を削除しました",
"extensionGroupCreate": "が拡張機能グループ {{target}} を作成しました",
"extensionGroupUpdate": "が拡張機能グループ {{target}} を変更しました",
"extensionGroupDelete": "が拡張機能グループ {{target}} を削除しました",
"templateCreate": "がテンプレート {{target}} を作成しました",
"templateUpdate": "がテンプレート {{target}} を変更しました",
"templateDelete": "がテンプレート {{target}} を削除しました",
"statusCreate": "がステータス {{target}} を作成しました",
"statusUpdate": "がステータス {{target}} を変更しました",
"statusDelete": "がステータス {{target}} を削除しました",
"other": "が {{target}} に {{action}} を実行しました"
},
"fields": {
"name": "名前",
"group": "グループ",
"tags": "タグ",
"note": "メモ",
"clearOnClose": "終了時に消去",
"windowColor": "ウィンドウの色",
"launchHook": "起動フック",
"proxyBypassRules": "プロキシの除外ルール",
"dnsBlocklist": "DNS ブロックリスト",
"dnsServer": "DNS サーバー",
"startUrls": "スタートページ",
"status": "ステータス",
"fingerprint": "フィンガープリント",
"proxy": "プロキシ",
"vpn": "VPN",
"extensionGroup": "拡張機能グループ",
"bookmarks": "ブックマーク",
"extensions": "拡張機能"
},
"via": {
"desktop": "デスクトップアプリから",
"web": "Web から",
"mcp": "MCP エージェントによって",
"service": "API キーで",
"remote_vm": "クラウドブラウザから"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "{{email}}이(가) 사용 중",
"profileLockedShort": "사용 중",
"cannotLaunchLocked": "실행할 수 없습니다 — 프로필을 {{email}}이(가) 사용 중",
"createdBy": "{{email}}이(가) 생성"
"createdBy": "{{email}}이(가) 생성",
"roleLauncher": "런처"
},
"disabled": "사용 안 함",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "원격 세션이 이 프로필을 사용 중입니다. 작업이 이곳으로 돌아올 때까지 기다리세요.",
"scheduledAutomationNotEntitled": "예약된 레시피는 Solo, Pro, Team, Enterprise에 포함됩니다.",
"agentScheduleInvalid": "Donut 클라우드가 이 일정을 받아들이지 않았습니다. 요일, 시간, 프로필을 확인하세요.",
"agentScheduleNotFound": "이 일정은 더 이상 없습니다."
"agentScheduleNotFound": "이 일정은 더 이상 없습니다.",
"teamRoleReadOnly": "내 팀 역할은 프로필을 열 수 있지만 변경할 수는 없습니다. 팀 소유자나 관리자에게 요청하세요.",
"teamActivityForbidden": "팀 활동은 팀 소유자와 관리자만 볼 수 있습니다.",
"teamActivityNotEntitled": "활동 기록에는 활성 Team 또는 Enterprise 요금제가 필요합니다.",
"teamActivityInvalid": "활동 기록 요청이 올바르지 않습니다."
},
"rail": {
"profiles": "프로필",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "계정",
"selfHosted": "자체 호스팅",
"teamUsage": "팀 사용량"
"teamUsage": "팀 사용량",
"teamActivity": "팀 활동"
},
"selfHosted": {
"title": "자체 호스팅 동기화 서버",
@@ -3941,5 +3947,84 @@
"reply": "더 자세한 내용이 필요하면 {{email}}(으)로 연락드리겠습니다.",
"done": "완료"
}
},
"teamRoles": {
"readOnlyNotice": "내 팀 역할은 런처입니다. 팀의 프로필을 열 수 있고, 세션에서 바뀐 내용은 유지됩니다. 설정, 프록시, 그룹은 팀이 정한 그대로 유지됩니다."
},
"teamActivity": {
"title": "팀 활동",
"description": "멤버가 팀의 프로필과 공유 항목에 한 작업입니다(최신순). 90일 동안 보관되며, Enterprise에서는 365일 동안 보관됩니다.",
"empty": "아직 활동이 없습니다.",
"loadMore": "더 불러오기",
"system": "Donut",
"formerMember": "이전 멤버",
"noTarget": "항목",
"changedFields": "변경됨: {{fields}}",
"actions": {
"memberInvite": "님이 {{target}} 님을 초대했습니다",
"memberJoin": "님이 팀에 참여했습니다",
"memberRemove": "님이 {{target}} 님을 팀에서 제거했습니다",
"memberRoleChange": "님이 {{target}} 님의 역할을 변경했습니다",
"invitationCancel": "님이 {{target}} 님에 대한 초대를 취소했습니다",
"profileLaunch": "님이 {{target}}을(를) 열었습니다",
"profileStop": "님이 {{target}}을(를) 닫았습니다",
"profileCreate": "님이 {{target}}을(를) 만들었습니다",
"profileRename": "님이 {{target}}의 이름을 바꿨습니다",
"profileUpdate": "님이 {{target}}을(를) 변경했습니다",
"profileBulkUpdate": "님이 일괄 편집으로 {{target}}을(를) 변경했습니다",
"profileDelete": "님이 {{target}}을(를) 삭제했습니다",
"profileRestore": "님이 {{target}}을(를) 휴지통에서 복원했습니다",
"profileSyncOn": "님이 {{target}}의 동기화를 켰습니다",
"profileSyncOff": "님이 {{target}}의 동기화를 껐습니다",
"proxyCreate": "님이 프록시 {{target}}을(를) 추가했습니다",
"proxyUpdate": "님이 프록시 {{target}}을(를) 변경했습니다",
"proxyDelete": "님이 프록시 {{target}}을(를) 삭제했습니다",
"groupCreate": "님이 그룹 {{target}}을(를) 만들었습니다",
"groupUpdate": "님이 그룹 {{target}}을(를) 변경했습니다",
"groupDelete": "님이 그룹 {{target}}을(를) 삭제했습니다",
"vpnCreate": "님이 VPN {{target}}을(를) 추가했습니다",
"vpnUpdate": "님이 VPN {{target}}을(를) 변경했습니다",
"vpnDelete": "님이 VPN {{target}}을(를) 삭제했습니다",
"extensionAdd": "님이 확장 프로그램 {{target}}을(를) 추가했습니다",
"extensionUpdate": "님이 확장 프로그램 {{target}}을(를) 업데이트했습니다",
"extensionDelete": "님이 확장 프로그램 {{target}}을(를) 삭제했습니다",
"extensionGroupCreate": "님이 확장 프로그램 그룹 {{target}}을(를) 만들었습니다",
"extensionGroupUpdate": "님이 확장 프로그램 그룹 {{target}}을(를) 변경했습니다",
"extensionGroupDelete": "님이 확장 프로그램 그룹 {{target}}을(를) 삭제했습니다",
"templateCreate": "님이 템플릿 {{target}}을(를) 만들었습니다",
"templateUpdate": "님이 템플릿 {{target}}을(를) 변경했습니다",
"templateDelete": "님이 템플릿 {{target}}을(를) 삭제했습니다",
"statusCreate": "님이 상태 {{target}}을(를) 만들었습니다",
"statusUpdate": "님이 상태 {{target}}을(를) 변경했습니다",
"statusDelete": "님이 상태 {{target}}을(를) 삭제했습니다",
"other": "님이 {{target}}에 {{action}}을(를) 실행했습니다"
},
"fields": {
"name": "이름",
"group": "그룹",
"tags": "태그",
"note": "메모",
"clearOnClose": "닫을 때 지우기",
"windowColor": "창 색상",
"launchHook": "실행 훅",
"proxyBypassRules": "프록시 예외 규칙",
"dnsBlocklist": "DNS 차단 목록",
"dnsServer": "DNS 서버",
"startUrls": "시작 페이지",
"status": "상태",
"fingerprint": "핑거프린트",
"proxy": "프록시",
"vpn": "VPN",
"extensionGroup": "확장 프로그램 그룹",
"bookmarks": "북마크",
"extensions": "확장 프로그램"
},
"via": {
"desktop": "데스크톱 앱에서",
"web": "웹에서",
"mcp": "MCP 에이전트가",
"service": "API 키로",
"remote_vm": "클라우드 브라우저에서"
}
}
}
+88 -3
View File
@@ -843,7 +843,8 @@
"profileLocked": "Em uso por {{email}}",
"profileLockedShort": "Em uso",
"cannotLaunchLocked": "Não é possível iniciar — o perfil está em uso por {{email}}",
"createdBy": "Criado por {{email}}"
"createdBy": "Criado por {{email}}",
"roleLauncher": "Iniciador"
},
"disabled": "Desativada",
"toast": {
@@ -2661,7 +2662,11 @@
"localStorageRemoteSession": "Uma sessão remota está usando este perfil. Aguarde até o trabalho dela voltar para cá.",
"scheduledAutomationNotEntitled": "As receitas agendadas fazem parte do Solo, Pro, Team e Enterprise.",
"agentScheduleInvalid": "A nuvem do Donut não aceitou este agendamento. Verifique os dias, o horário e os perfis.",
"agentScheduleNotFound": "Este agendamento não existe mais."
"agentScheduleNotFound": "Este agendamento não existe mais.",
"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."
},
"rail": {
"profiles": "Perfis",
@@ -2750,7 +2755,8 @@
"tabs": {
"account": "Conta",
"selfHosted": "Auto-hospedado",
"teamUsage": "Uso da equipe"
"teamUsage": "Uso da equipe",
"teamActivity": "Atividade da equipe"
},
"selfHosted": {
"title": "Servidor de sincronização auto-hospedado",
@@ -3984,5 +3990,84 @@
"reply": "Se precisarmos de mais detalhes, escreveremos para {{email}}.",
"done": "Concluir"
}
},
"teamRoles": {
"readOnlyNotice": "Sua função na equipe é Iniciador: você pode abrir os perfis da equipe, e o que uma sessão altera é mantido. Configurações, proxies e grupos ficam como a equipe os definiu."
},
"teamActivity": {
"title": "Atividade da equipe",
"description": "O que os membros fizeram com os perfis e itens compartilhados da equipe, do mais recente ao mais antigo. Guardado por 90 dias, ou 365 dias no Enterprise.",
"empty": "Ainda não há atividade.",
"loadMore": "Carregar mais",
"system": "Donut",
"formerMember": "Um ex-membro",
"noTarget": "um item",
"changedFields": "Alterado: {{fields}}",
"actions": {
"memberInvite": "convidou {{target}}",
"memberJoin": "entrou na equipe",
"memberRemove": "removeu {{target}} da equipe",
"memberRoleChange": "alterou a função de {{target}}",
"invitationCancel": "cancelou o convite de {{target}}",
"profileLaunch": "abriu {{target}}",
"profileStop": "fechou {{target}}",
"profileCreate": "criou {{target}}",
"profileRename": "renomeou {{target}}",
"profileUpdate": "alterou {{target}}",
"profileBulkUpdate": "alterou {{target}} em uma edição em massa",
"profileDelete": "excluiu {{target}}",
"profileRestore": "restaurou {{target}} da lixeira",
"profileSyncOn": "ativou a sincronização de {{target}}",
"profileSyncOff": "desativou a sincronização de {{target}}",
"proxyCreate": "adicionou o proxy {{target}}",
"proxyUpdate": "alterou o proxy {{target}}",
"proxyDelete": "excluiu o proxy {{target}}",
"groupCreate": "criou o grupo {{target}}",
"groupUpdate": "alterou o grupo {{target}}",
"groupDelete": "excluiu o grupo {{target}}",
"vpnCreate": "adicionou a VPN {{target}}",
"vpnUpdate": "alterou a VPN {{target}}",
"vpnDelete": "excluiu a VPN {{target}}",
"extensionAdd": "adicionou a extensão {{target}}",
"extensionUpdate": "atualizou a extensão {{target}}",
"extensionDelete": "excluiu a extensão {{target}}",
"extensionGroupCreate": "criou o grupo de extensões {{target}}",
"extensionGroupUpdate": "alterou o grupo de extensões {{target}}",
"extensionGroupDelete": "excluiu o grupo de extensões {{target}}",
"templateCreate": "criou o modelo {{target}}",
"templateUpdate": "alterou o modelo {{target}}",
"templateDelete": "excluiu o modelo {{target}}",
"statusCreate": "criou o status {{target}}",
"statusUpdate": "alterou o status {{target}}",
"statusDelete": "excluiu o status {{target}}",
"other": "fez {{action}} em {{target}}"
},
"fields": {
"name": "Nome",
"group": "Grupo",
"tags": "Tags",
"note": "Nota",
"clearOnClose": "Limpar ao fechar",
"windowColor": "Cor da janela",
"launchHook": "Hook de início",
"proxyBypassRules": "Exceções do proxy",
"dnsBlocklist": "Lista de bloqueio DNS",
"dnsServer": "Servidor DNS",
"startUrls": "Páginas iniciais",
"status": "Status",
"fingerprint": "Impressão digital",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Grupo de extensões",
"bookmarks": "Favoritos",
"extensions": "Extensões"
},
"via": {
"desktop": "pelo app de desktop",
"web": "pela web",
"mcp": "por um agente MCP",
"service": "com uma chave de API",
"remote_vm": "de um navegador na nuvem"
}
}
}
+88 -3
View File
@@ -848,7 +848,8 @@
"profileLocked": "Используется {{email}}",
"profileLockedShort": "Используется",
"cannotLaunchLocked": "Невозможно запустить — профиль используется {{email}}",
"createdBy": "Создано {{email}}"
"createdBy": "Создано {{email}}",
"roleLauncher": "Запуск"
},
"disabled": "Отключена",
"toast": {
@@ -2681,7 +2682,11 @@
"localStorageRemoteSession": "Этот профиль использует удалённая сессия. Дождитесь, пока её работа вернётся сюда.",
"scheduledAutomationNotEntitled": "Рецепты по расписанию входят в Solo, Pro, Team и Enterprise.",
"agentScheduleInvalid": "Облако Donut не приняло это расписание. Проверьте дни, время и профили.",
"agentScheduleNotFound": "Этого расписания больше нет."
"agentScheduleNotFound": "Этого расписания больше нет.",
"teamRoleReadOnly": "Ваша роль в команде позволяет открывать профили, но не изменять их. Обратитесь к владельцу или администратору команды.",
"teamActivityForbidden": "Активность команды видят только её владельцы и администраторы.",
"teamActivityNotEntitled": "Для журнала активности нужен активный план Team или Enterprise.",
"teamActivityInvalid": "Запрос журнала активности недействителен."
},
"rail": {
"profiles": "Профили",
@@ -2770,7 +2775,8 @@
"tabs": {
"account": "Аккаунт",
"selfHosted": "Свой сервер",
"teamUsage": "Использование командой"
"teamUsage": "Использование командой",
"teamActivity": "Активность команды"
},
"selfHosted": {
"title": "Свой сервер синхронизации",
@@ -4027,5 +4033,84 @@
"reply": "Если понадобятся подробности, мы напишем на {{email}}.",
"done": "Готово"
}
},
"teamRoles": {
"readOnlyNotice": "Ваша роль в команде — «Запуск»: вы можете открывать профили команды, и то, что меняет сеанс, сохраняется. Настройки, прокси и группы остаются такими, как их задала команда."
},
"teamActivity": {
"title": "Активность команды",
"description": "Что участники делали с профилями и общими элементами команды, сначала новые. Хранится 90 дней, на Enterprise — 365 дней.",
"empty": "Активности пока нет.",
"loadMore": "Загрузить ещё",
"system": "Donut",
"formerMember": "Бывший участник",
"noTarget": "элемент",
"changedFields": "Изменено: {{fields}}",
"actions": {
"memberInvite": "пригласил(а) {{target}}",
"memberJoin": "присоединился(-ась) к команде",
"memberRemove": "удалил(а) {{target}} из команды",
"memberRoleChange": "изменил(а) роль {{target}}",
"invitationCancel": "отменил(а) приглашение для {{target}}",
"profileLaunch": "открыл(а) {{target}}",
"profileStop": "закрыл(а) {{target}}",
"profileCreate": "создал(а) {{target}}",
"profileRename": "переименовал(а) {{target}}",
"profileUpdate": "изменил(а) {{target}}",
"profileBulkUpdate": "изменил(а) {{target}} при массовом редактировании",
"profileDelete": "удалил(а) {{target}}",
"profileRestore": "восстановил(а) {{target}} из корзины",
"profileSyncOn": "включил(а) синхронизацию для {{target}}",
"profileSyncOff": "выключил(а) синхронизацию для {{target}}",
"proxyCreate": "добавил(а) прокси {{target}}",
"proxyUpdate": "изменил(а) прокси {{target}}",
"proxyDelete": "удалил(а) прокси {{target}}",
"groupCreate": "создал(а) группу {{target}}",
"groupUpdate": "изменил(а) группу {{target}}",
"groupDelete": "удалил(а) группу {{target}}",
"vpnCreate": "добавил(а) VPN {{target}}",
"vpnUpdate": "изменил(а) VPN {{target}}",
"vpnDelete": "удалил(а) VPN {{target}}",
"extensionAdd": "добавил(а) расширение {{target}}",
"extensionUpdate": "обновил(а) расширение {{target}}",
"extensionDelete": "удалил(а) расширение {{target}}",
"extensionGroupCreate": "создал(а) группу расширений {{target}}",
"extensionGroupUpdate": "изменил(а) группу расширений {{target}}",
"extensionGroupDelete": "удалил(а) группу расширений {{target}}",
"templateCreate": "создал(а) шаблон {{target}}",
"templateUpdate": "изменил(а) шаблон {{target}}",
"templateDelete": "удалил(а) шаблон {{target}}",
"statusCreate": "создал(а) статус {{target}}",
"statusUpdate": "изменил(а) статус {{target}}",
"statusDelete": "удалил(а) статус {{target}}",
"other": "выполнил(а) {{action}} для {{target}}"
},
"fields": {
"name": "Название",
"group": "Группа",
"tags": "Теги",
"note": "Заметка",
"clearOnClose": "Очищать при закрытии",
"windowColor": "Цвет окна",
"launchHook": "Хук запуска",
"proxyBypassRules": "Исключения прокси",
"dnsBlocklist": "Блок-лист DNS",
"dnsServer": "DNS-сервер",
"startUrls": "Стартовые страницы",
"status": "Статус",
"fingerprint": "Отпечаток",
"proxy": "Прокси",
"vpn": "VPN",
"extensionGroup": "Группа расширений",
"bookmarks": "Закладки",
"extensions": "Расширения"
},
"via": {
"desktop": "из приложения для компьютера",
"web": "из веба",
"mcp": "через MCP-агента",
"service": "с ключом API",
"remote_vm": "из облачного браузера"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "{{email}} tarafından kullanılıyor",
"profileLockedShort": "Kullanımda",
"cannotLaunchLocked": "Başlatılamıyor — profil {{email}} tarafından kullanılıyor",
"createdBy": "{{email}} tarafından oluşturuldu"
"createdBy": "{{email}} tarafından oluşturuldu",
"roleLauncher": "Başlatıcı"
},
"disabled": "Devre Dışı",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "Bu profili uzak bir oturum kullanıyor. İşi buraya dönene kadar bekleyin.",
"scheduledAutomationNotEntitled": "Zamanlanmış tarifler Solo, Pro, Team ve Enterprise'a dahildir.",
"agentScheduleInvalid": "Donut bulutu bu zamanlamayı kabul etmedi. Günleri, saati ve profilleri kontrol edin.",
"agentScheduleNotFound": "Bu zamanlama artık yok."
"agentScheduleNotFound": "Bu zamanlama artık yok.",
"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."
},
"rail": {
"profiles": "Profiller",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "Hesap",
"selfHosted": "Kendi sunucunuz",
"teamUsage": "Ekip kullanımı"
"teamUsage": "Ekip kullanımı",
"teamActivity": "Ekip etkinliği"
},
"selfHosted": {
"title": "Kendi eşitleme sunucunuz",
@@ -3941,5 +3947,84 @@
"reply": "Daha fazla ayrıntıya ihtiyacımız olursa {{email}} adresine yazarız.",
"done": "Tamam"
}
},
"teamRoles": {
"readOnlyNotice": "Ekip rolün Başlatıcı: Ekibin profillerini açabilirsin ve bir oturumun değiştirdikleri korunur. Ayarlar, proxy'ler ve gruplar ekibin belirlediği gibi kalır."
},
"teamActivity": {
"title": "Ekip etkinliği",
"description": "Üyelerin ekibin profilleri ve paylaşılan öğeleriyle yaptıkları, en yenisi önce. 90 gün, Enterprise'da 365 gün saklanır.",
"empty": "Henüz etkinlik yok.",
"loadMore": "Daha fazla yükle",
"system": "Donut",
"formerMember": "Eski bir üye",
"noTarget": "bir öğe",
"changedFields": "Değişen: {{fields}}",
"actions": {
"memberInvite": "{{target}} kişisini davet etti",
"memberJoin": "ekibe katıldı",
"memberRemove": "{{target}} kişisini ekipten çıkardı",
"memberRoleChange": "{{target}} kişisinin rolünü değiştirdi",
"invitationCancel": "{{target}} için daveti iptal etti",
"profileLaunch": "{{target}} profilini açtı",
"profileStop": "{{target}} profilini kapattı",
"profileCreate": "{{target}} profilini oluşturdu",
"profileRename": "{{target}} profilinin adını değiştirdi",
"profileUpdate": "{{target}} profilini değiştirdi",
"profileBulkUpdate": "{{target}} profilini toplu düzenlemede değiştirdi",
"profileDelete": "{{target}} profilini sildi",
"profileRestore": "{{target}} profilini çöp kutusundan geri yükledi",
"profileSyncOn": "{{target}} için eşitlemeyi açtı",
"profileSyncOff": "{{target}} için eşitlemeyi kapattı",
"proxyCreate": "{{target}} proxy'sini ekledi",
"proxyUpdate": "{{target}} proxy'sini değiştirdi",
"proxyDelete": "{{target}} proxy'sini sildi",
"groupCreate": "{{target}} grubunu oluşturdu",
"groupUpdate": "{{target}} grubunu değiştirdi",
"groupDelete": "{{target}} grubunu sildi",
"vpnCreate": "{{target}} VPN'ini ekledi",
"vpnUpdate": "{{target}} VPN'ini değiştirdi",
"vpnDelete": "{{target}} VPN'ini sildi",
"extensionAdd": "{{target}} eklentisini ekledi",
"extensionUpdate": "{{target}} eklentisini güncelledi",
"extensionDelete": "{{target}} eklentisini sildi",
"extensionGroupCreate": "{{target}} eklenti grubunu oluşturdu",
"extensionGroupUpdate": "{{target}} eklenti grubunu değiştirdi",
"extensionGroupDelete": "{{target}} eklenti grubunu sildi",
"templateCreate": "{{target}} şablonunu oluşturdu",
"templateUpdate": "{{target}} şablonunu değiştirdi",
"templateDelete": "{{target}} şablonunu sildi",
"statusCreate": "{{target}} durumunu oluşturdu",
"statusUpdate": "{{target}} durumunu değiştirdi",
"statusDelete": "{{target}} durumunu sildi",
"other": "{{target}} üzerinde {{action}} yaptı"
},
"fields": {
"name": "Ad",
"group": "Grup",
"tags": "Etiketler",
"note": "Not",
"clearOnClose": "Kapanınca temizle",
"windowColor": "Pencere rengi",
"launchHook": "Başlatma kancası",
"proxyBypassRules": "Proxy istisna kuralları",
"dnsBlocklist": "DNS engel listesi",
"dnsServer": "DNS sunucusu",
"startUrls": "Başlangıç sayfaları",
"status": "Durum",
"fingerprint": "Parmak izi",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Eklenti grubu",
"bookmarks": "Yer imleri",
"extensions": "Eklentiler"
},
"via": {
"desktop": "masaüstü uygulamasından",
"web": "web'den",
"mcp": "bir MCP ajanıyla",
"service": "bir API anahtarıyla",
"remote_vm": "bir bulut tarayıcısından"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "Đang được sử dụng bởi {{email}}",
"profileLockedShort": "Đang sử dụng",
"cannotLaunchLocked": "Không thể khởi chạy — profile đang được sử dụng bởi {{email}}",
"createdBy": "Được tạo bởi {{email}}"
"createdBy": "Được tạo bởi {{email}}",
"roleLauncher": "Người khởi chạy"
},
"disabled": "Tắt",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "Một phiên từ xa đang dùng profile này. Hãy đợi đến khi công việc của nó trở lại đây.",
"scheduledAutomationNotEntitled": "Công thức theo lịch có trong Solo, Pro, Team và Enterprise.",
"agentScheduleInvalid": "Đám mây Donut không chấp nhận lịch chạy này. Hãy kiểm tra ngày, giờ và profile.",
"agentScheduleNotFound": "Lịch chạy này không còn tồn tại."
"agentScheduleNotFound": "Lịch chạy này không còn tồn tại.",
"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ệ."
},
"rail": {
"profiles": "Profile",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "Tài khoản",
"selfHosted": "Tự lưu trữ",
"teamUsage": "Mức dùng của nhóm"
"teamUsage": "Mức dùng của nhóm",
"teamActivity": "Hoạt động của nhóm"
},
"selfHosted": {
"title": "Máy chủ đồng bộ tự lưu trữ",
@@ -3941,5 +3947,84 @@
"reply": "Nếu cần thêm chi tiết, chúng tôi sẽ viết thư tới {{email}}.",
"done": "Xong"
}
},
"teamRoles": {
"readOnlyNotice": "Vai trò nhóm của bạn là Người khởi chạy: bạn có thể mở các hồ sơ của nhóm, và những gì một phiên thay đổi sẽ được giữ lại. Cài đặt, proxy và nhóm vẫn giữ như nhóm đã đặt."
},
"teamActivity": {
"title": "Hoạt động của nhóm",
"description": "Những gì thành viên đã làm với hồ sơ và mục dùng chung của nhóm, mới nhất trước. Lưu 90 ngày, hoặc 365 ngày với Enterprise.",
"empty": "Chưa có hoạt động nào.",
"loadMore": "Tải thêm",
"system": "Donut",
"formerMember": "Một cựu thành viên",
"noTarget": "một mục",
"changedFields": "Đã thay đổi: {{fields}}",
"actions": {
"memberInvite": "đã mời {{target}}",
"memberJoin": "đã tham gia nhóm",
"memberRemove": "đã xóa {{target}} khỏi nhóm",
"memberRoleChange": "đã đổi vai trò của {{target}}",
"invitationCancel": "đã hủy lời mời cho {{target}}",
"profileLaunch": "đã mở {{target}}",
"profileStop": "đã đóng {{target}}",
"profileCreate": "đã tạo {{target}}",
"profileRename": "đã đổi tên {{target}}",
"profileUpdate": "đã thay đổi {{target}}",
"profileBulkUpdate": "đã thay đổi {{target}} trong một lần sửa hàng loạt",
"profileDelete": "đã xóa {{target}}",
"profileRestore": "đã khôi phục {{target}} từ thùng rác",
"profileSyncOn": "đã bật đồng bộ cho {{target}}",
"profileSyncOff": "đã tắt đồng bộ cho {{target}}",
"proxyCreate": "đã thêm proxy {{target}}",
"proxyUpdate": "đã thay đổi proxy {{target}}",
"proxyDelete": "đã xóa proxy {{target}}",
"groupCreate": "đã tạo nhóm {{target}}",
"groupUpdate": "đã thay đổi nhóm {{target}}",
"groupDelete": "đã xóa nhóm {{target}}",
"vpnCreate": "đã thêm VPN {{target}}",
"vpnUpdate": "đã thay đổi VPN {{target}}",
"vpnDelete": "đã xóa VPN {{target}}",
"extensionAdd": "đã thêm tiện ích {{target}}",
"extensionUpdate": "đã cập nhật tiện ích {{target}}",
"extensionDelete": "đã xóa tiện ích {{target}}",
"extensionGroupCreate": "đã tạo nhóm tiện ích {{target}}",
"extensionGroupUpdate": "đã thay đổi nhóm tiện ích {{target}}",
"extensionGroupDelete": "đã xóa nhóm tiện ích {{target}}",
"templateCreate": "đã tạo mẫu {{target}}",
"templateUpdate": "đã thay đổi mẫu {{target}}",
"templateDelete": "đã xóa mẫu {{target}}",
"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}}"
},
"fields": {
"name": "Tên",
"group": "Nhóm",
"tags": "Thẻ",
"note": "Ghi chú",
"clearOnClose": "Xóa khi đóng",
"windowColor": "Màu cửa sổ",
"launchHook": "Hook khởi chạy",
"proxyBypassRules": "Quy tắc bỏ qua proxy",
"dnsBlocklist": "Danh sách chặn DNS",
"dnsServer": "Máy chủ DNS",
"startUrls": "Trang bắt đầu",
"status": "Trạng thái",
"fingerprint": "Dấu vân tay",
"proxy": "Proxy",
"vpn": "VPN",
"extensionGroup": "Nhóm tiện ích",
"bookmarks": "Dấu trang",
"extensions": "Tiện ích"
},
"via": {
"desktop": "từ ứng dụng máy tính",
"web": "từ web",
"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"
}
}
}
+88 -3
View File
@@ -838,7 +838,8 @@
"profileLocked": "{{email}} 正在使用中",
"profileLockedShort": "使用中",
"cannotLaunchLocked": "无法启动 — 配置文件正被 {{email}} 使用",
"createdBy": "由 {{email}} 创建"
"createdBy": "由 {{email}} 创建",
"roleLauncher": "启动者"
},
"disabled": "已禁用",
"toast": {
@@ -2641,7 +2642,11 @@
"localStorageRemoteSession": "远程会话正在使用此配置文件。请等待其工作回到这里。",
"scheduledAutomationNotEntitled": "计划配方包含在 Solo、Pro、Team 和 Enterprise 中。",
"agentScheduleInvalid": "Donut 云未接受此计划。请检查日期、时间和配置文件。",
"agentScheduleNotFound": "该计划已不存在。"
"agentScheduleNotFound": "该计划已不存在。",
"teamRoleReadOnly": "你的团队角色可以打开配置文件,但不能更改它们。请联系团队所有者或管理员。",
"teamActivityForbidden": "只有团队所有者和管理员可以查看团队活动。",
"teamActivityNotEntitled": "活动日志需要有效的 Team 或 Enterprise 套餐。",
"teamActivityInvalid": "活动日志请求无效。"
},
"rail": {
"profiles": "配置文件",
@@ -2730,7 +2735,8 @@
"tabs": {
"account": "账户",
"selfHosted": "自托管",
"teamUsage": "团队用量"
"teamUsage": "团队用量",
"teamActivity": "团队活动"
},
"selfHosted": {
"title": "自托管同步服务器",
@@ -3941,5 +3947,84 @@
"reply": "如需更多细节,我们会写信到 {{email}}。",
"done": "完成"
}
},
"teamRoles": {
"readOnlyNotice": "你的团队角色是启动者:你可以打开团队的配置文件,会话中的更改会保留。设置、代理和分组保持团队设定的样子。"
},
"teamActivity": {
"title": "团队活动",
"description": "成员对团队配置文件和共享项目所做的操作,最新的在前。保留 90 天,Enterprise 保留 365 天。",
"empty": "暂无活动。",
"loadMore": "加载更多",
"system": "Donut",
"formerMember": "前成员",
"noTarget": "某个项目",
"changedFields": "已更改:{{fields}}",
"actions": {
"memberInvite": "邀请了 {{target}}",
"memberJoin": "加入了团队",
"memberRemove": "将 {{target}} 移出了团队",
"memberRoleChange": "更改了 {{target}} 的角色",
"invitationCancel": "取消了对 {{target}} 的邀请",
"profileLaunch": "打开了 {{target}}",
"profileStop": "关闭了 {{target}}",
"profileCreate": "创建了 {{target}}",
"profileRename": "重命名了 {{target}}",
"profileUpdate": "更改了 {{target}}",
"profileBulkUpdate": "在批量编辑中更改了 {{target}}",
"profileDelete": "删除了 {{target}}",
"profileRestore": "从回收站恢复了 {{target}}",
"profileSyncOn": "为 {{target}} 开启了同步",
"profileSyncOff": "为 {{target}} 关闭了同步",
"proxyCreate": "添加了代理 {{target}}",
"proxyUpdate": "更改了代理 {{target}}",
"proxyDelete": "删除了代理 {{target}}",
"groupCreate": "创建了分组 {{target}}",
"groupUpdate": "更改了分组 {{target}}",
"groupDelete": "删除了分组 {{target}}",
"vpnCreate": "添加了 VPN {{target}}",
"vpnUpdate": "更改了 VPN {{target}}",
"vpnDelete": "删除了 VPN {{target}}",
"extensionAdd": "添加了扩展 {{target}}",
"extensionUpdate": "更新了扩展 {{target}}",
"extensionDelete": "删除了扩展 {{target}}",
"extensionGroupCreate": "创建了扩展分组 {{target}}",
"extensionGroupUpdate": "更改了扩展分组 {{target}}",
"extensionGroupDelete": "删除了扩展分组 {{target}}",
"templateCreate": "创建了模板 {{target}}",
"templateUpdate": "更改了模板 {{target}}",
"templateDelete": "删除了模板 {{target}}",
"statusCreate": "创建了状态 {{target}}",
"statusUpdate": "更改了状态 {{target}}",
"statusDelete": "删除了状态 {{target}}",
"other": "对 {{target}} 执行了 {{action}}"
},
"fields": {
"name": "名称",
"group": "分组",
"tags": "标签",
"note": "备注",
"clearOnClose": "关闭时清除",
"windowColor": "窗口颜色",
"launchHook": "启动钩子",
"proxyBypassRules": "代理例外规则",
"dnsBlocklist": "DNS 拦截列表",
"dnsServer": "DNS 服务器",
"startUrls": "起始页",
"status": "状态",
"fingerprint": "指纹",
"proxy": "代理",
"vpn": "VPN",
"extensionGroup": "扩展分组",
"bookmarks": "书签",
"extensions": "扩展"
},
"via": {
"desktop": "来自桌面应用",
"web": "来自网页",
"mcp": "由 MCP 代理",
"service": "使用 API 密钥",
"remote_vm": "来自云端浏览器"
}
}
}
+12 -9
View File
@@ -125,9 +125,10 @@ export type BackendErrorCode =
| "SCHEDULED_AUTOMATION_NOT_ENTITLED"
| "AGENT_SCHEDULE_INVALID"
| "AGENT_SCHEDULE_NOT_FOUND"
| "SCHEDULED_AUTOMATION_NOT_ENTITLED"
| "AGENT_SCHEDULE_INVALID"
| "AGENT_SCHEDULE_NOT_FOUND"
| "TEAM_ROLE_READ_ONLY"
| "TEAM_ACTIVITY_FORBIDDEN"
| "TEAM_ACTIVITY_NOT_ENTITLED"
| "TEAM_ACTIVITY_INVALID"
| "LOCAL_STORAGE_EPHEMERAL"
| "LOCAL_STORAGE_PROFILE_PROTECTED"
| "LOCAL_STORAGE_UNREADABLE"
@@ -685,12 +686,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.agentScheduleInvalid");
case "AGENT_SCHEDULE_NOT_FOUND":
return t("backendErrors.agentScheduleNotFound");
case "SCHEDULED_AUTOMATION_NOT_ENTITLED":
return t("backendErrors.scheduledAutomationNotEntitled");
case "AGENT_SCHEDULE_INVALID":
return t("backendErrors.agentScheduleInvalid");
case "AGENT_SCHEDULE_NOT_FOUND":
return t("backendErrors.agentScheduleNotFound");
case "TEAM_ROLE_READ_ONLY":
return t("backendErrors.teamRoleReadOnly");
case "TEAM_ACTIVITY_FORBIDDEN":
return t("backendErrors.teamActivityForbidden");
case "TEAM_ACTIVITY_NOT_ENTITLED":
return t("backendErrors.teamActivityNotEntitled");
case "TEAM_ACTIVITY_INVALID":
return t("backendErrors.teamActivityInvalid");
case "MCP_TOOLSET_UNKNOWN":
return t("backendErrors.mcpToolsetUnknown");
case "MCP_AGENT_UNKNOWN":
+19
View File
@@ -205,3 +205,22 @@ export function isTeamOwner(user: CloudUser | null | undefined): boolean {
Boolean(user.teamId)
);
}
/**
* A team seat that may open the team's profiles and keep what a session
* changes, but not change the team's profiles, proxies or groups.
*/
export function isTeamLauncher(user: CloudUser | null | undefined): boolean {
return Boolean(user?.teamId) && user?.teamRole === "launcher";
}
/** Owners and admins read the team's activity log. The cloud checks again. */
export function canViewTeamActivity(
user: CloudUser | null | undefined,
): boolean {
return (
getEntitlements(user).teamCollaboration &&
Boolean(user?.teamId) &&
(user?.teamRole === "owner" || user?.teamRole === "admin")
);
}
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
actionKey,
actorKind,
fieldKeys,
targetLabel,
} from "./team-activity.ts";
test("known actions have words and unknown ones do not", () => {
assert.equal(actionKey("profile.bulk_update"), "profileBulkUpdate");
assert.equal(actionKey("extension.group_create"), "extensionGroupCreate");
assert.equal(actionKey("member.role_change"), "memberRoleChange");
assert.equal(actionKey("profile.teleport"), null);
});
test("only the fields this build can name are shown", () => {
assert.deepEqual(fieldKeys({ fields: ["tags", "dns_server", "secret"] }), [
"tags",
"dnsServer",
]);
assert.deepEqual(fieldKeys({ fields: "tags" }), []);
assert.deepEqual(fieldKeys(null), []);
});
test("a target reads as its local name first, then the log's, then a short id", () => {
const names = new Map([["p1", "Shop"]]);
assert.equal(
targetLabel({ type: "profile", id: "p1", name: "Old" }, names),
"Shop",
);
assert.equal(
targetLabel({ type: "profile", id: "p2", name: "Old" }, names),
"Old",
);
assert.equal(
targetLabel({ type: "profile", id: "0123456789abcdef", name: null }, names),
"01234567…",
);
assert.equal(
targetLabel({ type: "member", id: null, name: "a@b.c" }, names),
"a@b.c",
);
assert.equal(targetLabel({ type: null, id: null, name: null }, names), null);
});
test("an actor kind this build does not know is not shown", () => {
assert.equal(actorKind("mcp"), "mcp");
assert.equal(actorKind("satellite"), null);
});
+135
View File
@@ -0,0 +1,135 @@
import { invoke } from "@tauri-apps/api/core";
/** One line of the team's log, as the cloud returns it. */
export interface TeamActivityItem {
id: number;
at: string;
actor: { userId: string | null; email: string | null; kind: string };
action: string;
target: { type: string | null; id: string | null; name: string | null };
detail: Record<string, unknown> | null;
source: "server" | "client";
}
export interface TeamActivityPage {
items: TeamActivityItem[];
nextCursor: string | null;
}
/** The actions this build has words for, by the key under `teamActivity.actions`. */
const ACTION_KEYS: Record<string, string> = {
"member.invite": "memberInvite",
"member.join": "memberJoin",
"member.remove": "memberRemove",
"member.role_change": "memberRoleChange",
"invitation.cancel": "invitationCancel",
"profile.launch": "profileLaunch",
"profile.stop": "profileStop",
"profile.create": "profileCreate",
"profile.rename": "profileRename",
"profile.update": "profileUpdate",
"profile.edit": "profileUpdate",
"profile.bulk_update": "profileBulkUpdate",
"profile.delete": "profileDelete",
"profile.restore": "profileRestore",
"profile.sync_on": "profileSyncOn",
"profile.sync_off": "profileSyncOff",
"proxy.create": "proxyCreate",
"proxy.update": "proxyUpdate",
"proxy.delete": "proxyDelete",
"group.create": "groupCreate",
"group.update": "groupUpdate",
"group.delete": "groupDelete",
"vpn.create": "vpnCreate",
"vpn.update": "vpnUpdate",
"vpn.delete": "vpnDelete",
"extension.add": "extensionAdd",
"extension.update": "extensionUpdate",
"extension.delete": "extensionDelete",
"extension.group_create": "extensionGroupCreate",
"extension.group_update": "extensionGroupUpdate",
"extension.group_delete": "extensionGroupDelete",
"template.create": "templateCreate",
"template.update": "templateUpdate",
"template.delete": "templateDelete",
"status.create": "statusCreate",
"status.update": "statusUpdate",
"status.delete": "statusDelete",
};
/** The fields a desktop names in `detail.fields`, by their key under `teamActivity.fields`. */
const FIELD_KEYS: Record<string, string> = {
name: "name",
group: "group",
tags: "tags",
note: "note",
clear_on_close: "clearOnClose",
window_color: "windowColor",
launch_hook: "launchHook",
proxy_bypass_rules: "proxyBypassRules",
dns_blocklist: "dnsBlocklist",
dns_server: "dnsServer",
start_urls: "startUrls",
status: "status",
fingerprint: "fingerprint",
proxy: "proxy",
vpn: "vpn",
extension_group: "extensionGroup",
bookmarks: "bookmarks",
extensions: "extensions",
};
const ACTOR_KINDS = new Set([
"desktop",
"web",
"mcp",
"service",
"remote_vm",
"system",
]);
/** The translation key for `action`, or null for one this build does not know. */
export function actionKey(action: string): string | null {
return ACTION_KEYS[action] ?? null;
}
/** The translation keys of the fields an edit names, in order, known ones only. */
export function fieldKeys(detail: TeamActivityItem["detail"]): string[] {
const fields = detail?.fields;
if (!Array.isArray(fields)) return [];
return fields.flatMap((field) =>
typeof field === "string" && FIELD_KEYS[field] ? [FIELD_KEYS[field]] : [],
);
}
/** The actor kind to name, or null when the line needs none. */
export function actorKind(kind: string): string | null {
return ACTOR_KINDS.has(kind) ? kind : null;
}
/**
* What to call the target: the name this computer knows it by, else the name
* in the log, else a short id. Null when the action has no target.
*/
export function targetLabel(
target: TeamActivityItem["target"],
localNames: ReadonlyMap<string, string>,
): string | null {
if (!target.id) return target.name;
return localNames.get(target.id) ?? target.name ?? shortId(target.id);
}
function shortId(id: string): string {
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
}
export const ACTIVITY_PAGE = 50;
export function listTeamActivity(
cursor: string | null,
): Promise<TeamActivityPage> {
return invoke<TeamActivityPage>("list_team_activity", {
cursor,
limit: ACTIVITY_PAGE,
});
}