From 5213bbb170236b86047c847f464c9dff07c09ea9 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:27:39 +0400 Subject: [PATCH] feat: group access, notices --- donut-sync/src/auth/auth.guard.spec.ts | 30 +++ donut-sync/src/auth/auth.guard.ts | 13 + donut-sync/src/auth/user-context.interface.ts | 3 + donut-sync/src/sync/sync.service.spec.ts | 130 ++++++++++ donut-sync/src/sync/sync.service.ts | 168 ++++++++++++- e2e/coverage-map.mjs | 8 +- e2e/tests/integrations.test.mjs | 22 ++ e2e/tests/ui.test.mjs | 114 +++++++++ src-tauri/src/agent_schedules.rs | 18 +- src-tauri/src/cloud_api.rs | 28 ++- src-tauri/src/lib.rs | 5 + src-tauri/src/sync/engine.rs | 59 ++++- src-tauri/src/team_activity.rs | 195 ++++++++++++-- src-tauri/src/team_members.rs | 135 ++++++++++ src/components/account-page.tsx | 17 +- src/components/agent-schedules.tsx | 117 +++++++++ src/components/team-access.tsx | 237 ++++++++++++++++++ src/components/team-activity-log.tsx | 173 +++++++++++-- src/i18n/locales/de.json | 64 ++++- src/i18n/locales/en.json | 64 ++++- src/i18n/locales/es.json | 66 ++++- src/i18n/locales/fr.json | 66 ++++- src/i18n/locales/ja.json | 64 ++++- src/i18n/locales/ko.json | 64 ++++- src/i18n/locales/pt.json | 66 ++++- src/i18n/locales/ru.json | 68 ++++- src/i18n/locales/tr.json | 64 ++++- src/i18n/locales/vi.json | 64 ++++- src/i18n/locales/zh.json | 64 ++++- src/lib/backend-errors.ts | 21 ++ src/lib/schedules.test.mjs | 25 ++ src/lib/schedules.ts | 44 ++++ src/lib/team-activity.test.mjs | 23 ++ src/lib/team-activity.ts | 80 ++++++ 34 files changed, 2286 insertions(+), 93 deletions(-) create mode 100644 src-tauri/src/team_members.rs create mode 100644 src/components/team-access.tsx diff --git a/donut-sync/src/auth/auth.guard.spec.ts b/donut-sync/src/auth/auth.guard.spec.ts index e77441ca..c002f130 100644 --- a/donut-sync/src/auth/auth.guard.spec.ts +++ b/donut-sync/src/auth/auth.guard.spec.ts @@ -129,6 +129,36 @@ describe("AuthGuard", () => { expect(unknown.request.user?.teamRole).toBe("member"); }); + it("keeps the groups a limited member may reach, and drops malformed ids", async () => { + const answers = [["g1", "bad/id", 7], null]; + jest.spyOn(globalThis, "fetch").mockImplementation(async () => { + const groupIds = answers.shift(); + return new Response( + JSON.stringify({ + ownerId: "99999999-8888-7777-6666-555555555555", + teamId: "team-1", + teamProfileLimit: 0, + role: "member", + groupIds, + }), + ); + }); + const guard = () => + guardWith({ + SYNC_JWT_PUBLIC_KEY: signingKeys.publicKey, + BACKEND_INTERNAL_URL: "http://backend.internal", + BACKEND_INTERNAL_KEY: "internal-key", + }); + + const limited = contextFor(syncToken()); + await expect(guard().canActivate(limited.context)).resolves.toBe(true); + expect(limited.request.user?.groupIds).toEqual(["g1"]); + + const everyGroup = contextFor(syncToken()); + await expect(guard().canActivate(everyGroup.context)).resolves.toBe(true); + expect(everyGroup.request.user?.groupIds).toBeUndefined(); + }); + it("refuses an account access token signed with the same key", async () => { const accessToken = sign({ sub: USER_ID, diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index 64a4b94e..3df21f10 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -23,8 +23,11 @@ type TeamScope = { teamId: string; teamProfileLimit: number; role?: string; + groupIds?: string[] | null; }; +const GROUP_ID = /^[A-Za-z0-9_-]{1,128}$/; + const TEAM_ROLES = ["owner", "admin", "member", "launcher"] as const; type TeamRole = (typeof TEAM_ROLES)[number]; @@ -153,6 +156,7 @@ export class AuthGuard implements CanActivate { // fall back to the user's own namespace, never widening to a team one. let effectivePrefix = ownPrefix; let teamRole: TeamRole | undefined; + let groupIds: string[] | undefined; let effectiveProfileLimit = typeof decoded.profileLimit === "number" ? decoded.profileLimit : 0; try { @@ -160,6 +164,14 @@ export class AuthGuard implements CanActivate { if (scope && /^[^/]+$/.test(scope.ownerId)) { effectivePrefix = `users/${scope.ownerId}/`; teamRole = teamRoleOf(scope.role); + // A malformed id cannot name a group, so it grants nothing; it + // never turns a limited member into an unlimited one. + if (Array.isArray(scope.groupIds)) { + groupIds = scope.groupIds.filter( + (id): id is string => + typeof id === "string" && GROUP_ID.test(id), + ); + } if (scope.teamProfileLimit > 0) { effectiveProfileLimit = scope.teamProfileLimit; } @@ -178,6 +190,7 @@ export class AuthGuard implements CanActivate { profileLimit: effectiveProfileLimit, sub, ...(teamRole ? { teamRole } : {}), + ...(groupIds ? { groupIds } : {}), } satisfies UserContext; return true; } catch (err) { diff --git a/donut-sync/src/auth/user-context.interface.ts b/donut-sync/src/auth/user-context.interface.ts index 77c14077..2f8373eb 100644 --- a/donut-sync/src/auth/user-context.interface.ts +++ b/donut-sync/src/auth/user-context.interface.ts @@ -11,4 +11,7 @@ export interface UserContext { // 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"; + // The profile groups this member may reach, when the owner limited them. + // Absent means every group. `__ungrouped__` stands for profiles in no group. + groupIds?: string[]; } diff --git a/donut-sync/src/sync/sync.service.spec.ts b/donut-sync/src/sync/sync.service.spec.ts index 0b91e0a1..80a9a603 100644 --- a/donut-sync/src/sync/sync.service.spec.ts +++ b/donut-sync/src/sync/sync.service.spec.ts @@ -137,3 +137,133 @@ describe("SyncService team roles", () => { ).resolves.toHaveProperty("deletedCount", 0); }); }); + +describe("SyncService access by group", () => { + const groups: Record = { + p1: "g1", + p2: "g2", + p3: null, + }; + + function limitedService(): { service: SyncService; send: jest.Mock } { + const { service, send } = serviceWithStubbedS3(); + send.mockImplementation( + async (command: { input: Record }) => { + const name = command.constructor.name; + const key = String(command.input.Key ?? ""); + if (name === "HeadObjectCommand") { + const match = /profiles\/([^/]+)\/metadata\.json$/.exec(key); + const id = match?.[1]; + if (!id || !(id in groups)) { + throw Object.assign(new Error("not found"), { name: "NotFound" }); + } + const group = groups[id]; + return { Metadata: group ? { "group-id": group } : {} }; + } + if (name === "ListObjectsV2Command") { + return { + Contents: [ + "profiles/p1/manifest.json", + "profiles/p2/manifest.json", + "teams/t1/profiles/p1/files/Default/Cookies", + "groups/g1.json", + "groups/g2.json", + "proxies/x.json", + ].map((relative) => ({ Key: `${OWNER_PREFIX}${relative}` })), + }; + } + return {}; + }, + ); + return { service, send }; + } + + const limited: UserContext = { + mode: "cloud", + prefix: OWNER_PREFIX, + profileLimit: 0, + teamRole: "member", + groupIds: ["g1"], + }; + + async function denialCode(promise: Promise): Promise { + return refusalCode(promise); + } + + beforeEach(() => { + jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("shows a limited member only the profiles and groups it may reach", async () => { + const { service } = limitedService(); + await expect( + service.stat({ key: "profiles/p1/manifest.json" }, limited), + ).resolves.toBeDefined(); + for (const key of [ + "profiles/p2/manifest.json", + "profiles/p3/manifest.json", + "groups/g2.json", + ]) { + await expect(denialCode(service.stat({ key }, limited))).resolves.toBe( + "TEAM_GROUP_ACCESS_DENIED", + ); + } + await expect( + service.presignDownload({ key: "proxies/x.json" }, limited), + ).resolves.toHaveProperty("url"); + + const listed = await service.list({ prefix: "" }, limited); + expect(listed.objects.map((object) => object.key)).toEqual([ + "profiles/p1/manifest.json", + "teams/t1/profiles/p1/files/Default/Cookies", + "groups/g1.json", + "proxies/x.json", + ]); + }); + + it("lets a limited member change profiles only inside its groups", async () => { + const { service } = limitedService(); + await expect( + service.presignUpload( + { key: "profiles/new/metadata.json", metadata: { "group-id": "g1" } }, + limited, + ), + ).resolves.toHaveProperty("url"); + await expect( + service.presignUpload( + { key: "profiles/p1/files/Default/Cookies" }, + limited, + ), + ).resolves.toHaveProperty("url"); + + for (const [key, metadata] of [ + ["profiles/new/metadata.json", { "group-id": "g2" }], + ["profiles/new/metadata.json", undefined], + ["profiles/new/files/Default/Cookies", undefined], + ["profiles/p1/metadata.json", { "group-id": "g2" }], + ["profiles/p2/files/Default/Cookies", undefined], + ["groups/g2.json", undefined], + ] as const) { + await expect( + denialCode(service.presignUpload({ key, metadata }, limited)), + ).resolves.toBe("TEAM_GROUP_ACCESS_DENIED"); + } + await expect( + denialCode(service.deletePrefix({ prefix: "profiles/p2/" }, limited)), + ).resolves.toBe("TEAM_GROUP_ACCESS_DENIED"); + }); + + it("leaves a member without a limit free to reach every group", async () => { + const { service } = limitedService(); + const everyGroup: UserContext = { ...limited, groupIds: undefined }; + await expect( + service.stat({ key: "profiles/p2/manifest.json" }, everyGroup), + ).resolves.toBeDefined(); + const listed = await service.list({ prefix: "" }, everyGroup); + expect(listed.objects).toHaveLength(6); + }); +}); diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 09901bf1..34301749 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -65,8 +65,9 @@ function clampExpiresIn(requested: number | undefined): number { return Math.min(v, MAX_PRESIGN_EXPIRES_IN); } -/** Only this metadata key is meaningful to sync (LWW conflict resolution). - * Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */ +/** Only these metadata keys are meaningful to sync: `updated-at` for LWW + * conflict resolution, `group-id` for team access by group. Whitelisting + * prevents a client from signing arbitrary x-amz-meta-* values. */ function sanitizeMetadata( metadata: Record | undefined, ): Record | undefined { @@ -75,9 +76,26 @@ function sanitizeMetadata( if (typeof metadata["updated-at"] === "string") { out["updated-at"] = metadata["updated-at"]; } + if ( + typeof metadata["group-id"] === "string" && + GROUP_ID.test(metadata["group-id"]) + ) { + out["group-id"] = metadata["group-id"]; + } return Object.keys(out).length > 0 ? out : undefined; } +/** The `group-id` a profile without a group carries. */ +export const UNGROUPED = "__ungrouped__"; +const GROUP_ID = /^[A-Za-z0-9_-]{1,128}$/; +/** A profile's objects, relative to the namespace: its root and its id. */ +const PROFILE_KEY = /^((?:teams\/[^/]+\/)?profiles\/([^/]+)\/)/; +/** A group's config object, relative to the namespace. */ +const GROUP_KEY = /^(?:teams\/[^/]+\/)?groups\/([^/]+)\.json$/; +/** How long a profile's group is trusted before it is read again. */ +const GROUP_CACHE_MS = 30_000; +const GROUP_CACHE_MAX = 10_000; + /** * What a launcher's session may write, relative to the team namespace. Team * profiles live under `teams//`, personal ones at the root. @@ -103,6 +121,11 @@ export class SyncService implements OnModuleInit { private static readonly MAX_BATCH_ITEMS = 1000; private changeSubject = new Subject(); + /** A profile's group by the full key of its metadata object. */ + private groupCache = new Map< + string, + { group: string | null | undefined; expires: number } + >(); private s3Ready = false; private backendInternalUrl: string | undefined; private backendInternalKey: string | undefined; @@ -384,9 +407,122 @@ export class SyncService implements OnModuleInit { }); } + /** + * The group of the profile whose root is `root` (relative), read from the + * `group-id` of its metadata object. `undefined` when the profile has no + * metadata object yet, `null` when it has one without a group id (written + * by an older app), so no group can be checked. + */ + private async profileGroup( + ctx: UserContext, + root: string, + ): Promise { + const key = `${ctx.prefix}${root}metadata.json`; + const now = Date.now(); + const cached = this.groupCache.get(key); + if (cached && cached.expires > now) return cached.group; + let group: string | null | undefined; + try { + const head = await this.s3Client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }), + ); + const value = head.Metadata?.["group-id"]; + group = typeof value === "string" && GROUP_ID.test(value) ? value : null; + } catch (error: unknown) { + const name = + error && typeof error === "object" && "name" in error + ? error.name + : undefined; + if (name !== "NotFound" && name !== "NoSuchKey") throw error; + group = undefined; + } + if (this.groupCache.size > GROUP_CACHE_MAX) this.groupCache.clear(); + this.groupCache.set(key, { group, expires: now + GROUP_CACHE_MS }); + return group; + } + + /** + * Whether a member limited to some groups may see the object at the scoped + * `key`. A profile's objects follow its group; a group's config object + * follows the group itself; everything else is shared team config. + */ + private async canReach(ctx: UserContext, key: string): Promise { + if (!ctx.groupIds) return true; + const relative = key.slice(ctx.prefix.length); + const groupConfig = GROUP_KEY.exec(relative); + if (groupConfig) return ctx.groupIds.includes(groupConfig[1]); + const profile = PROFILE_KEY.exec(relative); + if (!profile) return true; + const group = await this.profileGroup(ctx, profile[1]); + return typeof group === "string" && ctx.groupIds.includes(group); + } + + private async assertReachable(ctx: UserContext, key: string): Promise { + if (await this.canReach(ctx, key)) return; + throw new ForbiddenException({ + code: "TEAM_GROUP_ACCESS_DENIED", + message: "This member has no access to the group of this profile", + }); + } + + /** + * A limited member may change a profile only inside its groups: the group + * the profile is in now, and the group a new metadata object names. A new + * profile starts with its metadata, so its first write names its group. + */ + private async assertGroupWrite( + ctx: UserContext, + key: string, + metadata?: Record, + ): Promise { + if (!ctx.groupIds) return; + const relative = key.slice(ctx.prefix.length); + const groupConfig = GROUP_KEY.exec(relative); + const profile = PROFILE_KEY.exec(relative); + let allowed = true; + if (groupConfig) { + allowed = ctx.groupIds.includes(groupConfig[1]); + } else if (profile) { + const current = await this.profileGroup(ctx, profile[1]); + const isMetadata = relative === `${profile[1]}metadata.json`; + const named = metadata?.["group-id"]; + if (current === null) allowed = false; + if (typeof current === "string" && !ctx.groupIds.includes(current)) { + allowed = false; + } + if (current === undefined && !isMetadata) allowed = false; + if (isMetadata && !(named && ctx.groupIds.includes(named))) { + allowed = false; + } + if (allowed && isMetadata && named) { + // The next read must see the group this write gives the profile. + this.groupCache.delete(`${ctx.prefix}${profile[1]}metadata.json`); + } + } + if (allowed) return; + throw new ForbiddenException({ + code: "TEAM_GROUP_ACCESS_DENIED", + message: "This member has no access to the group of this profile", + }); + } + + /** `items` without the ones a limited member may not see. */ + private async reachableOnly( + ctx: UserContext, + items: T[], + keyOf: (item: T) => string, + ): Promise { + if (!ctx.groupIds) return items; + const verdicts = await Promise.all( + items.map((item) => this.canReach(ctx, keyOf(item))), + ); + return items.filter((_, index) => verdicts[index]); + } + async stat(dto: StatRequestDto, ctx: UserContext): Promise { const key = this.scopeKey(ctx, dto.key); this.validateKeyAccess(ctx, key); + await this.assertReachable(ctx, key); try { const response = await this.s3Client.send( @@ -424,6 +560,10 @@ export class SyncService implements OnModuleInit { ): Promise { const key = this.scopeKey(ctx, dto.key); this.validateWriteAccess(ctx, key); + // Whitelist metadata to the keys sync relies on, so a client can't sign + // arbitrary x-amz-meta-* values into its objects. + const metadata = sanitizeMetadata(dto.metadata); + await this.assertGroupWrite(ctx, key, metadata); // Check profile limit for cloud users if (ctx.mode === "cloud" && ctx.profileLimit > 0) { @@ -433,9 +573,6 @@ export class SyncService implements OnModuleInit { const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); - // Whitelist metadata to the single key sync relies on, so a client can't - // sign arbitrary x-amz-meta-* values into its objects. - const metadata = sanitizeMetadata(dto.metadata); const command = new PutCmd({ Bucket: this.bucket, Key: key, @@ -481,6 +618,7 @@ export class SyncService implements OnModuleInit { ): Promise { const key = this.scopeKey(ctx, dto.key); this.validateKeyAccess(ctx, key); + await this.assertReachable(ctx, key); const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); @@ -504,6 +642,7 @@ export class SyncService implements OnModuleInit { ): Promise { const key = this.scopeKey(ctx, dto.key); this.validateWriteAccess(ctx, key); + await this.assertGroupWrite(ctx, key); const scopedTombstoneKey = dto.tombstoneKey ? this.scopeKey(ctx, dto.tombstoneKey) : undefined; @@ -585,7 +724,13 @@ export class SyncService implements OnModuleInit { }); return { - objects, + objects: ctx + ? await this.reachableOnly( + ctx, + objects, + (object) => `${userPrefix}${object.key}`, + ) + : objects, isTruncated: response.IsTruncated || false, nextContinuationToken: response.NextContinuationToken, }; @@ -617,6 +762,7 @@ export class SyncService implements OnModuleInit { dto.items.map(async (item) => { const key = this.scopeKey(ctx, item.key); this.validateWriteAccess(ctx, key); + await this.assertGroupWrite(ctx, key); const command = new PutCmd({ Bucket: this.bucket, @@ -680,6 +826,7 @@ export class SyncService implements OnModuleInit { dto.keys.map(async (rawKey) => { const key = this.scopeKey(ctx, rawKey); this.validateKeyAccess(ctx, key); + await this.assertReachable(ctx, key); const command = new GetObjectCommand({ Bucket: this.bucket, @@ -712,6 +859,7 @@ export class SyncService implements OnModuleInit { // verbatim) could wipe an entire shared namespace. Enforce scope, and // refuse an empty scoped prefix (which would match the whole scope). this.validateWriteAccess(ctx, prefix); + await this.assertGroupWrite(ctx, prefix); if (ctx.mode === "cloud" && prefix.length === 0) { throw new ForbiddenException("Refusing to delete an empty prefix"); } @@ -941,7 +1089,13 @@ export class SyncService implements OnModuleInit { lastKnownState = currentState; initialized = true; - return events; + // A limited member learns nothing about profiles outside its groups, + // not even that they changed. + return this.reachableOnly( + ctx, + events, + (event) => `${ctx.prefix}${event.key ?? ""}`, + ); }), switchMap((events) => of(...events)), filter((event): event is SubscribeEventDto => event !== null), diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 0516b41c..b5eb53c8 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -375,12 +375,18 @@ export const commandCoverage = { "agent_schedules::delete_agent_schedule", "agent_schedules::run_agent_schedule_now", "agent_schedules::list_agent_schedule_runs", + "agent_schedules::test_agent_schedule_notice", ], }, teamActivity: { suite: "integrations", level: "contract", - commands: ["team_activity::list_team_activity"], + commands: [ + "team_activity::list_team_activity", + "team_activity::export_team_activity_csv", + "team_members::list_team_members", + "team_members::set_team_member_access", + ], }, feedback: { suite: "ui", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index cd9ea9f8..f5410933 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -1617,6 +1617,24 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }), /"code":"TEAM_ACTIVITY_FORBIDDEN"/, ); + assert.match( + await app.invokeError("export_team_activity_csv", { + path: "/nonexistent/activity.csv", + filter: { targetType: "profile" }, + }), + /"code":"TEAM_ACTIVITY_FORBIDDEN"/, + ); + assert.match( + await app.invokeError("list_team_members"), + /"code":"TEAM_ACCESS_FORBIDDEN"/, + ); + assert.match( + await app.invokeError("set_team_member_access", { + userId: "u-1", + groups: ["g1"], + }), + /"code":"TEAM_ACCESS_FORBIDDEN"/, + ); assert.match( await app.invokeError("create_agent_schedule", { schedule: { @@ -1657,6 +1675,10 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de await app.invokeError("list_agent_schedule_runs", { id: scheduleId }), notSignedIn, ); + assert.match( + await app.invokeError("test_agent_schedule_notice", { id: scheduleId }), + notSignedIn, + ); // Recipes live in the cloud; signed out, every call refuses with a code. assert.match(await app.invokeError("get_agent_recipes"), notSignedIn); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index fbac369a..f18dd6e0 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -3083,6 +3083,23 @@ test("scheduled recipes stay behind the plan, and a schedule is built from the f await app.clickSelector('[data-slot="agent-schedule-new"]'); await app.fillSelector("#agent-schedule-name", "Weekday check"); await app.clickSelector(`#agent-schedule-profile-${profile.id}`); + // A webhook must be https; the form says so and does not save. + const saveDisabled = () => + app.execute( + `return document.querySelector('[data-slot="agent-schedule-save"]').disabled;`, + ); + await app.fillSelector( + "#agent-schedule-webhook", + "http://hooks.example.test/donut", + ); + await app.waitFor(saveDisabled, { description: "save refused" }); + await app.fillSelector( + "#agent-schedule-webhook", + "https://hooks.example.test/donut", + ); + await app.waitFor(async () => !(await saveDisabled()), { + description: "save allowed", + }); await app.capture("agent-schedule-editor"); await app.clickSelector('[data-slot="agent-schedule-save"]'); await app.waitFor( @@ -3105,6 +3122,11 @@ test("scheduled recipes stay behind the plan, and a schedule is built from the f assert.equal(created.schedule.runAtMinute, 9 * 60); assert.ok(created.schedule.timezone); assert.equal(created.schedule.enabled, true); + assert.equal(created.schedule.notifyEmail, true, "email on by default"); + assert.equal( + created.schedule.webhookUrl, + "https://hooks.example.test/donut", + ); assert.equal( created.schedule.intervalMinutes, undefined, @@ -3120,6 +3142,12 @@ test("a launcher seat opens the team's profiles but cannot change them, and an o "ui-team-roles", async (app) => { const profile = await createUiProfile(app, "Team Profile"); + const group = await app.invoke("create_profile_group", { name: "Shop" }); + const calls = (command) => + app.execute( + "return (window.__donutStubbedCalls ?? []).filter((c) => c.command === arguments[0]).map((c) => c.payload);", + [command], + ); const user = (teamRole) => ({ logged_in_at: new Date().toISOString(), user: { @@ -3249,6 +3277,26 @@ test("a launcher seat opens the team's profiles but cannot change them, and an o nextCursor: null, }); await rm(launcherSeat); + await stubCommand(app, "list_team_members", [ + { + userId: "ui-team-seat", + email: "seat@example.test", + role: "owner", + groupAccess: null, + }, + { + userId: "u-admin", + email: "admin@example.test", + role: "admin", + groupAccess: null, + }, + { + userId: "u-launcher", + email: "launcher@example.test", + role: "launcher", + groupAccess: null, + }, + ]); await signIn("owner"); await closeWelcome(); await app.clickSelector(`[aria-label="${en.rail.account}"]`); @@ -3286,6 +3334,72 @@ test("a launcher seat opens the team's profiles but cannot change them, and an o ); assert.match(rows[2], new RegExp(en.teamActivity.actions.memberJoin)); await app.capture("team-activity-log"); + + // A filter reads the log again, narrowed in the cloud. + await app.clickSelector('[data-testid="team-activity-member"]'); + await app.clickText("launcher@example.test", { roles: ["option"] }); + await app.waitFor( + async () => + (await calls("list_team_activity")).some( + (payload) => payload?.filter?.actorUserId === "u-launcher", + ), + { description: "the log read for one member" }, + ); + + // The export saves what the filters show. + await stubCommand(app, "plugin:dialog|save", "/tmp/team-activity.csv"); + await stubCommand(app, "export_team_activity_csv", 3); + await app.clickSelector('[data-testid="team-activity-export"]'); + await app.waitFor( + async () => (await calls("export_team_activity_csv")).length === 1, + { description: "the CSV export" }, + ); + const [exported] = await calls("export_team_activity_csv"); + assert.equal(exported.path, "/tmp/team-activity.csv"); + assert.equal(exported.filter.actorUserId, "u-launcher"); + + // Access by group: the owner limits the launcher to one group. + await app.clickText(en.account.tabs.teamAccess, { roles: ["tab"] }); + await app.waitFor( + () => + app.execute( + `return document.querySelectorAll('[data-testid="team-access"] li').length === 3;`, + ), + { description: "the team's members" }, + ); + assert.equal( + await app.execute( + `return document.querySelectorAll('[data-member="ui-team-seat"] button').length;`, + ), + 0, + "the owner is never limited", + ); + await stubCommand(app, "set_team_member_access", { + userId: "u-launcher", + email: "launcher@example.test", + role: "launcher", + groupAccess: [group.id], + }); + await app.clickSelector('[data-member="u-launcher"] button'); + await app.clickSelector('label[for="access-some-u-launcher"]'); + await app.clickSelector(`#access-u-launcher-${group.id}`); + await app.capture("team-access-editor"); + await app.clickSelector('[data-testid="team-access-save"]'); + await app.waitFor( + async () => (await calls("set_team_member_access")).length === 1, + { description: "the access sent to the cloud" }, + ); + const [access] = await calls("set_team_member_access"); + assert.deepEqual(access, { userId: "u-launcher", groups: [group.id] }); + await app.waitFor( + async () => + ( + await app.execute( + `return document.querySelector('[data-member="u-launcher"]')?.innerText ?? "";`, + ) + ).includes(en.teamAccess.someGroups_one.replace("{{count}}", "1")), + { description: "the new access on the row" }, + ); await dismissSurface(app); }, { seedDownloadedBrowser: true }, diff --git a/src-tauri/src/agent_schedules.rs b/src-tauri/src/agent_schedules.rs index b5ce62a1..4140cf27 100644 --- a/src-tauri/src/agent_schedules.rs +++ b/src-tauri/src/agent_schedules.rs @@ -29,12 +29,16 @@ fn url_for(id: &str, tail: &str) -> Result { Ok(format!("{}/{}{tail}", base(), urlencoding::encode(id))) } -fn failure(action: &str, err: BackendFailure) -> String { +fn failure(action: &str, mut err: BackendFailure) -> String { log::warn!( "Schedule request failed action={action} code={} status={}", err.code, err.status ); + // The cloud names a schedule a job; the app has one word for it. + if err.code == "AGENT_JOB_NOT_FOUND" { + err.code = "AGENT_SCHEDULE_NOT_FOUND".to_string(); + } err.to_error_json() } @@ -110,6 +114,18 @@ pub async fn list_agent_schedule_runs(id: String) -> Result { call("runs", reqwest::Method::GET, url_for(&id, "/runs")?, None).await } +/// Send a test email and webhook for the schedule `id`, as a failed run would. +#[tauri::command] +pub async fn test_agent_schedule_notice(id: String) -> Result { + call( + "test_notice", + reqwest::Method::POST, + url_for(&id, "/test-notice")?, + Some(serde_json::json!({})), + ) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/cloud_api.rs b/src-tauri/src/cloud_api.rs index b13385df..800de3d5 100644 --- a/src-tauri/src/cloud_api.rs +++ b/src-tauri/src/cloud_api.rs @@ -28,6 +28,28 @@ pub(crate) async fn call( body: Option, codes: FailureCodes, ) -> Result { + let bytes = send(method, url, body, codes).await?; + decode_body::(&bytes) + .map_err(|e| cloud_errors::classify_message(&format!("decode response: {e}"), codes)) +} + +/// `call` for an answer that is text (a CSV file), not JSON. +pub(crate) async fn call_text( + method: reqwest::Method, + url: String, + codes: FailureCodes, +) -> Result { + let bytes = send(method, url, None, codes).await?; + String::from_utf8(bytes) + .map_err(|e| cloud_errors::classify_message(&format!("decode response: {e}"), codes)) +} + +async fn send( + method: reqwest::Method, + url: String, + body: Option, + codes: FailureCodes, +) -> Result, BackendFailure> { crate::cloud_auth::CLOUD_AUTH .api_call_with_retry(|token| { let method = method.clone(); @@ -48,11 +70,11 @@ pub(crate) async fn call( // api_call_with_retry and classify_message read the status from this prefix. return Err(format!("({status}) {text}")); } - let bytes = response + response .bytes() .await - .map_err(|e| format!("decode response: {e}"))?; - decode_body::(&bytes).map_err(|e| format!("decode response: {e}")) + .map(|bytes| bytes.to_vec()) + .map_err(|e| format!("decode response: {e}")) } }) .await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7f7be60..55bebd1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,6 +160,7 @@ mod navigation_watch; mod tag_manager; pub mod team_activity; mod team_lock; +mod team_members; pub mod team_roles; mod totp; mod vault; @@ -3557,6 +3558,7 @@ pub fn run_with_builder( agent_schedules::delete_agent_schedule, agent_schedules::run_agent_schedule_now, agent_schedules::list_agent_schedule_runs, + agent_schedules::test_agent_schedule_notice, start_mcp_remote_bridge, stop_mcp_remote_bridge, get_mcp_remote_status, @@ -3658,6 +3660,9 @@ pub fn run_with_builder( lock_profile, is_profile_locked, team_activity::list_team_activity, + team_activity::export_team_activity_csv, + team_members::list_team_members, + team_members::set_team_member_access, ])) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 1eee62c3..bb22b019 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -23,6 +23,23 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore}; /// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts /// (last-write-wins) from a HEAD request without downloading the object body. const UPDATED_AT_META_KEY: &str = "updated-at"; +/// The profile's group, in plain text on its metadata object, so the sync +/// server can hold a team member to the groups the owner gave them. +const GROUP_ID_META_KEY: &str = "group-id"; + +/// Set when the server signed an upload without `group-id`: an older server +/// that keeps no group, so asking it again on every sync changes nothing. +static GROUP_META_UNSUPPORTED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// The `group-id` a profile's metadata object carries. +fn group_meta(profile: &BrowserProfile) -> String { + profile + .group_id + .clone() + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| crate::team_members::UNGROUPED.to_string()) +} static TOMBSTONE_CHECK: KeyedStreak = KeyedStreak::new(module_path!(), "Sync tombstone check"); @@ -598,6 +615,15 @@ impl SyncEngine { String::new() } + /// Whether the signed-in account holds a team seat, where access by group + /// applies. + async fn is_team_seat() -> bool { + crate::cloud_auth::CLOUD_AUTH + .get_user() + .await + .is_some_and(|auth| auth.user.team_id.is_some()) + } + /// Check if this is a self-hosted sync (no cloud login). async fn is_self_hosted_sync() -> bool { !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await @@ -643,18 +669,38 @@ impl SyncEngine { remote_key: &str, json: &str, updated_at: u64, + ) -> SyncResult<()> { + self + .upload_config_json_with(remote_key, json, updated_at, HashMap::new()) + .await + } + + /// `upload_config_json`, with more plain-text object metadata. + async fn upload_config_json_with( + &self, + remote_key: &str, + json: &str, + updated_at: u64, + mut meta: HashMap, ) -> 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(); meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string()); + let wants_group = meta.contains_key(GROUP_ID_META_KEY); let presign = self .client .presign_upload_with_metadata(remote_key, Some(content_type), Some(meta)) .await?; + if wants_group { + let kept = presign + .metadata + .as_ref() + .is_some_and(|signed| signed.contains_key(GROUP_ID_META_KEY)); + GROUP_META_UNSUPPORTED.store(!kept, std::sync::atomic::Ordering::Relaxed); + } self .client .upload_bytes_with_metadata( @@ -1255,7 +1301,13 @@ impl SyncEngine { let local_updated = profile.updated_at.unwrap_or(0); let remote_updated = self.remote_updated_at(&stat, &remote_key).await; if !self.remote_wins(local_updated, remote_updated) { - if local_updated > remote_updated { + // An object an older app wrote has no group, and a member limited to + // some groups cannot reach it until it has one. + let group_differs = stat.metadata.as_ref().is_some_and(|meta| { + meta.get(GROUP_ID_META_KEY).map(String::as_str) != Some(group_meta(profile).as_str()) + }) && !GROUP_META_UNSUPPORTED.load(std::sync::atomic::Ordering::Relaxed) + && Self::is_team_seat().await; + if local_updated > remote_updated || group_differs { self .upload_profile_metadata(&profile_id, profile, &key_prefix) .await?; @@ -1322,8 +1374,9 @@ impl SyncEngine { .map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?; let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); + let meta = HashMap::from([(GROUP_ID_META_KEY.to_string(), group_meta(&sanitized))]); self - .upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0)) + .upload_config_json_with(&remote_key, &json, sanitized.updated_at.unwrap_or(0), meta) .await?; Ok(()) diff --git a/src-tauri/src/team_activity.rs b/src-tauri/src/team_activity.rs index ef66d304..5bb42bed 100644 --- a/src-tauri/src/team_activity.rs +++ b/src-tauri/src/team_activity.rs @@ -163,39 +163,144 @@ async fn flush() { } } +/// What part of the team's log to read. Every field is optional. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityFilter { + /// An action (`profile.delete`) or every action of a kind (`profile.*`). + pub action: Option, + pub actor_user_id: Option, + pub target_type: Option, + /// The oldest time to include, ISO 8601. + pub since: Option, +} + +const TARGET_TYPES: &[&str] = &[ + "profile", + "proxy", + "group", + "vpn", + "extension", + "template", + "status", + "member", + "invitation", +]; + +impl ActivityFilter { + /// The query pairs this filter adds, or `TEAM_ACTIVITY_INVALID`. + fn query(&self) -> Result, String> { + let invalid = || crate::backend_error("TEAM_ACTIVITY_INVALID"); + let present = |value: &Option| value.clone().filter(|v| !v.is_empty()); + let mut query = Vec::new(); + if let Some(action) = present(&self.action) { + let (kind, verb) = action.split_once('.').ok_or_else(invalid)?; + let word = |w: &str| !w.is_empty() && w.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'); + if action.len() > 64 || !word(kind) || !(verb == "*" || word(verb)) { + return Err(invalid()); + } + query.push(format!("action={}", urlencoding::encode(&action))); + } + if let Some(actor) = present(&self.actor_user_id) { + if actor.len() > 128 + || !actor + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(invalid()); + } + query.push(format!("actorUserId={actor}")); + } + if let Some(target_type) = present(&self.target_type) { + if !TARGET_TYPES.contains(&target_type.as_str()) { + return Err(invalid()); + } + query.push(format!("targetType={target_type}")); + } + if let Some(since) = present(&self.since) { + let at = chrono::DateTime::parse_from_rfc3339(&since).map_err(|_| invalid())?; + query.push(format!( + "since={}", + urlencoding::encode( + &at + .to_utc() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + ) + )); + } + Ok(query) + } +} + +async fn signed_in_team() -> Result { + crate::cloud_auth::CLOUD_AUTH + .get_user() + .await + .and_then(|auth| auth.user.team_id) + .ok_or_else(|| crate::backend_error("TEAM_ACTIVITY_FORBIDDEN")) +} + +fn activity_failure(err: crate::cloud_errors::BackendFailure) -> String { + log::warn!( + "Team activity request failed code={} status={}", + err.code, + err.status + ); + err.to_error_json() +} + /// 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, limit: Option, - action: Option, + filter: Option, ) -> Result { - 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())?; + let team_id = signed_in_team().await?; + let url = activity_url( + &team_id, + "activity", + cursor.as_deref(), + limit, + &filter.unwrap_or_default(), + )?; crate::cloud_api::call::(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() - }) + .map_err(activity_failure) +} + +/// The filtered log as a CSV file at `path`, which the person chose. Returns +/// how many rows it holds. +#[tauri::command] +pub async fn export_team_activity_csv( + path: String, + filter: Option, +) -> Result { + let team_id = signed_in_team().await?; + let url = activity_url( + &team_id, + "activity.csv", + None, + None, + &filter.unwrap_or_default(), + )?; + let csv = crate::cloud_api::call_text(reqwest::Method::GET, url, ACTIVITY_CODES) + .await + .map_err(activity_failure)?; + std::fs::write(&path, csv.as_bytes()) + .map_err(|e| crate::backend_error_with_detail("TEAM_ACTIVITY_EXPORT_FAILED", e))?; + let rows = csv.lines().count().saturating_sub(1) as u64; + log::info!("Team activity exported rows={rows}"); + Ok(rows) } fn activity_url( team_id: &str, + resource: &str, cursor: Option<&str>, limit: Option, - action: Option<&str>, + filter: &ActivityFilter, ) -> Result { let mut query = Vec::new(); if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { @@ -207,11 +312,9 @@ fn activity_url( 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)))); - } + query.extend(filter.query()?); let mut url = format!( - "{}/api/teams/{}/activity", + "{}/api/teams/{}/{resource}", crate::cloud_auth::CLOUD_API_URL, urlencoding::encode(team_id) ); @@ -264,14 +367,52 @@ mod tests { #[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(); + let filter = ActivityFilter { + action: Some("profile.*".into()), + actor_user_id: Some("u-1".into()), + target_type: Some("proxy".into()), + since: Some("2026-10-01T12:00:00+02:00".into()), + }; + let url = activity_url("a/b", "activity", Some("42"), Some(500), &filter).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) + assert!(url.contains("action=profile.%2A"), "{url}"); + assert!(url.contains("actorUserId=u-1")); + assert!(url.contains("targetType=proxy")); + assert!(url.contains("since=2026-10-01T10%3A00%3A00Z"), "{url}"); + let plain = ActivityFilter::default(); + assert!(activity_url("t1", "activity.csv", None, None, &plain) .unwrap() - .ends_with("/api/teams/t1/activity")); - assert!(activity_url("t1", Some("1;drop"), None, None).is_err()); + .ends_with("/api/teams/t1/activity.csv")); + assert!(activity_url("t1", "activity", Some("1;drop"), None, &plain).is_err()); + } + + #[test] + fn a_filter_the_cloud_would_refuse_is_refused_here() { + for filter in [ + ActivityFilter { + action: Some("Profile.delete".into()), + ..Default::default() + }, + ActivityFilter { + action: Some("profile".into()), + ..Default::default() + }, + ActivityFilter { + actor_user_id: Some("a&b=c".into()), + ..Default::default() + }, + ActivityFilter { + target_type: Some("planet".into()), + ..Default::default() + }, + ActivityFilter { + since: Some("yesterday".into()), + ..Default::default() + }, + ] { + assert!(filter.query().is_err(), "{filter:?}"); + } } } diff --git a/src-tauri/src/team_members.rs b/src-tauri/src/team_members.rs new file mode 100644 index 00000000..55390d28 --- /dev/null +++ b/src-tauri/src/team_members.rs @@ -0,0 +1,135 @@ +//! The team's members and the profile groups each one may reach. Owners and +//! admins set the access here, because group names live only in the app; the +//! cloud stores group ids and the sync server holds each member to them. + +use serde_json::{json, Value}; + +use crate::cloud_errors::FailureCodes; + +/// The `group-id` the sync server uses for profiles in no group. +pub const UNGROUPED: &str = "__ungrouped__"; +const MAX_GROUPS: usize = 500; + +const MEMBER_CODES: FailureCodes = FailureCodes { + bad_request: "TEAM_ACCESS_INVALID", + forbidden: "TEAM_ACCESS_FORBIDDEN", + not_found: "TEAM_MEMBER_NOT_FOUND", + conflict: "TEAM_ACCESS_INVALID", +}; + +async fn team_id() -> Result { + crate::cloud_auth::CLOUD_AUTH + .get_user() + .await + .and_then(|auth| auth.user.team_id) + .ok_or_else(|| crate::backend_error("TEAM_ACCESS_FORBIDDEN")) +} + +fn failure(action: &str, err: crate::cloud_errors::BackendFailure) -> String { + log::warn!( + "Team member request failed action={action} code={} status={}", + err.code, + err.status + ); + err.to_error_json() +} + +fn is_group_id(id: &str) -> bool { + (1..=128).contains(&id.len()) + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// The access to send: `None` is every group; a list is deduplicated, in order. +fn checked_groups(groups: Option>) -> Result>, String> { + let Some(groups) = groups else { + return Ok(None); + }; + let mut seen = std::collections::HashSet::new(); + let groups: Vec = groups + .into_iter() + .map(|id| id.trim().to_string()) + .filter(|id| seen.insert(id.clone())) + .collect(); + if groups.len() > MAX_GROUPS || !groups.iter().all(|id| is_group_id(id)) { + return Err(crate::backend_error("TEAM_ACCESS_INVALID")); + } + Ok(Some(groups)) +} + +fn members_url(team_id: &str, tail: &str) -> String { + format!( + "{}/api/teams/{}/members{tail}", + crate::cloud_auth::CLOUD_API_URL, + urlencoding::encode(team_id) + ) +} + +#[tauri::command] +pub async fn list_team_members() -> Result { + let team = team_id().await?; + crate::cloud_api::call::( + reqwest::Method::GET, + members_url(&team, ""), + None, + MEMBER_CODES, + ) + .await + .map_err(|e| failure("list", e)) +} + +/// Limit `user_id` to `groups`, or give back every group with `None`. +#[tauri::command] +pub async fn set_team_member_access( + user_id: String, + groups: Option>, +) -> Result { + let team = team_id().await?; + if !is_group_id(user_id.trim()) { + return Err(crate::backend_error("TEAM_MEMBER_NOT_FOUND")); + } + let groups = checked_groups(groups)?; + let count = groups.as_ref().map(Vec::len); + let url = members_url( + &team, + &format!("/{}/access", urlencoding::encode(user_id.trim())), + ); + let member = crate::cloud_api::call::( + reqwest::Method::PUT, + url, + Some(json!({ "groups": groups })), + MEMBER_CODES, + ) + .await + .map_err(|e| failure("set_access", e))?; + match count { + Some(count) => log::info!("Team member access set groups={count}"), + None => log::info!("Team member access set groups=all"), + } + Ok(member) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_is_every_group_or_a_clean_list_of_ids() { + assert_eq!(checked_groups(None).unwrap(), None); + assert_eq!( + checked_groups(Some(vec!["g1".into(), " g1 ".into(), UNGROUPED.into()])).unwrap(), + Some(vec!["g1".to_string(), UNGROUPED.to_string()]) + ); + assert_eq!(checked_groups(Some(vec![])).unwrap(), Some(vec![])); + assert!(checked_groups(Some(vec!["a/b".into()])).is_err()); + assert!(checked_groups(Some(vec!["x".repeat(129)])).is_err()); + assert!(checked_groups(Some((0..501).map(|i| format!("g{i}")).collect())).is_err()); + } + + #[test] + fn a_member_id_is_one_path_segment() { + assert!(members_url("t/1", "/u/access").contains("/api/teams/t%2F1/members/u/access")); + assert!(!is_group_id("../x")); + } +} diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 7530d05c..2362c874 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -21,6 +21,7 @@ import { RemoteHoursMeter, } from "@/components/cookie-bot-shared"; import { LoadingButton } from "@/components/loading-button"; +import { TeamAccess } from "@/components/team-access"; import { TeamActivityLog } from "@/components/team-activity-log"; import { TeamUsagePanel } from "@/components/team-usage-panel"; import { @@ -115,7 +116,10 @@ export function AccountPage({ // click back to. useEffect(() => { if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account"); - if (!showTeamActivity && activeTab === "team-activity") { + if ( + !showTeamActivity && + (activeTab === "team-activity" || activeTab === "team-access") + ) { setActiveTab("account"); } }, [showTeamUsage, showTeamActivity, activeTab]); @@ -346,6 +350,11 @@ export function AccountPage({ {t("account.tabs.teamActivity")} )} + {showTeamActivity && ( + + {t("account.tabs.teamAccess")} + + )} )} + {showTeamActivity && ( + + + + )} + 0 && (target === "fleet" || canRunOnDesktop) && cadenceReady && + webhookValid && !isSaving; + const sendTestNotice = async () => { + if (!schedule) return; + setIsTesting(true); + try { + const result = await testScheduleNotice(schedule.id); + const failed = result.email === "failed" || result.webhook === "failed"; + if (failed) { + showErrorToast( + t("agent.schedules.notices.testFailed", { + status: result.webhookStatus ?? "-", + }), + ); + } else { + showSuccessToast(t("agent.schedules.notices.testSent")); + } + } catch (testError) { + showErrorToast(translateBackendError(t, testError)); + } finally { + setIsTesting(false); + } + }; + + const newSecret = async () => { + if (!schedule) return; + try { + const updated = await rotateWebhookSecret(schedule.id); + setWebhookSecret(updated.webhookSecret); + showSuccessToast(t("agent.schedules.notices.secretRotated")); + } catch (rotateError) { + showErrorToast(translateBackendError(t, rotateError)); + } + }; + const toggleProfile = (id: string, on: boolean) => { setProfileIds((current) => on @@ -472,6 +517,8 @@ function ScheduleEditor({ timezone, runOnceAt: onceDate?.toISOString(), intervalMinutes: interval, + notifyEmail, + webhookUrl: webhookUrl.trim() || null, }; try { if (schedule) { @@ -685,6 +732,76 @@ function ScheduleEditor({ )} +
+ + + setWebhookUrl(event.target.value)} + /> + {!webhookValid && ( +

+ {t("agent.schedules.notices.webhookInvalid")} +

+ )} + {schedule && + webhookSecret && + webhookUrl.trim() === schedule.webhookUrl && ( +
+ + {t("agent.schedules.notices.secret")}{" "} + {webhookSecret} + + + +
+ )} + {schedule && ( + + )} +
+
+ )} +
+ + {editing === member.userId && ( +
+ setEveryGroup(value === "all")} + > + + + + + {!everyGroup && ( +
+ {options.map((group) => ( + + ))} +
+ )} + +
+ + +
+
+ )} + + ))} + + )} + + ); +} diff --git a/src/components/team-activity-log.tsx b/src/components/team-activity-log.tsx index 9211c060..20fbf6f6 100644 --- a/src/components/team-activity-log.tsx +++ b/src/components/team-activity-log.tsx @@ -1,19 +1,39 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { translateBackendError } from "@/lib/backend-errors"; import { + ACTIVITY_PERIODS, + ACTIVITY_TARGET_TYPES, + type ActivityFilter, + type ActivityPeriod, actionKey, actorKind, + exportTeamActivityCsv, fieldKeys, listTeamActivity, + listTeamMembers, + sinceFor, type TeamActivityItem, + type TeamMember, targetLabel, } from "@/lib/team-activity"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; + +/** The value a filter Select holds for "no filter". */ +const ANY = "any"; interface Named { id: string; @@ -55,14 +75,34 @@ export function TeamActivityLog() { ); const [isLoading, setIsLoading] = React.useState(true); const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); const [error, setError] = React.useState(null); + const [members, setMembers] = React.useState([]); + const [actor, setActor] = React.useState(ANY); + const [targetType, setTargetType] = React.useState(ANY); + const [period, setPeriod] = React.useState("all"); + + const filter = React.useMemo( + () => ({ + actorUserId: actor === ANY ? undefined : actor, + targetType: targetType === ANY ? undefined : targetType, + since: sinceFor(period), + }), + [actor, targetType, period], + ); + + React.useEffect(() => { + void listTeamMembers() + .then(setMembers) + .catch(() => setMembers([])); + }, []); const load = React.useCallback(async () => { setIsLoading(true); setError(null); try { const [page, names] = await Promise.all([ - listTeamActivity(null), + listTeamActivity(null, filter), loadLocalNames(), ]); setItems(page.items); @@ -73,17 +113,34 @@ export function TeamActivityLog() { } finally { setIsLoading(false); } - }, [t]); + }, [t, filter]); React.useEffect(() => { void load(); }, [load]); + const exportCsv = async () => { + const path = await save({ + defaultPath: `team-activity-${new Date().toISOString().slice(0, 10)}.csv`, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!path) return; + setIsExporting(true); + try { + const rows = await exportTeamActivityCsv(path, filter); + showSuccessToast(t("teamActivity.exported", { count: rows })); + } catch (err) { + showErrorToast(translateBackendError(t, err)); + } finally { + setIsExporting(false); + } + }; + const loadMore = async () => { if (!nextCursor) return; setIsLoadingMore(true); try { - const page = await listTeamActivity(nextCursor); + const page = await listTeamActivity(nextCursor, filter); setItems((current) => [...current, ...page.items]); setNextCursor(page.nextCursor); } catch (err) { @@ -112,14 +169,23 @@ export function TeamActivityLog() { ? t("sync.team.roleLauncher") : t("sync.team.roleMember"); - /** "Member → Launcher" for a role change, when the log says both. */ - const roleChange = (item: TeamActivityItem) => { - const { from, to } = item.detail ?? {}; - return item.action === "member.role_change" && + /** "Member → Launcher" for a role change, or the groups an access change gave. */ + const memberChange = (item: TeamActivityItem) => { + const { from, to, groups } = item.detail ?? {}; + if ( + item.action === "member.role_change" && typeof from === "string" && typeof to === "string" - ? `${roleLabel(from)} → ${roleLabel(to)}` - : null; + ) { + return `${roleLabel(from)} → ${roleLabel(to)}`; + } + if (item.action === "member.access_change") { + if (groups === "all") return t("teamAccess.allGroups"); + if (typeof groups === "number") { + return t("teamAccess.someGroups", { count: groups }); + } + } + return null; }; const describe = (item: TeamActivityItem) => { @@ -140,14 +206,87 @@ export function TeamActivityLog() { {t("teamActivity.description")}

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