Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec49ef417 | ||
|
|
c13aa69565 | ||
|
|
1470adb159 | ||
|
|
bf89991eef | ||
|
|
4dfab874e0 | ||
|
|
4303fb8726 | ||
|
|
64f811ed0b | ||
|
|
4d51873140 | ||
|
|
3c0fc2e88b | ||
|
|
c702da0334 | ||
|
|
92169eb0d2 | ||
|
|
3d74357e96 | ||
|
|
6593291d00 | ||
|
|
c48fb44a8d | ||
|
|
e7c908bc80 | ||
|
|
ad35e19f8e | ||
|
|
3fc8c6c84e | ||
|
|
da7aeb4a21 | ||
|
|
6197a2a5c5 | ||
|
|
de186b95c1 | ||
|
|
6b24513073 | ||
|
|
55ea18128a | ||
|
|
d5557fa512 | ||
|
|
9c97b2aa6d | ||
|
|
1e226a40d1 | ||
|
|
bbe46eaa7d | ||
|
|
7799b191ec | ||
|
|
f3269a3ddf | ||
|
|
a4be822636 | ||
|
|
7cd8dbf7d8 | ||
|
|
654a865007 | ||
|
|
409a0277fb | ||
|
|
7adab1f65f | ||
|
|
3b92c86582 | ||
|
|
de195a1c0f | ||
|
|
d7d2487f6d | ||
|
|
3e31945157 | ||
|
|
c11814c342 | ||
|
|
60e3244088 | ||
|
|
a1ac5f36f0 | ||
|
|
6f38d40613 | ||
|
|
6b7d22de3f | ||
|
|
2f763dceda | ||
|
|
96468c4083 | ||
|
|
858d52563f | ||
|
|
7505b3b271 | ||
|
|
758dc88b55 | ||
|
|
9be418dc65 | ||
|
|
2ac310456e | ||
|
|
ff264d6f7a | ||
|
|
8019c414e1 | ||
|
|
a3fbe3731e | ||
|
|
e3742dbc66 | ||
|
|
83bf717d6d | ||
|
|
7fcdaa1c46 | ||
|
|
a8154f9405 | ||
|
|
52a5afeba5 | ||
|
|
43cc40d7fe | ||
|
|
42a98f000e | ||
|
|
f003ef935b | ||
|
|
e2b2a184c8 | ||
|
|
094595c435 | ||
|
|
4118a86c0d | ||
|
|
928d6f3821 | ||
|
|
b2ade568c8 | ||
|
|
27820ce897 | ||
|
|
fea7e66996 | ||
|
|
474d1571ae | ||
|
|
2ef627b87e | ||
|
|
2dfb82b623 | ||
|
|
ccd7fdfd52 | ||
|
|
f92e59ed34 | ||
|
|
d29477349a | ||
|
|
259e480baa | ||
|
|
0055dd1142 | ||
|
|
61c44780f0 | ||
|
|
9710d3b15c | ||
|
|
04cefebd75 | ||
|
|
dbd927c002 | ||
|
|
e6b2116a2b | ||
|
|
855a02091f | ||
|
|
358434429e | ||
|
|
b00061d85e | ||
|
|
022f08c05e | ||
|
|
6ff5c8ce5f | ||
|
|
3be7fb9dd8 | ||
|
|
72dd970b6e | ||
|
|
f4d09ece43 | ||
|
|
239e6085ba | ||
|
|
b17eab3aeb | ||
|
|
752f7fcde0 | ||
|
|
f6b061f787 | ||
|
|
cc8a1290ee | ||
|
|
b7cf893169 | ||
|
|
1458cacdbb |
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
GOOGLE_WEBHOOK_URL=
|
||||
|
||||
# Token to verify incoming webhooks from Microsoft Calendar
|
||||
MICROSOFT_WEBHOOK_TOKEN=
|
||||
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
MICROSOFT_WEBHOOK_URL=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
@@ -261,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
|
||||
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# 0 = false, 1=true
|
||||
NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER=
|
||||
|
||||
# Set the following value to true if you wish to enable Team Impersonation
|
||||
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
||||
|
||||
@@ -473,3 +482,7 @@ DATABASE_CHUNK_SIZE=
|
||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
|
||||
SALESFORCE_GRAPHQL_DELAY_MS=500
|
||||
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
|
||||
SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||
/packages/embeds/**/* @calcom/Foundation
|
||||
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: WARN
|
||||
fail-on: ERR
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||
|
||||
/** Only OWNERS and ADMINS can edit teams */
|
||||
const _team = await prisma.team.findFirst({
|
||||
const team = await prisma.team.findFirst({
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { members: true },
|
||||
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||
});
|
||||
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
||||
|
||||
const slugAlreadyExists = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: {
|
||||
mode: "insensitive",
|
||||
equals: data.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (slugAlreadyExists && data.slug !== _team.slug)
|
||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
if (data.slug) {
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
|
||||
slug: data.slug,
|
||||
teamId: team.id,
|
||||
parentId: team.parentId,
|
||||
});
|
||||
if (!isSlugAvailable) {
|
||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parentId is related to this user
|
||||
if (data.parentId && data.parentId === teamId) {
|
||||
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
}
|
||||
|
||||
let paymentUrl;
|
||||
if (_team.slug === null && data.slug) {
|
||||
if (team.slug === null && data.slug) {
|
||||
data.metadata = {
|
||||
...(_team.metadata as Prisma.JsonObject),
|
||||
...(team.metadata as Prisma.JsonObject),
|
||||
requestedSlug: data.slug,
|
||||
};
|
||||
delete data.slug;
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
||||
teamId: _team.id,
|
||||
seatsUsed: _team.members.length,
|
||||
teamId: team.id,
|
||||
seatsUsed: team.members.length,
|
||||
userId,
|
||||
pricePerSeat: null,
|
||||
});
|
||||
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
const result = {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
team: schemaTeamReadPublic.parse(updatedTeam),
|
||||
paymentUrl,
|
||||
};
|
||||
if (!paymentUrl) {
|
||||
|
||||
@@ -76,7 +76,7 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
|
||||
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
|
||||
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import handler from "../../../pages/api/selected-calendars/_post";
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
|
||||
|
||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||
id: 444444,
|
||||
} as any);
|
||||
} as User);
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { NextApiRequest } from "next";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { WatchlistSeverity } from "@calcom/prisma/enums";
|
||||
|
||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||
|
||||
describe("isLockedOrBlocked", () => {
|
||||
@@ -18,7 +17,6 @@ describe("isLockedOrBlocked", () => {
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "blocked.com",
|
||||
severity: WatchlistSeverity.CRITICAL,
|
||||
createdById: 1,
|
||||
},
|
||||
],
|
||||
@@ -26,13 +24,13 @@ describe("isLockedOrBlocked", () => {
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
const req = { userId: null, user: null } as any;
|
||||
const req = { userId: null, user: null } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if user has no email", async () => {
|
||||
const req = { userId: 123, user: { email: null } } as any;
|
||||
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -44,7 +42,7 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: true,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
@@ -57,7 +55,7 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@blocked.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
@@ -70,7 +68,7 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
@@ -83,7 +81,7 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@BLOCKED.COM",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -65,6 +65,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let team1EventTypeId: number;
|
||||
let team2EventTypeId: number;
|
||||
let phoneOnlyEventTypeId: number;
|
||||
let collectiveEventWithoutHostsId: number;
|
||||
let roundRobinEventWithoutHostsId: number;
|
||||
|
||||
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
@@ -223,6 +225,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
team1EventTypeId = team1EventType.id;
|
||||
|
||||
const team1CollectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||
slug: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
});
|
||||
collectiveEventWithoutHostsId = team1CollectiveEventTypeWithoutHosts.id;
|
||||
|
||||
const team1RoundRobinEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||
slug: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
});
|
||||
roundRobinEventWithoutHostsId = team1RoundRobinEventTypeWithoutHosts.id;
|
||||
|
||||
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
@@ -363,6 +387,52 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("cant book event types without hosts", () => {
|
||||
it("should fail to book a collective event type without hosts", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: collectiveEventWithoutHostsId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
timeZone: "Europe/Madrid",
|
||||
email: "alice@gmail.com",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${collectiveEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail to book a round robin event type without hosts", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: roundRobinEventWithoutHostsId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
timeZone: "Europe/Madrid",
|
||||
email: "alice@gmail.com",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${roundRobinEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create team bookings", () => {
|
||||
it("should create a team 1 booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -765,8 +835,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -132,6 +133,10 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
|
||||
await this.checkEventTypeHasHosts(eventType.id);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
@@ -159,6 +164,15 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async checkEventTypeHasHosts(eventTypeId: number) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
|
||||
if (!eventType?.hosts?.length) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async checkBookingRequiresAuthenticationSetting(
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
authUser: AuthOptionalUser,
|
||||
|
||||
+51
-9
@@ -38,6 +38,7 @@ import type {
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
SplitNameDefaultFieldOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(falseTestUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
try {
|
||||
await userRepositoryFixture.delete(orgUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2187,6 +2188,47 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split name booking field", () => {
|
||||
it("should create an event type with split name booking field", async () => {
|
||||
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
|
||||
isDefault: true,
|
||||
type: "splitName",
|
||||
slug: "splitName",
|
||||
firstNameLabel: "First name",
|
||||
firstNamePlaceholder: "",
|
||||
lastNameLabel: "last name",
|
||||
lastNamePlaceholder: "",
|
||||
lastNameRequired: false,
|
||||
disableOnPrefill: false,
|
||||
};
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 9",
|
||||
slug: "coding-class-9",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookingFields: [splitNameBookingField],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
|
||||
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
|
||||
(field) => field.type === "splitName"
|
||||
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
+expect(updatedEventType.hidden).toBe(false);
|
||||
expect(updatedEventType.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AtomsSecondaryEmailsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getSecondaryEmailsVerified(userId: number) {
|
||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
userId_email: { userId, email },
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmail(email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async addSecondaryEmailVerified(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||
data: {
|
||||
userId,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
AtomsSecondaryEmailsRepository,
|
||||
UsersService,
|
||||
SchedulesAtomsService,
|
||||
VerificationAtomsService,
|
||||
|
||||
@@ -64,47 +64,4 @@ export class AtomsRepository {
|
||||
|
||||
return userTeams;
|
||||
}
|
||||
|
||||
async getSecondaryEmails(userId: number) {
|
||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
userId_email: { userId, email },
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmail(email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async addSecondaryEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||
data: {
|
||||
userId,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
@Injectable()
|
||||
export class VerificationAtomsService {
|
||||
constructor(
|
||||
private readonly atomsRepository: AtomsRepository,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
private readonly atomsSecondaryEmailsRepository: AtomsSecondaryEmailsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||
@@ -76,7 +76,8 @@ export class VerificationAtomsService {
|
||||
|
||||
if (teamId) {
|
||||
const verifiedEmails: string[] = [];
|
||||
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
|
||||
|
||||
const teamMembers = await this.usersRepository.getUserEmailsVerifiedForTeam(teamId);
|
||||
|
||||
if (teamMembers.length === 0) {
|
||||
return verifiedEmails;
|
||||
@@ -96,7 +97,7 @@ export class VerificationAtomsService {
|
||||
|
||||
let verifiedEmails = [userEmailWithoutOauthClientId];
|
||||
|
||||
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
|
||||
const secondaryEmails = await this.atomsSecondaryEmailsRepository.getSecondaryEmailsVerified(userId);
|
||||
verifiedEmails = verifiedEmails.concat(
|
||||
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
|
||||
);
|
||||
@@ -113,11 +114,9 @@ export class VerificationAtomsService {
|
||||
existingPrimaryEmail: string;
|
||||
email: string;
|
||||
}): Promise<boolean> {
|
||||
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
|
||||
userId,
|
||||
email
|
||||
);
|
||||
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
|
||||
const existingSecondaryEmail =
|
||||
await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmailByUserAndEmail(userId, email);
|
||||
const alreadyExistingEmail = await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmail(email);
|
||||
|
||||
if (alreadyExistingEmail) {
|
||||
throw new BadRequestException("Email already exists");
|
||||
@@ -127,7 +126,7 @@ export class VerificationAtomsService {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.atomsRepository.addSecondaryEmail(userId, email);
|
||||
await this.atomsSecondaryEmailsRepository.addSecondaryEmailVerified(userId, email);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
+109
-4
@@ -705,6 +705,111 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe("managed user time zone", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not allow '' time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever2@gmail.com",
|
||||
timeZone: "",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not allow 'invalid-timezone' time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever2@gmail.com",
|
||||
timeZone: "invalid-timezone",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
it("should allow null timezone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever1@gmail.com",
|
||||
timeZone: null,
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow undefined time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever3@gmail.com",
|
||||
timeZone: undefined,
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow valid time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever4@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toBe("Europe/Rome");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow without any time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever5@gmail.com",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id);
|
||||
@@ -712,17 +817,17 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData2.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(platformAdmin.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -931,7 +1036,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+2
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: `organizations-users-new-member-${randomString()}@api.com`,
|
||||
bio,
|
||||
metadata,
|
||||
timeZone: "Europe/Rome",
|
||||
};
|
||||
|
||||
const emailSpy = jest
|
||||
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
expect(userData.bio).toBe(newOrgUser.bio);
|
||||
expect(userData.metadata).toEqual(newOrgUser.metadata);
|
||||
expect(userData.timeZone).toBe(newOrgUser.timeZone);
|
||||
expect(emailSpy).toHaveBeenCalledWith({
|
||||
usernameOrEmail: newOrgUser.email,
|
||||
orgName: org.name,
|
||||
|
||||
+125
@@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
|
||||
});
|
||||
|
||||
it("should filter memberships by single email", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
|
||||
expect(responseBody.data[0].role).toEqual("ADMIN");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter memberships by multiple emails", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
|
||||
const emails = responseBody.data.map((membership) => membership.user.email);
|
||||
expect(emails).toContain(teamAdminEmail);
|
||||
expect(emails).toContain(teamMemberEmail);
|
||||
|
||||
const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail);
|
||||
const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail);
|
||||
|
||||
expect(adminMembership).toBeDefined();
|
||||
expect(memberMembership).toBeDefined();
|
||||
expect(adminMembership?.role).toEqual("ADMIN");
|
||||
expect(memberMembership?.role).toEqual("MEMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when filtering by non-existent email", async () => {
|
||||
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return partial results when filtering by mix of existing and non-existent emails", async () => {
|
||||
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with pagination and email filtering combined", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
const returnedEmail = responseBody.data[0].user.email;
|
||||
expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty emails array gracefully", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle URL encoded email addresses in filter", async () => {
|
||||
const encodedEmail = encodeURIComponent(teamAdminEmail);
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by email and maintain all user properties", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
const membership = responseBody.data[0];
|
||||
expect(membership.user.email).toEqual(teamMemberEmail);
|
||||
expect(membership.user.bio).toEqual(teamMember.bio);
|
||||
expect(membership.user.metadata).toEqual(teamMember.metadata);
|
||||
expect(membership.user.username).toEqual(teamMember.username);
|
||||
expect(membership.teamId).toEqual(team.id);
|
||||
expect(membership.userId).toEqual(teamMember.id);
|
||||
expect(membership.role).toEqual("MEMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate email array size limits", async () => {
|
||||
const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(",");
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
|
||||
|
||||
+8
-4
@@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
|
||||
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
|
||||
@@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/memberships",
|
||||
@@ -85,16 +85,20 @@ export class TeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all memberships" })
|
||||
@ApiOperation({
|
||||
summary: "Get all memberships",
|
||||
description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.",
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getTeamMemberships(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetTeamMembershipsInput
|
||||
): Promise<GetTeamMembershipsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, emails } = queryParams;
|
||||
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
|
||||
teamId,
|
||||
emails,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
|
||||
|
||||
export class GetTeamMembershipsInput extends GetUsersInput {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null) return undefined;
|
||||
const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) =>
|
||||
typeof entry === "string" ? entry.split(",") : []
|
||||
);
|
||||
const normalized = rawValues
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.length > 0)
|
||||
.map((email) => email.toLowerCase());
|
||||
const deduplicated = [...new Set(normalized)];
|
||||
return deduplicated.length > 0 ? deduplicated : undefined;
|
||||
})
|
||||
@ArrayNotEmpty({ message: "emails cannot be empty." })
|
||||
@ArrayMaxSize(20, {
|
||||
message: "emails array cannot contain more than 20 email addresses for team membership filtering",
|
||||
})
|
||||
@IsEmail({}, { each: true, message: "Each email must be a valid email address" })
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description:
|
||||
"Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).",
|
||||
example: "?emails=user1@example.com,user2@example.com",
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
@@ -23,13 +23,15 @@ export class TeamsMembershipsService {
|
||||
return teamMembership;
|
||||
}
|
||||
|
||||
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
|
||||
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
|
||||
async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) {
|
||||
const emailArray = !emails ? [] : emails;
|
||||
|
||||
return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters(
|
||||
teamId,
|
||||
{ emails: emailArray },
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return teamMemberships;
|
||||
}
|
||||
|
||||
async getTeamMembership(teamId: number, membershipId: number) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export interface TeamMembershipFilters {
|
||||
emails?: string[];
|
||||
}
|
||||
|
||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||
username: true,
|
||||
email: true,
|
||||
@@ -41,6 +45,30 @@ export class TeamsMembershipsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamMembershipsPaginatedWithFilters(
|
||||
teamId: number,
|
||||
filters: TeamMembershipFilters,
|
||||
skip: number,
|
||||
take: number
|
||||
) {
|
||||
const whereClause: Prisma.MembershipWhereInput = {
|
||||
teamId: teamId,
|
||||
};
|
||||
|
||||
if (filters.emails && filters.emails.length > 0) {
|
||||
whereClause.user = {
|
||||
email: { in: filters.emails },
|
||||
};
|
||||
}
|
||||
|
||||
return await this.dbRead.prisma.membership.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamMembership(teamId: number, membershipId: number) {
|
||||
return this.dbRead.prisma.membership.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -120,30 +120,4 @@ export class TeamsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamMemberEmails(teamId: number) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
secondaryEmails: {
|
||||
where: {
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
@@ -29,6 +30,7 @@ export class CreateManagedUserInput {
|
||||
})
|
||||
weekStart?: WeekDay;
|
||||
|
||||
@Transform(({ value }) => (value === null ? undefined : value))
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
@CapitalizeTimeZone()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ export class CreateUserInput {
|
||||
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@CapitalizeTimeZone()
|
||||
@Validate(TimeZoneValidator)
|
||||
@Expose()
|
||||
timeZone?: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ export class GetUsersInput {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: "The email address or an array of email addresses to filter by",
|
||||
example: ["user1@example.com", "user2@example.com"],
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
|
||||
@@ -408,4 +408,30 @@ export class UsersRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEmailsVerifiedForTeam(teamId: number) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
secondaryEmails: {
|
||||
where: {
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
import tzdata from "tzdata";
|
||||
import * as tzdata from "tzdata";
|
||||
|
||||
@ValidatorConstraint({ name: "timezoneValidator", async: false })
|
||||
export class TimeZoneValidator implements ValidatorConstraintInterface {
|
||||
|
||||
@@ -636,10 +636,6 @@ select:focus {
|
||||
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app {
|
||||
@apply z-40 !important;
|
||||
}
|
||||
|
||||
@import "../../web/styles/tokens.css";
|
||||
|
||||
@layer base {
|
||||
@@ -1027,10 +1023,6 @@ select:focus {
|
||||
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app {
|
||||
@apply z-40 !important;
|
||||
}
|
||||
|
||||
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ShellMainAppDir(props: LayoutProps) {
|
||||
className={classNames(
|
||||
props.backPath
|
||||
? "relative"
|
||||
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
|
||||
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
|
||||
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
|
||||
)}>
|
||||
{props.CTA}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -72,8 +73,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
|
||||
userId: session.user.id,
|
||||
permission: "availability.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
const canViewTeamAvailability = teamIdsWithPermission.length > 0 || !isOrgPrivate;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
|
||||
@@ -23,7 +23,8 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const searchParams = await _searchParams;
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
|
||||
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}${autoAccept ? `&autoAccept=${encodeURIComponent(autoAccept)}` : ''}` : null;
|
||||
|
||||
if (!session) {
|
||||
redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login");
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { SearchParams } from "app/_types";
|
||||
import type { Session } from "next-auth";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -32,14 +33,21 @@ export const ServerTeamsListing = async ({
|
||||
session: Session;
|
||||
}) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
|
||||
const userId = session.user.id;
|
||||
let invitationAccepted = false;
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||
if (autoAccept === "true") {
|
||||
await TeamService.acceptInvitationByToken(token, userId);
|
||||
invitationAccepted = true;
|
||||
} else {
|
||||
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
@@ -49,21 +57,30 @@ export const ServerTeamsListing = async ({
|
||||
const teams = await getCachedTeams(userId);
|
||||
const userProfile = session?.user?.profile;
|
||||
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const canCreateTeam = orgId
|
||||
? await permissionCheckService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: orgId,
|
||||
permission: "team.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
})
|
||||
: false;
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
invitationAccepted={invitationAccepted}
|
||||
teams={teams}
|
||||
orgId={orgId ?? null}
|
||||
isOrgAdmin={isOrgAdminOrOwner}
|
||||
permissions={{
|
||||
canCreateTeam: canCreateTeam,
|
||||
}}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
|
||||
CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ArrowButton } from "@calcom/ui/components/arrow-button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -46,6 +47,10 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
|
||||
createFunction={(teamId) => {
|
||||
setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" });
|
||||
}}
|
||||
withPermission={{
|
||||
permission: "routingForm.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-11
@@ -175,6 +175,7 @@ const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_syn
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
canUpdateOrganization?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
@@ -190,7 +191,6 @@ const useTabs = ({
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
const orgBranding = useOrgBranding();
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
const processTabsMemod = useMemo(() => {
|
||||
const processedTabs = getTabs(orgBranding).map((tab) => {
|
||||
@@ -203,10 +203,10 @@ const useTabs = ({
|
||||
};
|
||||
} else if (tab.href === "/settings/organizations") {
|
||||
const newArray = (tab?.children ?? []).filter(
|
||||
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
|
||||
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
if (isOrgAdminOrOwner) {
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
@@ -238,7 +238,7 @@ const useTabs = ({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (isOrgAdminOrOwner) {
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
@@ -264,7 +264,7 @@ const useTabs = ({
|
||||
return { ...tab, children: filtered };
|
||||
} else if (tab.href === "/settings/developer") {
|
||||
const filtered = tab?.children?.filter(
|
||||
(childTab) => isOrgAdminOrOwner || childTab.name !== "admin_api"
|
||||
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
|
||||
);
|
||||
return { ...tab, children: filtered };
|
||||
}
|
||||
@@ -274,12 +274,12 @@ const useTabs = ({
|
||||
// check if name is in adminRequiredKeys
|
||||
return processedTabs.filter((tab) => {
|
||||
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
|
||||
if (tab.name === "other_teams" && !isOrgAdminOrOwner) return false;
|
||||
if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false;
|
||||
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
@@ -560,8 +560,6 @@ const SettingsSidebarContainer = ({
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
@@ -638,12 +636,12 @@ const SettingsSidebarContainer = ({
|
||||
as="p"
|
||||
className="text-subtle truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
|
||||
{t("my_teams")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
<TeamListCollapsible teamFeatures={teamFeatures} />
|
||||
{(!orgBranding?.id || isOrgAdminOrOwner) && (
|
||||
{(!orgBranding?.id || permissions?.canUpdateOrganization) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
|
||||
@@ -47,6 +47,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
let canViewRoles = false;
|
||||
let canViewOrganizationBilling = false;
|
||||
let canUpdateOrganization = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
@@ -68,6 +69,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
|
||||
} else {
|
||||
canViewRoles = false;
|
||||
canViewOrganizationBilling = isOrgAdminOrOwner;
|
||||
canUpdateOrganization = isOrgAdminOrOwner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +82,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-2
@@ -1,8 +1,9 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -14,7 +15,10 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
);
|
||||
|
||||
const OrgAttributesEditPageWrapper = async () => {
|
||||
await validateUserHasOrgAdmin();
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.attributes.update",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return <OrgAttributesEditPage />;
|
||||
};
|
||||
|
||||
+6
-2
@@ -1,8 +1,9 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -14,7 +15,10 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const OrgAttributesCreatePageWrapper = async () => {
|
||||
await validateUserHasOrgAdmin();
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.attributes.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return <OrgAttributesCreatePage />;
|
||||
};
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -20,7 +20,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
|
||||
+6
-2
@@ -2,8 +2,9 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import DelegationCredentialList from "@calcom/features/ee/organizations/pages/settings/delegationCredential";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -17,7 +18,10 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrgAdmin();
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.update",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const { canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -21,7 +21,7 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -20,7 +20,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/organizations/general");
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
const INTERNAL_DATAACCESS_KEY = "_resource";
|
||||
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
|
||||
onChange,
|
||||
disabled,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false,
|
||||
}: AdvancedPermissionGroupProps) {
|
||||
const { t } = useLocale();
|
||||
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
const resourceConfig = scopedRegistry[resource];
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
|
||||
+19
-4
@@ -56,9 +56,17 @@ interface RoleSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
teamId: number;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean; // Add isPrivate prop to control permission visibility
|
||||
}
|
||||
|
||||
export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
|
||||
export function RoleSheet({
|
||||
role,
|
||||
open,
|
||||
onOpenChange,
|
||||
teamId,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false,
|
||||
}: RoleSheetProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isEditing = Boolean(role);
|
||||
@@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
const { isAdvancedMode, permissions, color } = form.watch();
|
||||
|
||||
const { filteredResources, scopedRegistry } = useMemo(() => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
// Use privacy-aware filtering if we have privacy information
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
|
||||
t(
|
||||
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
|
||||
@@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return { filteredResources, scopedRegistry };
|
||||
}, [searchQuery, t, scope]);
|
||||
}, [searchQuery, t, scope, isPrivate]);
|
||||
|
||||
const createMutation = trpc.viewer.pbac.createRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -182,6 +191,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
{...form.register("name")}
|
||||
placeholder={t("role_name_placeholder")}
|
||||
disabled={isSystemRole}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<RoleColorPicker
|
||||
@@ -199,10 +209,13 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
<Label className="mb-0">{t("permissions")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="advanced_mode_checkbox"
|
||||
checked={form.watch("isAdvancedMode")}
|
||||
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
|
||||
/>
|
||||
<span className="text-sm">{t("advanced")}</span>
|
||||
<label htmlFor="advanced_mode_checkbox" className="text-sm">
|
||||
{t("advanced")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
@@ -221,6 +234,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
))}{" "}
|
||||
</div>
|
||||
@@ -245,6 +259,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
))}
|
||||
</div>{" "}
|
||||
|
||||
+3
@@ -48,6 +48,7 @@ interface RolesListProps {
|
||||
initialSheetOpen?: boolean;
|
||||
teamId: number;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function RolesList({
|
||||
@@ -57,6 +58,7 @@ export function RolesList({
|
||||
initialSheetOpen,
|
||||
teamId,
|
||||
scope,
|
||||
isPrivate,
|
||||
}: RolesListProps) {
|
||||
const { t } = useLocale();
|
||||
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
|
||||
@@ -113,6 +115,7 @@ export function RolesList({
|
||||
onOpenChange={handleSheetOpenChange}
|
||||
teamId={teamId}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-1
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function SimplePermissionItem({
|
||||
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
|
||||
onChange,
|
||||
disabled,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false,
|
||||
}: SimplePermissionItemProps) {
|
||||
const { t } = useLocale();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
|
||||
const registry = scopedRegistry || PERMISSION_REGISTRY;
|
||||
const isAllResources = resource === "*";
|
||||
|
||||
+15
-1
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
|
||||
{ revalidate: 3600 }
|
||||
);
|
||||
|
||||
const getCachedTeamPrivacy = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { isPrivate: true },
|
||||
});
|
||||
return team?.isPrivate ?? false;
|
||||
},
|
||||
["team-privacy"],
|
||||
{ revalidate: 3600 }
|
||||
);
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("roles_and_permissions"),
|
||||
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
const [roles, rolePermissions] = await Promise.all([
|
||||
const [roles, rolePermissions, isPrivate] = await Promise.all([
|
||||
getCachedTeamRoles(session.user.org.id),
|
||||
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
|
||||
getCachedTeamPrivacy(session.user.org.id),
|
||||
]);
|
||||
|
||||
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
|
||||
@@ -108,6 +121,7 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
}}
|
||||
initialSelectedRole={selectedRole}
|
||||
initialSheetOpen={isSheetOpen}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canListMembers: team.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
|
||||
+17
-2
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
@@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
|
||||
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
|
||||
);
|
||||
|
||||
const getCachedTeamPrivacy = (teamId: number) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { isPrivate: true },
|
||||
});
|
||||
return team?.isPrivate ?? false;
|
||||
},
|
||||
[`team-privacy-${teamId}`],
|
||||
{ revalidate: 3600, tags: [`team-privacy-${teamId}`] }
|
||||
);
|
||||
|
||||
const getCachedTeam = (teamId: string, userId: number) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
@@ -111,9 +124,10 @@ const Page = async ({
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
const [roles, rolePermissions] = await Promise.all([
|
||||
const [roles, rolePermissions, isPrivate] = await Promise.all([
|
||||
getCachedTeamRoles(team.id)(),
|
||||
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
|
||||
getCachedTeamPrivacy(team.id)(),
|
||||
]);
|
||||
|
||||
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
|
||||
@@ -153,6 +167,7 @@ const Page = async ({
|
||||
initialSelectedRole={selectedRole}
|
||||
initialSheetOpen={isSheetOpen}
|
||||
scope={Scope.Team}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -70,6 +70,7 @@ const Page = async () => {
|
||||
userRole: session.user.org.role,
|
||||
actions: [
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
@@ -79,6 +80,9 @@ const Page = async () => {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
@@ -96,7 +100,9 @@ const Page = async () => {
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canListMembers: org.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
@@ -10,6 +11,7 @@ import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
csrfToken: z.string(),
|
||||
password: z.string().refine(validPassword, () => ({
|
||||
message: "Password does not meet the requirements",
|
||||
})),
|
||||
@@ -18,8 +20,22 @@ const passwordResetRequestSchema = z.object({
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const body = await parseRequestData(req);
|
||||
const {
|
||||
password: rawPassword,
|
||||
requestId: rawRequestId,
|
||||
csrfToken: submittedToken,
|
||||
} = passwordResetRequestSchema.parse(body);
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
|
||||
|
||||
if (submittedToken !== cookieToken) {
|
||||
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
||||
}
|
||||
|
||||
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
|
||||
cookieStore.delete("calcom.csrf_token");
|
||||
|
||||
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(body);
|
||||
// rate-limited there is a low, very low chance that a password request stays valid long enough
|
||||
// to brute force 3.8126967e+40 options.
|
||||
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository");
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/perfObserver", () => ({
|
||||
performance: {
|
||||
mark: vi.fn(),
|
||||
measure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
wrapApiHandlerWithSentry: vi.fn((handler) => handler),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({
|
||||
getServerErrorFromUnknown: vi.fn((error) => ({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
statusCode: 500,
|
||||
url: "test-url",
|
||||
method: "GET",
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir: vi.fn((handler) => handler),
|
||||
}));
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions-cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should accept CRON_API_KEY in authorization header", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept CRON_SECRET as Bearer token", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "Bearer test-cron-secret");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept API key as query parameter", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key"
|
||||
);
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup functionality", () => {
|
||||
test("should successfully cleanup stale cache", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle cleanup errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Database connection failed");
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error");
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate CalendarCacheEventService with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarCacheEventService).toHaveBeenCalledWith({
|
||||
calendarCacheEventRepository: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Cleanup stale calendar cache
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
try {
|
||||
await calendarCacheEventService.cleanupStaleCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
console.error(`[calendar-subscriptions-cleanup] ${message}:`, e);
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
}, 10000);
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
});
|
||||
|
||||
test("should accept valid API key", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag checks", () => {
|
||||
test("should return early when cache AND sync are disabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockCheckForNewSubscriptions = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should proceed when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subscription checking functionality", () => {
|
||||
test("should successfully check for new subscriptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle subscription checking errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Subscription service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Subscription service unavailable");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Checks for new calendar subscriptions (rollouts)
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbiden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await calendarSubscriptionService.checkForNewSubscriptions();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -2,4 +2,4 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
|
||||
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
|
||||
|
||||
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
|
||||
export const GET = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
|
||||
|
||||
@@ -3,19 +3,25 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export async function GET() {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const sameSiteParam = url.searchParams.get("sameSite");
|
||||
|
||||
const useSecureCookies = WEBAPP_URL.startsWith("https://");
|
||||
|
||||
// Validate the param, default to "lax"
|
||||
let sameSite: "lax" | "strict" | "none" = "lax";
|
||||
if (sameSiteParam === "strict" || (sameSiteParam === "none" && useSecureCookies)) {
|
||||
sameSite = sameSiteParam;
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const res = NextResponse.json({ csrfToken: token });
|
||||
|
||||
// We need this cookie to be accessible from embeds where the booking flow is displayed within an iframe on a different origin.
|
||||
// For third‑party iframe contexts (embeds on other sites), browsers require SameSite=None and Secure to make the cookie available.
|
||||
// For local development on http://localhost we fall back to SameSite=Lax to avoid requiring https during development.
|
||||
const useSecureCookies = WEBAPP_URL.startsWith("https://");
|
||||
res.cookies.set("calcom.csrf_token", token, {
|
||||
httpOnly: true,
|
||||
secure: useSecureCookies,
|
||||
sameSite: useSecureCookies ? "none" : "lax",
|
||||
sameSite,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import type { Params } from "app/_types";
|
||||
import { ApiError } from "next/dist/server/api-utils";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -21,10 +19,13 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
let ok = false;
|
||||
try {
|
||||
performance.mark("Start");
|
||||
|
||||
const result = endpointRoute
|
||||
? await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)()
|
||||
: await handler(req, { params });
|
||||
let result: T | undefined;
|
||||
if (process.env.NODE_ENV === "development" || !endpointRoute) {
|
||||
result = await handler(req, { params });
|
||||
} else {
|
||||
const { wrapApiHandlerWithSentry } = await import("@sentry/nextjs");
|
||||
result = await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)();
|
||||
}
|
||||
|
||||
ok = true;
|
||||
if (result) {
|
||||
@@ -49,6 +50,7 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
// Don't report 400-499 errors to Sentry/console
|
||||
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
|
||||
console.error(serverError);
|
||||
const { captureException } = await import("@sentry/nextjs");
|
||||
captureException(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipSchema } from "@calcom/prisma/zod/modelSchema/MembershipSchema";
|
||||
import { TeamSchema } from "@calcom/prisma/zod/modelSchema/TeamSchema";
|
||||
@@ -54,6 +56,19 @@ async function handler(request: NextRequest) {
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
if (checkoutSessionSubscription) {
|
||||
const internalBillingService = new InternalTeamBilling(finalizedTeam);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: finalizedTeam.id,
|
||||
subscriptionId: checkoutSessionSubscription.id,
|
||||
subscriptionItemId: checkoutSessionSubscription.items.data[0].id,
|
||||
customerId: checkoutSessionSubscription.customer as string,
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
const response = {
|
||||
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
|
||||
team: schemaTeamReadPublic.parse(finalizedTeam),
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -84,6 +86,19 @@ async function getHandler(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
if (checkoutSession && subscription) {
|
||||
const internalBillingService = new InternalTeamBilling(team);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: team.id,
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionItemId: subscription.items.data[0].id,
|
||||
customerId: subscription.customer as string,
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
});
|
||||
}
|
||||
|
||||
// redirect to team screen
|
||||
return NextResponse.redirect(
|
||||
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "POST";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/webhooks/calendar-subscription/[provider]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Provider validation", () => {
|
||||
test("should accept google_calendar provider", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
}, 10000);
|
||||
|
||||
test("should accept office365_calendar provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/office365_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "office365_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("office365_calendar", request);
|
||||
});
|
||||
|
||||
test("should reject unsupported provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/unsupported_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "unsupported_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unsupported provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag handling", () => {
|
||||
test("should return 200 when neither cache nor sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("No cache or sync enabled");
|
||||
expect(mockProcessWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should process webhook when cache is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("should handle webhook processing errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Webhook validation failed");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook validation failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
|
||||
test("should handle feature flag check errors", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Feature flag service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockRejectedValue(mockError);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Feature flag service unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Params } from "app/_types";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["calendar-webhook"] });
|
||||
|
||||
function extractAndValidateProviderFromParams(params: Params): CalendarSubscriptionProvider | null {
|
||||
if (!("provider" in params)) {
|
||||
return null;
|
||||
}
|
||||
const { provider } = params;
|
||||
if (provider === "google_calendar" || provider === "office365_calendar") {
|
||||
return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming POST requests for calendar webhooks.
|
||||
* It processes the webhook based on the calendar provider specified in the URL.
|
||||
* If the provider is unsupported, it returns a 400 response.
|
||||
* If the webhook is processed successfully, it returns a 200 response.
|
||||
* In case of errors during processing, it returns a 500 response with the error message.
|
||||
* @param {NextRequest} request - The incoming request object.
|
||||
* @param {Object} context - The context object containing route parameters.
|
||||
* @param {Promise<Params>} context.params - A promise that resolves to the route parameters.
|
||||
* @returns {Promise<NextResponse>} - A promise that resolves to the response object.
|
||||
*/
|
||||
async function postHandler(request: NextRequest, ctx: { params: Promise<Params> }) {
|
||||
const providerFromParams = extractAndValidateProviderFromParams(await ctx.params);
|
||||
if (!providerFromParams) {
|
||||
return NextResponse.json({ message: "Unsupported provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
log.debug("No cache or sync enabled");
|
||||
return NextResponse.json({ message: "No cache or sync enabled" }, { status: 200 });
|
||||
}
|
||||
|
||||
await calendarSubscriptionService.processWebhook(providerFromParams, request);
|
||||
return NextResponse.json({ message: "Webhook processed" }, { status: 200 });
|
||||
} catch (error) {
|
||||
log.error("Error processing webhook", { error });
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(postHandler);
|
||||
@@ -266,7 +266,7 @@ export default function CancelBooking(props: Props) {
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const response = await fetch("/api/csrf", { cache: "no-store" });
|
||||
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
|
||||
Regular → Executable
+2
-2
@@ -23,9 +23,9 @@ try {
|
||||
"*/5 * * * * *",
|
||||
async function () {
|
||||
await Promise.allSettled([
|
||||
fetchCron("/calendar-cache/cron"),
|
||||
fetchCron("/cron/calendar-subscriptions"),
|
||||
// fetchCron("/calendar-cache/cron"),
|
||||
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
|
||||
//
|
||||
fetchCron("/tasks/cron"),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The added config here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { initBotId } from "botid/client/core";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.init({
|
||||
@@ -48,3 +49,13 @@ export function onRouterTransitionStart(url: string, navigationType: "push" | "r
|
||||
Sentry.captureRouterTransitionStart(url, navigationType);
|
||||
}
|
||||
}
|
||||
|
||||
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
|
||||
initBotId({
|
||||
protect: [
|
||||
{
|
||||
path: "/api/book/event",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
getOrganizationSettings,
|
||||
getVerifiedDomain,
|
||||
} from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useReducer, type CSSProperties } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -16,121 +17,176 @@ import type { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/g
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Page({ requestId, isRequestExpired, csrfToken }: PageProps) {
|
||||
function Success() {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useForm<{ new_password: string }>();
|
||||
const success = formMethods.formState.isSubmitSuccessful;
|
||||
const loading = formMethods.formState.isSubmitting;
|
||||
const passwordValue = formMethods.watch("new_password");
|
||||
const isEmpty = passwordValue?.length === 0;
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
|
||||
{t("password_updated")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button href="/auth/login" className="w-full justify-center">
|
||||
{t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
|
||||
function Expired() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
|
||||
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
|
||||
</div>
|
||||
<p>{t("request_is_expired_instructions")}</p>
|
||||
<Link href="/auth/forgot-password" passHref legacyBehavior>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
{t("try_again")}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
newPassword: string;
|
||||
csrfToken: string;
|
||||
};
|
||||
|
||||
function PasswordResetForm({
|
||||
form: formMethods,
|
||||
requestId,
|
||||
}: {
|
||||
form: UseFormReturn<FormValues>;
|
||||
requestId: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [refreshToken, forceRefresh] = useReducer((x) => x + 1, 0);
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { isSubmitting: loading },
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/csrf", { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then(({ csrfToken }) => setValue("csrfToken", csrfToken))
|
||||
.catch(() => setValue("csrfToken", ""));
|
||||
}, [setValue, refreshToken]);
|
||||
|
||||
const submitChangePassword = async ({
|
||||
password,
|
||||
requestId,
|
||||
csrfToken,
|
||||
}: {
|
||||
password: string;
|
||||
requestId: string;
|
||||
csrfToken: string;
|
||||
}) => {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requestId, password }),
|
||||
body: JSON.stringify({ requestId, password, csrfToken }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
|
||||
if (!res.ok) {
|
||||
// if the request fails, we want to force refresh of the CSRF token - this allows resubmit
|
||||
forceRefresh();
|
||||
return setError("newPassword", { type: "server", message: json.message });
|
||||
}
|
||||
};
|
||||
|
||||
const Success = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
|
||||
{t("password_updated")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button href="/auth/login" className="w-full justify-center">
|
||||
{t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const passwordValue = watch("newPassword");
|
||||
const isEmpty = passwordValue?.length === 0;
|
||||
|
||||
const Expired = () => {
|
||||
return (
|
||||
<Form
|
||||
className="space-y-6"
|
||||
form={formMethods}
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
handleSubmit={async (values) => {
|
||||
await submitChangePassword({
|
||||
password: values.newPassword,
|
||||
csrfToken: values.csrfToken,
|
||||
requestId,
|
||||
});
|
||||
}}>
|
||||
<input {...formMethods.register("csrfToken")} name="csrfToken" type="hidden" hidden />
|
||||
<div className="mt-1">
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t("password_hint_min"),
|
||||
value: 7, // We don't have user here so we can't check if they are admin or not
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
loading={loading}
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading || isEmpty}
|
||||
className="w-full justify-center">
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ requestId, isRequestExpired }: PageProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
newPassword: "",
|
||||
csrfToken: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitSuccessful: success },
|
||||
} = formMethods;
|
||||
|
||||
if (isRequestExpired) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
|
||||
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
|
||||
</div>
|
||||
<p>{t("request_is_expired_instructions")}</p>
|
||||
<Link href="/auth/forgot-password" passHref legacyBehavior>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
{t("try_again")}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
<AuthContainer showLogo heading={t("reset_password")}>
|
||||
<Expired />
|
||||
</AuthContainer>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContainer showLogo heading={!success ? t("reset_password") : undefined}>
|
||||
{isRequestExpired && <Expired />}
|
||||
{!isRequestExpired && !success && (
|
||||
<>
|
||||
<Form
|
||||
className="space-y-6"
|
||||
form={formMethods}
|
||||
style={
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
handleSubmit={async (values) => {
|
||||
await submitChangePassword({
|
||||
password: values.new_password,
|
||||
requestId,
|
||||
});
|
||||
}}>
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||
<div className="mt-1">
|
||||
<PasswordField
|
||||
{...formMethods.register("new_password", {
|
||||
minLength: {
|
||||
message: t("password_hint_min"),
|
||||
value: 7, // We don't have user here so we can't check if they are admin or not
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
loading={loading}
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading || isEmpty}
|
||||
className="w-full justify-center">
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{!isRequestExpired && success && (
|
||||
<>
|
||||
<Success />
|
||||
</>
|
||||
)}
|
||||
{success ? <Success /> : <PasswordResetForm form={formMethods} requestId={requestId} />}
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { withBotId } = require("botid/next/config");
|
||||
const { version } = require("./package.json");
|
||||
const { PrismaPlugin } = require("@prisma/nextjs-monorepo-workaround-plugin");
|
||||
const {
|
||||
@@ -117,6 +118,11 @@ if (process.env.ANALYZE === "true") {
|
||||
}
|
||||
|
||||
plugins.push(withAxiom);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1") {
|
||||
plugins.push(withBotId);
|
||||
}
|
||||
|
||||
const orgDomainMatcherConfig = {
|
||||
root: nextJsOrgRewriteConfig.disableRootPathRewrite
|
||||
? null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.7.5",
|
||||
"version": "5.7.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -87,6 +87,7 @@
|
||||
"async": "^3.2.4",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"botid": "^1.5.7",
|
||||
"classnames": "^2.3.1",
|
||||
"dompurify": "^3.1.7",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
@@ -108,7 +109,7 @@
|
||||
"memory-cache": "^0.2.0",
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^15.4.5",
|
||||
"next": "15.5.4",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-collect": "^0.2.1",
|
||||
@@ -138,7 +139,7 @@
|
||||
"react-select": "^5.7.0",
|
||||
"react-timezone-select": "^1.4.0",
|
||||
"react-turnstile": "^1.1.3",
|
||||
"react-use-intercom": "1.5.1",
|
||||
"react-use-intercom": "^5.5.0",
|
||||
"recoil": "^0.7.7",
|
||||
"remove-markdown": "^0.5.0",
|
||||
"retell-client-js-sdk": "^2.0.0",
|
||||
|
||||
@@ -2,11 +2,15 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { BotDetectionService } from "@calcom/features/bot-detection";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
@@ -19,6 +23,16 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for bot detection using feature flag
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const eventTypeRepository = new EventTypeRepository(prisma);
|
||||
const botDetectionService = new BotDetectionService(featuresRepository, eventTypeRepository);
|
||||
|
||||
await botDetectionService.checkBotDetection({
|
||||
eventTypeId: req.body.eventTypeId,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(userIp),
|
||||
|
||||
@@ -12,7 +12,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
rateLimitingType: "instantMeeting",
|
||||
identifier: `instant.event-${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -6,7 +6,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -27,7 +27,10 @@ const handleSubscriptionUpdate = async (event: Stripe.Event) => {
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||
throw new HttpCode({
|
||||
statusCode: 202,
|
||||
message: `No credential found with subscription ID ${subscription.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
@@ -51,7 +54,10 @@ const handleSubscriptionDeleted = async (event: Stripe.Event) => {
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
|
||||
throw new HttpCode({
|
||||
statusCode: 202,
|
||||
message: `No credential found with subscription ID ${subscription.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// should we delete the credential here rather than marking as inactive?
|
||||
@@ -103,7 +109,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
if (!err.message.includes("No credential found with subscription ID")) {
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
res.status(err.statusCode ?? 500).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe("Forgot password", async () => {
|
||||
// Wait for page to fully load
|
||||
await page.waitForSelector("text=Reset Password");
|
||||
|
||||
await page.fill('input[name="new_password"]', newPassword);
|
||||
await page.fill('input[name="newPassword"]', newPassword);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await page.waitForSelector("text=Password updated");
|
||||
|
||||
@@ -160,6 +160,7 @@ const createTeamAndAddUser = async (
|
||||
orgRequestedSlug,
|
||||
schedulingType,
|
||||
assignAllTeamMembersForSubTeamEvents,
|
||||
teamSlug,
|
||||
}: {
|
||||
user: { id: number; email: string; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
@@ -172,12 +173,15 @@ const createTeamAndAddUser = async (
|
||||
orgRequestedSlug?: string;
|
||||
schedulingType?: SchedulingType;
|
||||
assignAllTeamMembersForSubTeamEvents?: boolean;
|
||||
teamSlug?: string;
|
||||
},
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
const slugIndex = index ? `-count-${index}` : "";
|
||||
const slug =
|
||||
orgRequestedSlug ?? `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`;
|
||||
teamSlug ??
|
||||
orgRequestedSlug ??
|
||||
`${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`;
|
||||
const data: PrismaType.TeamCreateInput = {
|
||||
name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`,
|
||||
isOrganization: isOrg,
|
||||
@@ -283,6 +287,7 @@ export const createUsersFixture = (
|
||||
schedulingType?: SchedulingType;
|
||||
teamEventTitle?: string;
|
||||
teamEventSlug?: string;
|
||||
teamSlug?: string;
|
||||
teamEventLength?: number;
|
||||
isOrg?: boolean;
|
||||
isOrgVerified?: boolean;
|
||||
@@ -367,6 +372,7 @@ export const createUsersFixture = (
|
||||
orgRequestedSlug: scenario.orgRequestedSlug,
|
||||
schedulingType: scenario.schedulingType,
|
||||
assignAllTeamMembersForSubTeamEvents: scenario.assignAllTeamMembersForSubTeamEvents,
|
||||
teamSlug: scenario?.teamSlug,
|
||||
},
|
||||
workerInfo
|
||||
);
|
||||
@@ -465,7 +471,7 @@ export const createUsersFixture = (
|
||||
},
|
||||
data: {
|
||||
orgProfiles: _user.profiles.length
|
||||
? {
|
||||
? {
|
||||
connect: _user.profiles.map((profile) => ({ id: profile.id })),
|
||||
}
|
||||
: {
|
||||
@@ -711,6 +717,31 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getAllTeamMembership: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
team: {
|
||||
isOrganization: false,
|
||||
},
|
||||
},
|
||||
include: { team: true, user: true },
|
||||
});
|
||||
|
||||
const filteredMemberships = memberships.map((membership) => ({
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
}));
|
||||
|
||||
if (filteredMemberships.length === 0) {
|
||||
throw new Error("No team memberships found for user");
|
||||
}
|
||||
|
||||
return filteredMemberships;
|
||||
},
|
||||
getOrgMembership: async () => {
|
||||
const membership = await prisma.membership.findFirstOrThrow({
|
||||
where: {
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function expectInvitationEmailToBeReceived(
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
@@ -36,5 +35,5 @@ export async function expectExistingUserToBeInvitedToOrganization(
|
||||
userEmail: string,
|
||||
subject?: string | null
|
||||
) {
|
||||
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "settings/team");
|
||||
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "teams?token");
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ test.describe("Team", () => {
|
||||
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
|
||||
).toHaveCount(1);
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!inviteLink) return null;
|
||||
expect(inviteLink).toBeTruthy();
|
||||
|
||||
// Follow invite link to new window
|
||||
const context = await browser.newContext();
|
||||
@@ -103,7 +102,7 @@ test.describe("Team", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
|
||||
test("Invitation (verified)", async ({ page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
@@ -192,4 +191,128 @@ test.describe("Team", () => {
|
||||
});
|
||||
expect(parentEventType?.children.find((et) => et.userId === invitedMember.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Auto-accept invitation for existing user", async ({ browser, page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create({ name: "Invited User" }, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
const invitedUser = await users.create({
|
||||
email: `invited-user-${Date.now()}@example.com`,
|
||||
name: "Invited User",
|
||||
});
|
||||
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
||||
let inviteLink: string;
|
||||
|
||||
await test.step("Send invitation to existing user", async () => {
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUser.email);
|
||||
await page.getByText(t("send_invite")).click();
|
||||
|
||||
inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUser.email,
|
||||
`${teamOwner.name} invited you to join the team ${team.name} on Cal.com`,
|
||||
"teams?token"
|
||||
);
|
||||
|
||||
expect(inviteLink).toContain("autoAccept=true");
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: invitedUser.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
expect(membership?.accepted).toBe(false);
|
||||
});
|
||||
|
||||
await test.step("Auto-accept invitation by clicking link", async () => {
|
||||
const [secondContext, secondPage] = await invitedUser.apiLoginOnNewBrowser(browser);
|
||||
|
||||
await secondPage.goto(inviteLink);
|
||||
|
||||
await expect(secondPage.getByText("Successfully joined")).toBeVisible();
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: invitedUser.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
expect(membership?.accepted).toBe(true);
|
||||
|
||||
await secondPage.close();
|
||||
await secondContext.close();
|
||||
});
|
||||
});
|
||||
|
||||
test("Error when wrong user tries to use invitation link", async ({ browser, page, users, emails }) => {
|
||||
const t = await localize("en");
|
||||
const teamOwner = await users.create({ name: "Wrong User" }, { hasTeam: true });
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
const invitedUser = await users.create({
|
||||
email: `invited-user-${Date.now()}@example.com`,
|
||||
name: "Invited User",
|
||||
});
|
||||
const wrongUser = await users.create({
|
||||
email: `wrong-user-${Date.now()}@example.com`,
|
||||
name: "Wrong User",
|
||||
});
|
||||
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
||||
let inviteLink: string;
|
||||
|
||||
await test.step("Send invitation to specific user", async () => {
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUser.email);
|
||||
await page.getByText(t("send_invite")).click();
|
||||
|
||||
inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUser.email,
|
||||
`${teamOwner.name} invited you to join the team ${team.name} on Cal.com`,
|
||||
"teams?token"
|
||||
);
|
||||
|
||||
expect(inviteLink).toContain("autoAccept=true");
|
||||
});
|
||||
|
||||
await test.step("Wrong user tries to use invitation link", async () => {
|
||||
const [secondContext, secondPage] = await wrongUser.apiLoginOnNewBrowser(browser);
|
||||
|
||||
await secondPage.goto(inviteLink);
|
||||
|
||||
await expect(secondPage.getByText("This invitation is not for your account")).toBeVisible();
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: wrongUser.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
expect(membership).toBeNull();
|
||||
|
||||
const invitedMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: invitedUser.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
expect(invitedMembership?.accepted).toBe(false);
|
||||
|
||||
await secondPage.close();
|
||||
await secondContext.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
confirmReschedule,
|
||||
fillStripeTestCheckout,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
submitAndWaitForResponse,
|
||||
testName,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
@@ -301,3 +302,108 @@ test.describe("Teams - NonOrg", () => {
|
||||
});
|
||||
todo("Reschedule a Round Robin EventType booking");
|
||||
});
|
||||
|
||||
test.describe("Team Slug Validation", () => {
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
test("Teams in different organizations can have the same slug", async ({ page, users, orgs }) => {
|
||||
const org1 = await orgs.create({ name: "Organization 1" });
|
||||
const org2 = await orgs.create({ name: "Organization 2" });
|
||||
|
||||
const owner1 = await users.create(
|
||||
{
|
||||
organizationId: org1.id,
|
||||
roleInOrganization: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teamSlug: "cal",
|
||||
teamRole: "OWNER",
|
||||
}
|
||||
);
|
||||
|
||||
const owner2 = await users.create(
|
||||
{
|
||||
organizationId: org2.id,
|
||||
roleInOrganization: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teamSlug: "calCom",
|
||||
teamRole: "OWNER",
|
||||
}
|
||||
);
|
||||
const { team: team1 } = await owner1.getFirstTeamMembership();
|
||||
|
||||
await owner1.apiLogin();
|
||||
await page.goto(`/settings/teams/${team1.id}/profile`);
|
||||
await page.locator('input[name="slug"]').fill("calCom");
|
||||
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
||||
});
|
||||
});
|
||||
|
||||
test("Teams within same organization cannot have duplicate slugs", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({ name: "Organization 1" });
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
organizationId: org.id,
|
||||
roleInOrganization: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
numberOfTeams: 2,
|
||||
teamRole: "OWNER",
|
||||
}
|
||||
);
|
||||
|
||||
const teams = await owner.getAllTeamMembership();
|
||||
await owner.apiLogin();
|
||||
await page.goto(`/settings/teams/${teams[0].team.id}/profile`);
|
||||
if (!teams[1].team.slug) throw new Error("Slug not found for team 2");
|
||||
await page.locator('input[name="slug"]').fill(teams[1].team.slug);
|
||||
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
||||
expectedStatusCode: 409,
|
||||
});
|
||||
});
|
||||
|
||||
test("Teams without organization can have same slug as teams in organizations", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
}) => {
|
||||
const org = await orgs.create({ name: "Organization 1" });
|
||||
|
||||
const orgOwner = await users.create(
|
||||
{
|
||||
organizationId: org.id,
|
||||
roleInOrganization: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teamSlug: "calCom",
|
||||
teamRole: "OWNER",
|
||||
}
|
||||
);
|
||||
|
||||
const teamOwner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
await teamOwner.apiLogin();
|
||||
await page.goto(`/settings/teams/${team.id}/profile`);
|
||||
await page.locator('input[name="slug"]').fill("calCom");
|
||||
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "لم يتم العثور على مستخدمين متاحين. هل يمكنك تجرِبة وقت آخر؟",
|
||||
"timeslot_unavailable_book_a_new_time": "الفترة الزمنية المحددة لم تعد متاحة. <0>يرجى اختيار وقت جديد</0>",
|
||||
"timeslot_unavailable_short": "محجوز",
|
||||
"just_connected_description": "لقد اتصلت للتو. يرجى الحجز من الفترات المتاحة أعلاه أو إعادة المحاولة لاحقًا.",
|
||||
"unavailable_timeslot_title": "فترة زمنية غير متاحة",
|
||||
"booking_time_out_of_bounds_error": "لا يمكن حجز هذا النوع من الفعاليات في هذا الوقت. هل يمكنك تجربة وقت آخر؟",
|
||||
"request_body_end_time_internal_error": "خطأ داخلي. محتوى الطلب لا يحتوي على وقت النهاية",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "اجتماع سري",
|
||||
"login_instead": "تسجيل الدخول بدلًا من ذلك",
|
||||
"create_account": "إنشاء حساب",
|
||||
"get_started": "ابدأ الآن",
|
||||
"confirm_password": "تأكيد كلمة المرور",
|
||||
"reset_your_password": "عيّن كلمة المرور الجديدة متبعاً الإرشادات المرسلة إلى عنوان بريدك الإلكتروني.",
|
||||
"org_banner_instructions": "الرجاء رفع صورة بعرض {{width}} وطول {{height}}.",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "الحضور",
|
||||
"add_attendees": "إضافة الحضور",
|
||||
"show_advanced_settings": "عرض الإعدادات المتقدمة",
|
||||
"hide_advanced_settings": "إخفاء الإعدادات المتقدمة",
|
||||
"event_name": "اسم الحدث",
|
||||
"event_name_in_calendar": "اسم الحدث في التقويم",
|
||||
"event_name_tooltip": "الاسم الذي سيظهر في التقاويم",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "تواصل مع قسم المبيعات",
|
||||
"error_404": "خطأ 404",
|
||||
"default": "الافتراضي",
|
||||
"set_to_default": "تعيين إلى الافتراضي",
|
||||
"set_to_default": "تعيين كافتراضي",
|
||||
"new_schedule_btn": "جدول جديد",
|
||||
"add_new_schedule": "إضافة جدول جديد",
|
||||
"add_new_calendar": "إضافة تقويم جديد",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "لن يتمكن أعضاء فريقك من رؤية أعضاء الفريق الآخرين عند تشغيل هذا الإعداد.",
|
||||
"you_cannot_see_team_members": "لا يمكنك رؤية جميع أعضاء الفريق السري.",
|
||||
"you_cannot_see_teams_of_org": "لا يمكنك رؤية فرق المؤسسة الخاصة.",
|
||||
"allow_booker_to_select_duration": "السماح لمن يقوم بالحجز بتحديد المدة",
|
||||
"allow_multiple_durations": "السماح بمدد متعددة",
|
||||
"impersonate_user_tip": "يتم مراجعة جميع استخدامات هذه الميزة.",
|
||||
"impersonating_user_warning": "انتحال اسم مستخدم \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "انقر هنا للإيقاف",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "هل تريد بالتأكيد حذف تكوين {{connectionType}}؟ لن يتمكن أعضاء فريقك الذين يستخدمون معلومات تسجيل الدخول إلى {{connectionType}} من الوصول إلى Cal.com بعد الآن.",
|
||||
"organizer_timezone": "منظم المناطق الزمنية",
|
||||
"email_user_cta": "عرض الدعوة",
|
||||
"accept_invite": "قبول الدعوة",
|
||||
"join_now": "انضم الآن",
|
||||
"successfully_joined": "تم الانضمام بنجاح",
|
||||
"email_no_user_invite_heading_subteam": "تمت دعوتك للانضمام إلى فريق من منظمة {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "تمت دعوتك للانضمام إلى منظمة {{appName}}",
|
||||
"email_no_user_invite_subheading": "دعاك {{invitedBy}} للانضمام إلى فريقه على {{appName}}. إن {{appName}} هو برنامج جدولة الأحداث الذي يمكّنك أنت وفريقك من جدولة الاجتماعات دون الحاجة إلى المراسلة عبر البريد الإلكتروني.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "فشل تأكيد الحجز",
|
||||
"not_enough_seats": "لا توجد مقاعد كافية",
|
||||
"form_builder_field_already_exists": "يوجد حقل بهذا الاسم بالفعل",
|
||||
"guests_field_must_be_multiemail": "يجب أن يكون حقل الضيوف من نوع 'بريد إلكتروني متعدد'",
|
||||
"show_on_booking_page": "إظهار في صفحة الحجز",
|
||||
"visit_cancelled_booking": "يمكنك زيارة صفحة الحجز الملغى",
|
||||
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "تم نسخ رابط الدعوة",
|
||||
"invite_link_deleted": "تم حذف رابط الدعوة",
|
||||
"api_key_deleted": "تم حذف مفتاح API",
|
||||
"delete_api_key_warning": "سيؤدي هذا إلى حذف مفتاح API بشكل دائم. ستفقد أي تطبيقات تستخدم هذا المفتاح الوصول إلى حسابك على الفور. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
|
||||
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
|
||||
"one_day": "1 يوم",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "بالمتابعة، فإنك توافق على",
|
||||
"terms": "الشروط",
|
||||
"always_show_x_days": "دائمًا {{x}} أيام متاحة",
|
||||
"always_show_x_days_description": "ينقل تلقائيًا نافذة الحجز لمدة {{x}} يوم إلى الأمام كل يوم، مما يضمن أن تقويمك يعرض دائمًا الفترات المتاحة للـ {{x}} يومًا القادمة.",
|
||||
"team_subscription_cancelled_successfully": "تم إلغاء اشتراك الفريق بنجاح",
|
||||
"team_subscription_cancellation_error": "حدث خطأ أثناء إلغاء اشتراك الفريق",
|
||||
"unable_to_subscribe_to_the_platform": "حدث خطأ أثناء محاولة الاشتراك في خطة المنصة، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "ينتهي بـ",
|
||||
"filter_operator_is_empty": "فارغ",
|
||||
"filter_operator_not_empty": "غير فارغ",
|
||||
"translate_description_button": "ترجمة العنوان/الوصف إلى لغة متصفح الزائر باستخدام الذكاء الاصطناعي",
|
||||
"translate_description_button": "ترجمة العنوان والوصف تلقائيًا",
|
||||
"translate_description_button_description": "ترجمة العناوين والأوصاف تلقائيًا إلى لغة متصفح الزائر باستخدام الذكاء الاصطناعي.",
|
||||
"rr_distribution_method": "التوزيع",
|
||||
"rr_distribution_method_description": "يتيح تحسين التوزيع لتحقيق أقصى قدر من التوفر أو لتحقيق توزيع أكثر توازناً.",
|
||||
"rr_distribution_method_availability_title": "تعظيم التوفر",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "ميزة المقاعد لا تدعم الفعاليات المتكررة. قم بتعطيل ميزة المقاعد لجعلها متكررة.",
|
||||
"recurring_event_seats_error": "الفعالية المتكررة لا تدعم ميزة المقاعد. قم بتعطيل ميزة المقاعد أو اجعل الفعالية غير متكررة.",
|
||||
"segment": "شريحة",
|
||||
"saved_filters": "الفلاتر المحفوظة",
|
||||
"rename": "إعادة تسمية",
|
||||
"rename_segment": "إعادة تسمية الشريحة",
|
||||
"delete_segment": "حذف الشريحة",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "قائمة الاختيار",
|
||||
"most_cancelled_bookings": "الأكثر إلغاءً للحجوزات",
|
||||
"interface_language": "لغة الواجهة",
|
||||
"interface_language_description": "اختر لغتك المفضلة لواجهة الحجز",
|
||||
"visitors_browser_language": "لغة متصفح الزائر",
|
||||
"name_or_email": "الاسم أو البريد الإلكتروني",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "إذا لم يتم العثور على جهة اتصال، يتم الرجوع إلى مالك العميل المحتمل إذا كان موجودًا",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "إدارة الأدوار والصلاحيات لمؤسستك",
|
||||
"learn_more_permissions": "تعرف على المزيد حول كيفية عمل الصلاحيات",
|
||||
"role_name": "اسم الدور",
|
||||
"role_name_required": "اسم الدور مطلوب",
|
||||
"role_name_placeholder": "مثال: فريق المبيعات، موظفو الدعم",
|
||||
"role_description": "وصف الدور",
|
||||
"role_description_placeholder": "صف الغرض من هذا الدور والصلاحيات التي يحتاجها",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "تم إنشاء الدور بنجاح",
|
||||
"role_updated_successfully": "تم تحديث الدور بنجاح",
|
||||
"delete_role": "حذف الدور",
|
||||
"confirm_delete_role": "هل أنت متأكد من رغبتك في حذف هذا الدور؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"confirm_delete_role": "هل أنت متأكد أنك تريد حذف الدور: {{roleName}}. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"role_deleted_successfully": "تم حذف الدور بنجاح",
|
||||
"error_creating_role": "خطأ في إنشاء الدور",
|
||||
"error_updating_role": "خطأ في تحديث الدور",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "تعديل وتغيير إعدادات سير العمل",
|
||||
"pbac_desc_delete_workflows": "إزالة سير العمل من النظام",
|
||||
"pbac_resource_webhook": "ويب هوك",
|
||||
"pbac_resource_availability": "الأوقات المتاحة",
|
||||
"pbac_resource_out_of_office": "خارج المكتب",
|
||||
"pbac_desc_create_webhooks": "إنشاء ويب هوكس",
|
||||
"pbac_desc_view_webhooks": "عرض ويب هوكس",
|
||||
"pbac_desc_update_webhooks": "تحديث الويب هوك",
|
||||
"pbac_desc_delete_webhooks": "حذف الويب هوك",
|
||||
"pbac_desc_create_availability": "إنشاء وقت متاح",
|
||||
"pbac_desc_view_availability": "عرض الأوقات المتاحة",
|
||||
"pbac_desc_update_availability": "تحديث الأوقات المتاحة",
|
||||
"pbac_desc_delete_availability": "حذف الأوقات المتاحة",
|
||||
"pbac_desc_create_out_of_office": "إنشاء حالة خارج المكتب",
|
||||
"pbac_desc_view_out_of_office": "عرض حالة خارج المكتب",
|
||||
"pbac_desc_update_out_of_office": "تحديث حالة خارج المكتب",
|
||||
"pbac_desc_delete_out_of_office": "حذف حالة خارج المكتب",
|
||||
"pbac_desc_manage_workflows": "وصول كامل لإدارة جميع سير العمل",
|
||||
"pbac_desc_create_event_types": "إنشاء أنواع الأحداث",
|
||||
"pbac_desc_view_event_types": "عرض أنواع الأحداث",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "البريد الإلكتروني للمنظم",
|
||||
"webhook_organizer_timezone": "المنطقة الزمنية للمنظم (مثل 'America/New_York'، 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "اللغة المحلية للمنظم (مثل 'en'، 'fr')",
|
||||
"webhook_organizer_username": "اسم المستخدم للمنظم (مثال: 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "اسم المستخدم للمنظم في مؤسسته (مثال: 'john.doe')",
|
||||
"webhook_attendee_name": "اسم الحاضر الأول",
|
||||
"webhook_attendee_email": "البريد الإلكتروني للحاضر الأول",
|
||||
"webhook_attendee_timezone": "المنطقة الزمنية للحاضر الأول",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "استخدم الصوت",
|
||||
"current_voice": "الصوت الحالي",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Mövcud istifadəçilər tapılmadı. Başqa bir vaxt aralığını yoxlaya bilərsinizmi?",
|
||||
"timeslot_unavailable_book_a_new_time": "Seçilmiş vaxt artıq mövcud deyil. <0>Zəhmət olmasa yeni vaxt seçin</0>",
|
||||
"timeslot_unavailable_short": "Tutulub",
|
||||
"just_connected_description": "Siz indicə qoşuldunuz. Zəhmət olmasa, yuxarıdakı slotlardan birini seçin və ya sonra yenidən cəhd edin.",
|
||||
"unavailable_timeslot_title": "Mövcud olmayan vaxt intervalı",
|
||||
"booking_time_out_of_bounds_error": "Tədbir növünü bu vaxtda rezerv etmək mümkün deyil. Başqa vaxt aralığını sınamaq istərdinizmi?",
|
||||
"request_body_end_time_internal_error": "Daxili səhv. Sorğu bədənində bitmə vaxtı yoxdur",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Gizli Görüş",
|
||||
"login_instead": "Əvəzinə daxil olun",
|
||||
"create_account": "Hesab yaradın",
|
||||
"get_started": "Başlayın",
|
||||
"confirm_password": "Şifrəni təsdiqləyin",
|
||||
"reset_your_password": "E-poçt ünvanınıza göndərilən təlimatlarla yeni şifrənizi təyin edin.",
|
||||
"org_banner_instructions": "Zəhmət olmasa {{width}} enində və {{height}} hündürlüyündə şəkil yükləyin.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Tədbir Tipi Rəngi (İşıq Mövzusu)",
|
||||
"dark_event_type_color": "Tədbir Tipi Rəngi (Qaranlıq Mövzu)",
|
||||
"file_not_named": "Fayl adlandırılmayıb [idOrSlug]/[user]",
|
||||
"create_team": "Komanda Yarat",
|
||||
"create_team": "Komanda yaradın",
|
||||
"name": "Ad",
|
||||
"nameless_team": "Adsız Komanda",
|
||||
"oauth_clients": "OAuth Müştəriləri",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "İştirakçılar",
|
||||
"add_attendees": "İştirakçı əlavə et",
|
||||
"show_advanced_settings": "Ətraflı parametrləri göstər",
|
||||
"hide_advanced_settings": "Ətraflı parametrləri gizlədin",
|
||||
"event_name": "Tədbirin Adı",
|
||||
"event_name_in_calendar": "Tədbirin adı ilə",
|
||||
"event_name_tooltip": "Təqvimlərdə görünəcək ad",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Bu aktiv olduqda komanda üzvləri digər komanda üzvlərini görə bilməyəcək.",
|
||||
"you_cannot_see_team_members": "Gizli komandanın bütün üzvlərini görə bilməzsiniz.",
|
||||
"you_cannot_see_teams_of_org": "Gizli təşkilatın komandalarını görə bilməzsiniz.",
|
||||
"allow_booker_to_select_duration": "Rezervasiya edənə müddəti seçməyə icazə ver",
|
||||
"allow_multiple_durations": "Çoxsaylı müddətlərə icazə verin",
|
||||
"impersonate_user_tip": "Bu xüsusiyyətin bütün istifadələri auditi olunur.",
|
||||
"impersonating_user_warning": "\"{{user}}\" istifadəçi adını təqlid edirsiniz.",
|
||||
"impersonating_stop_instructions": "Dayandırmaq üçün bura klikləyin",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "{{connectionType}} konfiqurasiyasını silmək istədiyinizə əminsinizmi? {{connectionType}} girişi istifadə edən komanda üzvləriniz artıq Cal.com-a daxil ola bilməyəcəklər.",
|
||||
"organizer_timezone": "Təşkilatçının saat qurşağı",
|
||||
"email_user_cta": "Dəvəti Görüntülə",
|
||||
"accept_invite": "Dəvəti qəbul edin",
|
||||
"join_now": "İndi qoşulun",
|
||||
"successfully_joined": "Uğurla qoşuldunuz",
|
||||
"email_no_user_invite_heading_subteam": "{{parentTeamName}} təşkilatının komandasına qoşulmağa dəvət olunmusunuz",
|
||||
"email_no_user_invite_heading_org": "{{appName}} təşkilatına qoşulmağa dəvət olunmusunuz",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} sizi {{appName}} üzərindəki komandasına qoşulmağa dəvət edib. {{appName}} sizə və komandanıza görüşləri e-poçt mübadiləsi olmadan planlaşdırmağa imkan verən tədbir idarəetmə planlayıcısıdır.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Rezervasiya təsdiqi uğursuz oldu",
|
||||
"not_enough_seats": "Kifayət qədər oturacaq yoxdur",
|
||||
"form_builder_field_already_exists": "Bu adla bir sahə artıq mövcuddur",
|
||||
"guests_field_must_be_multiemail": "Qonaqlar sahəsi 'Çoxsaylı e-poçtlar' tipində olmalıdır",
|
||||
"show_on_booking_page": "Rezervasiya səhifəsində göstər",
|
||||
"visit_cancelled_booking": "Ləğv edilmiş rezervasiya səhifəsinə baxa bilərsiniz",
|
||||
"get_started_zapier_templates": "Zapier şablonları ilə başlayın",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Dəvət linki kopyalandı",
|
||||
"invite_link_deleted": "Dəvət linki silindi",
|
||||
"api_key_deleted": "API açarı silindi",
|
||||
"delete_api_key_warning": "Bu, API açarını həmişəlik siləcək. Bu açardan istifadə edən bütün tətbiqlər dərhal hesabınıza girişi itirəcək. Bu əməliyyat geri qaytarıla bilməz.",
|
||||
"invite_link_updated": "Dəvət linki parametrləri yadda saxlanıldı",
|
||||
"link_expires_after": "Linklər bu müddətdən sonra bitir...",
|
||||
"one_day": "1 gün",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "Müəssisə",
|
||||
"enterprise_description": "Təşkilatınızı yaratmaq üçün Enterprise-ə yüksəldin",
|
||||
"create_your_org": "Təşkilatınızı Yaradın",
|
||||
"create_org": "Təşkilat Yarat",
|
||||
"create_org": "Təşkilat yaradın",
|
||||
"create_your_org_description": "Təşkilatlara yüksəlin və subdomen, birləşdirilmiş ödəniş, Göstəricilər, geniş ağ etiketləmə və daha çoxunu əldə edin",
|
||||
"create_your_enterprise_description": "Enterprise-ə yüksəlin və Active Directory Sync, SCIM Avtomatik İstifadəçi təminatı, Cal.ai Səs Agentləri, Admin API-ləri və daha çoxuna giriş əldə edin!",
|
||||
"other_payment_app_enabled": "Hər tədbir növü üçün yalnız bir ödəniş tətbiqini aktivləşdirə bilərsiniz",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Davam etməklə, bizim",
|
||||
"terms": "Şərtlər",
|
||||
"always_show_x_days": "Həmişə {{x}} gün mövcuddur",
|
||||
"always_show_x_days_description": "Avtomatik olaraq {{x}}-günlük rezervasiya pəncərəsini hər gün irəli aparır, təqviminizin həmişə növbəti {{x}} günün əlçatan slotlarını göstərməsini təmin edir.",
|
||||
"team_subscription_cancelled_successfully": "Komanda abunəliyi uğurla ləğv edildi",
|
||||
"team_subscription_cancellation_error": "Komanda abunəliyini ləğv edərkən xəta baş verdi",
|
||||
"unable_to_subscribe_to_the_platform": "Platforma planına abunə olmağa çalışarkən bir səhv baş verdi, zəhmət olmasa bir az sonra yenidən cəhd edin",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "İlə bitir",
|
||||
"filter_operator_is_empty": "Boşdur",
|
||||
"filter_operator_not_empty": "Boş deyil",
|
||||
"translate_description_button": "Süni intellekt vasitəsilə başlıq/təsviri ziyarətçinin brauzer dilinə tərcümə et",
|
||||
"translate_description_button": "Başlıq və təsviri auto tərcümə et",
|
||||
"translate_description_button_description": "Başlıqları və təsvirləri ziyarətçinin brauzer dilinə AI vasitəsilə avtomatik tərcümə edin.",
|
||||
"rr_distribution_method": "Paylama",
|
||||
"rr_distribution_method_description": "Maksimum əlçatanlıq üçün paylamanı optimallaşdırmağa və ya daha balanslı bir təyinat üçün çalışmağa imkan verir.",
|
||||
"rr_distribution_method_availability_title": "Əlçatanlığı maksimuma çatdırmaq",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Yerlər funksiyası təkrarlanan tədbiri dəstəkləmir. Təkrarlanma funksiyasını aktivləşdirmək üçün yerlər funksiyasını deaktiv edin.",
|
||||
"recurring_event_seats_error": "Təkrarlanan tədbir yerlər funksiyasını dəstəkləmir. Yerlər funksiyasını deaktiv edin və ya tədbiri təkrarlanmayan edin.",
|
||||
"segment": "Seqment",
|
||||
"saved_filters": "Saxlanılmış filtrlər",
|
||||
"rename": "Adını dəyişdir",
|
||||
"rename_segment": "Seqmentin adını dəyişdir",
|
||||
"delete_segment": "Seqmenti sil",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Seçim siyahısı",
|
||||
"most_cancelled_bookings": "Ən Çox Ləğv Edilən",
|
||||
"interface_language": "İnterfeys dili",
|
||||
"interface_language_description": "Rezervasiya interfeysi üçün üstünlük verdiyiniz dili təyin edin",
|
||||
"visitors_browser_language": "Ziyarətçinin brauzer dili",
|
||||
"name_or_email": "Ad və ya E-poçt",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Əgər əlaqə tapılmazsa, mövcud olduğu təqdirdə potensial müştəri sahibinə qayıt",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Təşkilatınız üçün rolları və icazələri idarə edin",
|
||||
"learn_more_permissions": "İcazələrin necə işlədiyini daha ətraflı öyrənin",
|
||||
"role_name": "Rol adı",
|
||||
"role_name_required": "Rol adı tələb olunur",
|
||||
"role_name_placeholder": "məs. Satış Komandası, Dəstək İşçiləri",
|
||||
"role_description": "Rol təsviri",
|
||||
"role_description_placeholder": "Bu rolun nə üçün olduğunu və hansı icazələrə ehtiyacı olduğunu təsvir edin",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rol uğurla yaradıldı",
|
||||
"role_updated_successfully": "Rol uğurla yeniləndi",
|
||||
"delete_role": "Rolu sil",
|
||||
"confirm_delete_role": "Bu rolu silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
|
||||
"confirm_delete_role": "{{roleName}} rolunu silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
|
||||
"role_deleted_successfully": "Rol uğurla silindi",
|
||||
"error_creating_role": "Rol yaradılarkən xəta baş verdi",
|
||||
"error_updating_role": "Rol yenilənərkən xəta baş verdi",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "İş axını parametrlərini redaktə etmək və dəyişdirmək",
|
||||
"pbac_desc_delete_workflows": "İş axınlarını sistemdən silmək",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Mövcudluq",
|
||||
"pbac_resource_out_of_office": "Ofisdən kənarda",
|
||||
"pbac_desc_create_webhooks": "Webhook yaradın",
|
||||
"pbac_desc_view_webhooks": "Webhook-lara baxın",
|
||||
"pbac_desc_update_webhooks": "Webhook-ləri yeniləyin",
|
||||
"pbac_desc_delete_webhooks": "Webhook-ləri silin",
|
||||
"pbac_desc_create_availability": "Mövcudluq yaratmaq",
|
||||
"pbac_desc_view_availability": "Mövcudluğa baxmaq",
|
||||
"pbac_desc_update_availability": "Mövcudluğu yeniləmək",
|
||||
"pbac_desc_delete_availability": "Mövcudluğu silmək",
|
||||
"pbac_desc_create_out_of_office": "Ofisdən kənar yaratmaq",
|
||||
"pbac_desc_view_out_of_office": "Ofisdən kənara baxmaq",
|
||||
"pbac_desc_update_out_of_office": "Ofisdən kənarı yeniləmək",
|
||||
"pbac_desc_delete_out_of_office": "Ofisdən kənarı silmək",
|
||||
"pbac_desc_manage_workflows": "Bütün iş axınlarına tam idarəetmə girişi",
|
||||
"pbac_desc_create_event_types": "Tədbir növləri yaratmaq",
|
||||
"pbac_desc_view_event_types": "Tədbir növlərinə baxmaq",
|
||||
@@ -3561,7 +3584,7 @@
|
||||
"round_robin_settings_description": "Bu komanda üçün standart növbəli təqvim parametrlərini fərdiləşdirin",
|
||||
"round_robin_settings_updated_successfully": "Növbəli təqvim parametrləri uğurla yeniləndi",
|
||||
"booking_creation_time": "Rezervasiya yaradılma vaxtı",
|
||||
"meeting_start_time": "Görüş başlama vaxtı",
|
||||
"meeting_start_time": "Görüşün başlama vaxtı",
|
||||
"members_affected_by_disabling_delegation_credential": "Təsir edilən üzvlər",
|
||||
"no_members_affected_by_disabling_delegation_credential": "Nümayəndəlik etimadnaməsinin deaktiv edilməsindən təsirlənən üzv yoxdur",
|
||||
"download_expense_log": "Xərc jurnalını yüklə",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Təşkilatçının e-poçtu",
|
||||
"webhook_organizer_timezone": "Təşkilatçının saat qurşağı (məs., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Təşkilatçının lokalı (məs., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Təşkilatçının istifadəçi adı (məsələn, 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Təşkilatçının öz təşkilatındakı istifadəçi adı (məsələn, 'john.doe')",
|
||||
"webhook_attendee_name": "Birinci iştirakçının adı",
|
||||
"webhook_attendee_email": "Birinci iştirakçının e-poçtu",
|
||||
"webhook_attendee_timezone": "Birinci iştirakçının saat qurşağı",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Səsdən istifadə edin",
|
||||
"current_voice": "Cari səs",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Не са намерени свободни потребители. Бихте ли опитали друг времеви интервал?",
|
||||
"timeslot_unavailable_book_a_new_time": "Избраният времеви интервал вече не е наличен. <0>Моля, изберете ново време</0>",
|
||||
"timeslot_unavailable_short": "Заето",
|
||||
"just_connected_description": "Току-що се свързахте. Моля, резервирайте от наличните часове по-горе или опитайте отново по-късно.",
|
||||
"unavailable_timeslot_title": "Недостъпен времеви интервал",
|
||||
"booking_time_out_of_bounds_error": "Този тип събитие не може да бъде резервирано в този час. Бихте ли опитали друг часови интервал?",
|
||||
"request_body_end_time_internal_error": "Вътрешна грешка. Заявката не съдържа краен час",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Тайна среща",
|
||||
"login_instead": "Вместо това влезте",
|
||||
"create_account": "Създаване на акаунт",
|
||||
"get_started": "Започнете",
|
||||
"confirm_password": "Потвърдете паролата",
|
||||
"reset_your_password": "Задайте новата си парола с инструкциите, изпратени на вашия имейл адрес.",
|
||||
"org_banner_instructions": "Моля, качете изображение с ширина {{width}} и височина {{height}}.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Цвят на типа събитие (светла тема)",
|
||||
"dark_event_type_color": "Цвят на типа събитие (тъмна тема)",
|
||||
"file_not_named": "Файлът не е именуван [idOrSlug]/[user]",
|
||||
"create_team": "Създай екип",
|
||||
"create_team": "Създаване на екип",
|
||||
"name": "Име",
|
||||
"nameless_team": "Безименен екип",
|
||||
"oauth_clients": "OAuth клиенти",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Участници",
|
||||
"add_attendees": "Добавяне на участници",
|
||||
"show_advanced_settings": "Показване на разширени настройки",
|
||||
"hide_advanced_settings": "Скриване на разширените настройки",
|
||||
"event_name": "Име на събитието",
|
||||
"event_name_in_calendar": "С име на събитието",
|
||||
"event_name_tooltip": "Името, което ще се показва в календарите",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Свържи се с отдел продажби",
|
||||
"error_404": "Грешка 404",
|
||||
"default": "По подразбиране",
|
||||
"set_to_default": "Задай по подразбиране",
|
||||
"set_to_default": "Задаване по подразбиране",
|
||||
"new_schedule_btn": "Нов график",
|
||||
"add_new_schedule": "Добави нов график",
|
||||
"add_new_calendar": "Добави нов календар",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Когато това е включено, членовете на екипа няма да могат да виждат другите членове.",
|
||||
"you_cannot_see_team_members": "Не можеш да видиш всички членове на частния екип.",
|
||||
"you_cannot_see_teams_of_org": "Не можеш да видиш екипите на частна организация.",
|
||||
"allow_booker_to_select_duration": "Позволи на резервиращия да избере продължителност",
|
||||
"allow_multiple_durations": "Разрешаване на множество продължителности",
|
||||
"impersonate_user_tip": "Всяко използване на тази функция се одитира.",
|
||||
"impersonating_user_warning": "Представяш се като потребител \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Кликни тук, за да спреш",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Сигурни ли сте, че искате да изтриете {{connectionType}} конфигурацията? Членовете на вашия екип, които използват {{connectionType}} вход, вече няма да имат достъп до Cal.com.",
|
||||
"organizer_timezone": "Часова зона на организатора",
|
||||
"email_user_cta": "Преглед на поканата",
|
||||
"accept_invite": "Приемане на покана",
|
||||
"join_now": "Присъединете се сега",
|
||||
"successfully_joined": "Успешно се присъединихте",
|
||||
"email_no_user_invite_heading_subteam": "Поканени сте да се присъедините към екип от организацията {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Поканени сте да се присъедините към организация в {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} ви покани да се присъедините към техния екип в {{appName}}. {{appName}} е платформа за управление на събития, която позволява на вас и вашия екип да планирате срещи без безкрайна размяна на имейли.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Потвърждението на резервацията е неуспешно",
|
||||
"not_enough_seats": "Няма достатъчно места",
|
||||
"form_builder_field_already_exists": "Поле с това име вече съществува",
|
||||
"guests_field_must_be_multiemail": "Полето за гости трябва да бъде от тип 'Множество имейли'",
|
||||
"show_on_booking_page": "Покажи на страницата за резервации",
|
||||
"visit_cancelled_booking": "Можете да посетите страницата с отменената резервация",
|
||||
"get_started_zapier_templates": "Започнете със Zapier шаблони",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Линкът за покана е копиран",
|
||||
"invite_link_deleted": "Линкът за покана е изтрит",
|
||||
"api_key_deleted": "API ключът е изтрит",
|
||||
"delete_api_key_warning": "Това ще изтрие API ключа завинаги. Всички приложения, използващи този ключ, незабавно ще загубят достъп до вашия акаунт. Това действие не може да бъде отменено.",
|
||||
"invite_link_updated": "Настройките на линка са запазени",
|
||||
"link_expires_after": "Линковете изтичат след...",
|
||||
"one_day": "1 ден",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "С продължаването си, вие се съгласявате с нашите",
|
||||
"terms": "Условия",
|
||||
"always_show_x_days": "Винаги показвай {{x}} свободни дни",
|
||||
"always_show_x_days_description": "Автоматично премества {{x}}-дневния прозорец за резервации напред всеки ден, гарантирайки, че вашият календар винаги показва следващите {{x}} дни с налични слотове.",
|
||||
"team_subscription_cancelled_successfully": "Абонаментът на екипа е отменен успешно",
|
||||
"team_subscription_cancellation_error": "Грешка при отмяна на абонамента на екипа",
|
||||
"unable_to_subscribe_to_the_platform": "Възникна грешка при опит за абониране за плана на платформата, моля опитайте отново по-късно",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Завършва с",
|
||||
"filter_operator_is_empty": "Е празно",
|
||||
"filter_operator_not_empty": "Не е празно",
|
||||
"translate_description_button": "Преведи заглавието/описанието на езика на браузъра на посетителя чрез AI",
|
||||
"translate_description_button": "Автоматичен превод на заглавие и описание",
|
||||
"translate_description_button_description": "Автоматично превеждане на заглавия и описания на езика на браузъра на посетителя с помощта на изкуствен интелект.",
|
||||
"rr_distribution_method": "Разпределение",
|
||||
"rr_distribution_method_description": "Позволява оптимизиране на разпределението за максимална наличност или за по-балансирано разпределение.",
|
||||
"rr_distribution_method_availability_title": "Максимизиране на наличността",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Функцията за места не поддържа повтарящи се събития. Деактивирайте функцията за места, за да го направите повтарящо се.",
|
||||
"recurring_event_seats_error": "Повтарящото се събитие не поддържа функцията за места. Деактивирайте функцията за места или направете събитието неповтарящо се.",
|
||||
"segment": "Сегмент",
|
||||
"saved_filters": "Запазени филтри",
|
||||
"rename": "Преименуване",
|
||||
"rename_segment": "Преименуване на сегмент",
|
||||
"delete_segment": "Изтриване на сегмент",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Списък за избор",
|
||||
"most_cancelled_bookings": "Най-много отменени",
|
||||
"interface_language": "Език на интерфейса",
|
||||
"interface_language_description": "Задайте предпочитания от вас език за интерфейса за резервации",
|
||||
"visitors_browser_language": "Език на браузъра на посетителя",
|
||||
"name_or_email": "Име или имейл",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Ако не е намерен контакт, връщане към собственика на лийда, ако съществува",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Управлявайте роли и разрешения за вашата организация",
|
||||
"learn_more_permissions": "Научете повече за това как работят разрешенията",
|
||||
"role_name": "Име на ролята",
|
||||
"role_name_required": "Името на ролята е задължително",
|
||||
"role_name_placeholder": "напр. Търговски екип, Екип за поддръжка",
|
||||
"role_description": "Описание на ролята",
|
||||
"role_description_placeholder": "Опишете за какво е тази роля и какви разрешения са ѝ необходими",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Ролята е създадена успешно",
|
||||
"role_updated_successfully": "Ролята е актуализирана успешно",
|
||||
"delete_role": "Изтрий роля",
|
||||
"confirm_delete_role": "Сигурни ли сте, че искате да изтриете тази роля? Това действие не може да бъде отменено.",
|
||||
"confirm_delete_role": "Сигурни ли сте, че искате да изтриете ролята: {{roleName}}. Това действие не може да бъде отменено.",
|
||||
"role_deleted_successfully": "Ролята е изтрита успешно",
|
||||
"error_creating_role": "Грешка при създаване на роля",
|
||||
"error_updating_role": "Грешка при актуализиране на роля",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Редактиране и модифициране на настройките на работните процеси",
|
||||
"pbac_desc_delete_workflows": "Премахване на работни процеси от системата",
|
||||
"pbac_resource_webhook": "Уебхук",
|
||||
"pbac_resource_availability": "Наличност",
|
||||
"pbac_resource_out_of_office": "Извън офиса",
|
||||
"pbac_desc_create_webhooks": "Създаване на уебхуци",
|
||||
"pbac_desc_view_webhooks": "Преглед на уебхуци",
|
||||
"pbac_desc_update_webhooks": "Актуализиране на уебхукове",
|
||||
"pbac_desc_delete_webhooks": "Изтриване на уебхукове",
|
||||
"pbac_desc_create_availability": "Създаване на наличност",
|
||||
"pbac_desc_view_availability": "Преглед на наличност",
|
||||
"pbac_desc_update_availability": "Актуализиране на наличност",
|
||||
"pbac_desc_delete_availability": "Изтриване на наличност",
|
||||
"pbac_desc_create_out_of_office": "Създаване на статус \"Извън офиса\"",
|
||||
"pbac_desc_view_out_of_office": "Преглед на статус \"Извън офиса\"",
|
||||
"pbac_desc_update_out_of_office": "Актуализиране на статус \"Извън офиса\"",
|
||||
"pbac_desc_delete_out_of_office": "Изтриване на статус \"Извън офиса\"",
|
||||
"pbac_desc_manage_workflows": "Пълен достъп за управление на всички работни процеси",
|
||||
"pbac_desc_create_event_types": "Създаване на типове събития",
|
||||
"pbac_desc_view_event_types": "Преглед на типове събития",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Имейл на организатора",
|
||||
"webhook_organizer_timezone": "Часова зона на организатора (напр. 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Локал на организатора (напр. 'en', 'fr')",
|
||||
"webhook_organizer_username": "Потребителско име на организатора (напр. 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Потребителско име на организатора в неговата организация (напр. 'john.doe')",
|
||||
"webhook_attendee_name": "Име на първия участник",
|
||||
"webhook_attendee_email": "Имейл на първия участник",
|
||||
"webhook_attendee_timezone": "Часова зона на първия участник",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Използвай глас",
|
||||
"current_voice": "Текущ глас",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "কোনও উপলভ্য ব্যবহারকারী খুঁজে পাওয়া যায় নি। ",
|
||||
"timeslot_unavailable_book_a_new_time": "নির্বাচিত সময়সীমা আর উপলব্ধ নেই। <0>অনুগ্রহ করে একটি নতুন সময় নির্বাচন করুন</0>",
|
||||
"timeslot_unavailable_short": "নেওয়া হয়েছে",
|
||||
"just_connected_description": "আপনি সবেমাত্র সংযুক্ত হয়েছেন। অনুগ্রহ করে উপরের স্লট থেকে বুক করুন অথবা পরে আবার চেষ্টা করুন।",
|
||||
"unavailable_timeslot_title": "অনুপলব্ধ সময়সীমা",
|
||||
"booking_time_out_of_bounds_error": "এই সময়ে ইভেন্ট টাইপ বুক করা যাবে না। আপনি কি অন্য সময়সীমা চেষ্টা করতে পারেন?",
|
||||
"request_body_end_time_internal_error": "অভ্যন্তরীণ ত্রুটি। ",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "গোপন সভা",
|
||||
"login_instead": "পরিবর্তে লগইন",
|
||||
"create_account": "অ্যাকাউন্ট তৈরি করুন",
|
||||
"get_started": "শুরু করুন",
|
||||
"confirm_password": "পাসওয়ার্ড নিশ্চিত করুন",
|
||||
"reset_your_password": "আপনার ইমেল ঠিকানায় প্রেরিত নির্দেশাবলী সহ আপনার নতুন পাসওয়ার্ড সেট করুন।",
|
||||
"org_banner_instructions": "এর চিত্র আপলোড করুন {{width}} প্রস্থ এবং {{height}} উচ্চতা।",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "অংশগ্রহণকারীরা",
|
||||
"add_attendees": "অংশগ্রহণকারীদের যোগ করুন",
|
||||
"show_advanced_settings": "উন্নত সেটিংস দেখান",
|
||||
"hide_advanced_settings": "উন্নত সেটিংস লুকান",
|
||||
"event_name": "ইভেন্টের নাম",
|
||||
"event_name_in_calendar": "ইভেন্টের নাম সহ",
|
||||
"event_name_tooltip": "নাম যে ক্যালেন্ডারে প্রদর্শিত হবে",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "যোগাযোগ বিক্রয়",
|
||||
"error_404": "ত্রুটি 404",
|
||||
"default": "ডিফল্ট",
|
||||
"set_to_default": "ডিফল্ট সেট",
|
||||
"set_to_default": "ডিফল্ট হিসাবে সেট করুন",
|
||||
"new_schedule_btn": "নতুন সময়সূচী",
|
||||
"add_new_schedule": "একটি নতুন সময়সূচি যুক্ত করুন",
|
||||
"add_new_calendar": "একটি নতুন ক্যালেন্ডার যুক্ত করুন",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "আপনার দলের সদস্যরা এটি চালু থাকলে অন্য দলের সদস্যদের দেখতে সক্ষম হবেন না।",
|
||||
"you_cannot_see_team_members": "আপনি কোনও বেসরকারী দলের সমস্ত দলের সদস্য দেখতে পারবেন না।",
|
||||
"you_cannot_see_teams_of_org": "আপনি কোনও বেসরকারী সংস্থার দল দেখতে পাচ্ছেন না।",
|
||||
"allow_booker_to_select_duration": "বুকারকে সময়কাল নির্বাচন করার অনুমতি দিন",
|
||||
"allow_multiple_durations": "একাধিক সময়কাল অনুমতি দিন",
|
||||
"impersonate_user_tip": "এই বৈশিষ্ট্যের সমস্ত ব্যবহার নিরীক্ষণ করা হয়।",
|
||||
"impersonating_user_warning": "ব্যবহারকারীর নাম \"{{user}}\"।",
|
||||
"impersonating_stop_instructions": "থামাতে এখানে ক্লিক করুন",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "আপনি কি নিশ্চিত যে আপনি এটি মুছতে চান? {{connectionType}} কনফিগারেশন? {{connectionType}} লগইন আর Cal.com অ্যাক্সেস করতে সক্ষম হবে না।",
|
||||
"organizer_timezone": "আয়োজক টাইমজোন",
|
||||
"email_user_cta": "আমন্ত্রণ দেখুন",
|
||||
"accept_invite": "আমন্ত্রণ গ্রহণ করুন",
|
||||
"join_now": "এখনই যোগ দিন",
|
||||
"successfully_joined": "সফলভাবে যোগ দেওয়া হয়েছে",
|
||||
"email_no_user_invite_heading_subteam": "আপনাকে {{parentTeamName}} সংস্থার একটি দলে যোগদানের আমন্ত্রণ জানানো হয়েছে",
|
||||
"email_no_user_invite_heading_org": "আপনাকে একটি {{appName}} সংস্থায় যোগদানের আমন্ত্রণ জানানো হয়েছে",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} আপনাকে তাদের দলে যোগদানের জন্য আমন্ত্রণ জানিয়েছে {{appName}}। {{appName}} ইভেন্ট-জ্যাকলিং শিডিয়ুলার যা আপনাকে এবং আপনার দলকে ইমেল টেনিস ছাড়াই সভাগুলির সময়সূচী করতে সক্ষম করে।",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "বুকিং নিশ্চিতকরণ ব্যর্থ হয়েছে",
|
||||
"not_enough_seats": "পর্যাপ্ত আসন নেই",
|
||||
"form_builder_field_already_exists": "এই নাম সহ একটি ক্ষেত্র ইতিমধ্যে বিদ্যমান",
|
||||
"guests_field_must_be_multiemail": "অতিথি ক্ষেত্রটি অবশ্যই 'একাধিক ইমেইল' ধরনের হতে হবে",
|
||||
"show_on_booking_page": "বুকিং পৃষ্ঠায় দেখান",
|
||||
"visit_cancelled_booking": "আপনি বাতিল হওয়া বুকিং পৃষ্ঠাটি দেখতে পারেন",
|
||||
"get_started_zapier_templates": "জ্যাপিয়ার টেম্পলেটগুলি দিয়ে শুরু করুন",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "আমন্ত্রণ লিঙ্ক অনুলিপি",
|
||||
"invite_link_deleted": "লিঙ্ক মুছে ফেলা আমন্ত্রণ",
|
||||
"api_key_deleted": "এপিআই কী মুছে ফেলা হয়েছে",
|
||||
"delete_api_key_warning": "এটি স্থায়ীভাবে API কী মুছে ফেলবে। এই কী ব্যবহার করে যেকোনো অ্যাপ্লিকেশন অবিলম্বে আপনার অ্যাকাউন্টে অ্যাক্সেস হারাবে। এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"invite_link_updated": "সংরক্ষণের লিঙ্ক সেটিংসকে আমন্ত্রণ করুন",
|
||||
"link_expires_after": "লিঙ্কগুলি পরে শেষ হওয়ার জন্য সেট ...",
|
||||
"one_day": "1 দিন",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "এন্টারপ্রাইজ",
|
||||
"enterprise_description": "আপনার সংস্থা তৈরি করতে এন্টারপ্রাইজে আপগ্রেড করুন",
|
||||
"create_your_org": "আপনার সংস্থা তৈরি করুন",
|
||||
"create_org": "সংস্থা তৈরি করুন",
|
||||
"create_org": "সংগঠন তৈরি করুন",
|
||||
"create_your_org_description": "সংস্থাগুলিতে আপগ্রেড করুন এবং একটি সাবডোমেন, ইউনিফাইড বিলিং, অন্তর্দৃষ্টি, বিস্তৃত হোয়াইটেলবেলিং এবং আরও অনেক কিছু পান",
|
||||
"create_your_enterprise_description": "এন্টারপ্রাইজে আপগ্রেড করুন এবং অ্যাক্টিভ ডিরেক্টরি সিঙ্ক, এসসিআইএম স্বয়ংক্রিয় ব্যবহারকারীর প্রভিশনিং, ক্যাল.এই ভয়েস এজেন্টস, অ্যাডমিন এপিআই এবং আরও অনেক কিছুতে অ্যাক্সেস পান!",
|
||||
"other_payment_app_enabled": "আপনি প্রতি ইভেন্টের জন্য কেবল একটি পেমেন্ট অ্যাপ্লিকেশন সক্ষম করতে পারেন",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "এগিয়ে যাওয়ার মাধ্যমে, আপনি আমাদের নিম্নলিখিত শর্তাবলীতে সম্মত হচ্ছেন",
|
||||
"terms": "শর্তাবলী",
|
||||
"always_show_x_days": "সর্বদা {{x}} দিন উপলব্ধ",
|
||||
"always_show_x_days_description": "প্রতিদিন স্বয়ংক্রিয়ভাবে {{x}}-দিনের বুকিং উইন্ডো সামনে সরিয়ে দেয়, যা নিশ্চিত করে যে আপনার ক্যালেন্ডার সবসময় পরবর্তী {{x}} দিনের উপলব্ধ স্লট দেখায়।",
|
||||
"team_subscription_cancelled_successfully": "দলের সাবস্ক্রিপশন সফলভাবে বাতিল করা হয়েছে",
|
||||
"team_subscription_cancellation_error": "দলের সাবস্ক্রিপশন বাতিল করার সময় ত্রুটি",
|
||||
"unable_to_subscribe_to_the_platform": "প্ল্যাটফর্ম পরিকল্পনায় সাবস্ক্রাইব করার চেষ্টা করার সময় একটি ত্রুটি ঘটেছে, দয়া করে পরে আবার চেষ্টা করুন",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "সাথে শেষ",
|
||||
"filter_operator_is_empty": "খালি আছে",
|
||||
"filter_operator_not_empty": "খালি নয়",
|
||||
"translate_description_button": "এআই ব্যবহার করে দর্শনার্থীর ব্রাউজার ভাষায় বর্ণনা অনুবাদ করুন",
|
||||
"translate_description_button": "শিরোনাম এবং বিবরণ অটো অনুবাদ করুন",
|
||||
"translate_description_button_description": "AI ব্যবহার করে দর্শকের ব্রাউজার ভাষায় শিরোনাম এবং বিবরণ স্বয়ংক্রিয়ভাবে অনুবাদ করুন।",
|
||||
"rr_distribution_method": "বিতরণ",
|
||||
"rr_distribution_method_description": "সর্বাধিক প্রাপ্যতার জন্য বিতরণকে অনুকূলকরণের জন্য বা আরও সুষম অ্যাসাইনমেন্টের জন্য লক্ষ্য করার অনুমতি দেয়।",
|
||||
"rr_distribution_method_availability_title": "প্রাপ্যতা সর্বাধিক করুন",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "সিট ফিচার পুনরাবৃত্ত ইভেন্ট সমর্থন করে না। এটিকে পুনরাবৃত্ত করতে সিট ফিচার নিষ্ক্রিয় করুন।",
|
||||
"recurring_event_seats_error": "পুনরাবৃত্ত ইভেন্ট সিট ফিচার সমর্থন করে না। সিট ফিচার নিষ্ক্রিয় করুন অথবা ইভেন্টটিকে অপুনরাবৃত্ত করুন।",
|
||||
"segment": "সেগমেন্ট",
|
||||
"saved_filters": "সংরক্ষিত ফিল্টার",
|
||||
"rename": "পুনঃনামকরণ",
|
||||
"rename_segment": "সেগমেন্ট পুনঃনামকরণ",
|
||||
"delete_segment": "সেগমেন্ট মুছুন",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "পিকলিস্ট",
|
||||
"most_cancelled_bookings": "সবচেয়ে বেশি বাতিল করা বুকিং",
|
||||
"interface_language": "ইন্টারফেস ভাষা",
|
||||
"interface_language_description": "বুকিং ইন্টারফেসের জন্য আপনার পছন্দের ভাষা সেট করুন",
|
||||
"visitors_browser_language": "দর্শনার্থীর ব্রাউজার ভাষা",
|
||||
"name_or_email": "নাম বা ইমেইল",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "যদি কোন কন্টাক্ট না পাওয়া যায়, লিড মালিকে ফিরে যান যদি তা বিদ্যমান থাকে",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "আপনার সংস্থার জন্য রোল এবং অনুমতি পরিচালনা করুন",
|
||||
"learn_more_permissions": "অনুমতি কীভাবে কাজ করে সে সম্পর্কে আরও জানুন",
|
||||
"role_name": "ভূমিকার নাম",
|
||||
"role_name_required": "ভূমিকার নাম আবশ্যক",
|
||||
"role_name_placeholder": "যেমন বিক্রয় দল, সহায়তা কর্মী",
|
||||
"role_description": "ভূমিকার বিবরণ",
|
||||
"role_description_placeholder": "এই ভূমিকাটি কীসের জন্য এবং কোন অনুমতি প্রয়োজন তা বর্ণনা করুন",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "ভূমিকা সফলভাবে তৈরি করা হয়েছে",
|
||||
"role_updated_successfully": "ভূমিকা সফলভাবে আপডেট করা হয়েছে",
|
||||
"delete_role": "ভূমিকা মুছুন",
|
||||
"confirm_delete_role": "আপনি কি নিশ্চিত যে আপনি এই ভূমিকা মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"confirm_delete_role": "আপনি কি নিশ্চিত যে আপনি ভূমিকা মুছতে চান: {{roleName}}। এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।",
|
||||
"role_deleted_successfully": "ভূমিকা সফলভাবে মুছে ফেলা হয়েছে",
|
||||
"error_creating_role": "ভূমিকা তৈরি করতে ত্রুটি",
|
||||
"error_updating_role": "ভূমিকা আপডেট করতে ত্রুটি",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "কর্মপ্রবাহ সেটিংস সম্পাদনা ও পরিবর্তন করুন",
|
||||
"pbac_desc_delete_workflows": "সিস্টেম থেকে কর্মপ্রবাহ সরান",
|
||||
"pbac_resource_webhook": "ওয়েবহুক",
|
||||
"pbac_resource_availability": "প্রাপ্যতা",
|
||||
"pbac_resource_out_of_office": "অফিসের বাইরে",
|
||||
"pbac_desc_create_webhooks": "ওয়েবহুক তৈরি করুন",
|
||||
"pbac_desc_view_webhooks": "ওয়েবহুক দেখুন",
|
||||
"pbac_desc_update_webhooks": "ওয়েবহুক আপডেট করুন",
|
||||
"pbac_desc_delete_webhooks": "ওয়েবহুক মুছুন",
|
||||
"pbac_desc_create_availability": "প্রাপ্যতা তৈরি করুন",
|
||||
"pbac_desc_view_availability": "প্রাপ্যতা দেখুন",
|
||||
"pbac_desc_update_availability": "প্রাপ্যতা আপডেট করুন",
|
||||
"pbac_desc_delete_availability": "প্রাপ্যতা মুছুন",
|
||||
"pbac_desc_create_out_of_office": "অফিসের বাইরে তৈরি করুন",
|
||||
"pbac_desc_view_out_of_office": "অফিসের বাইরে দেখুন",
|
||||
"pbac_desc_update_out_of_office": "অফিসের বাইরে আপডেট করুন",
|
||||
"pbac_desc_delete_out_of_office": "অফিসের বাইরে মুছুন",
|
||||
"pbac_desc_manage_workflows": "সমস্ত কর্মপ্রবাহের জন্য সম্পূর্ণ ব্যবস্থাপনা অ্যাক্সেস",
|
||||
"pbac_desc_create_event_types": "ইভেন্টের ধরণ তৈরি করুন",
|
||||
"pbac_desc_view_event_types": "ইভেন্টের ধরণ দেখুন",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "আয়োজকের ইমেইল",
|
||||
"webhook_organizer_timezone": "আয়োজকের টাইমজোন (যেমন, 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "আয়োজকের লোকেল (যেমন, 'en', 'fr')",
|
||||
"webhook_organizer_username": "আয়োজকের ইউজারনেম (যেমন, 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "তাদের সংগঠনে আয়োজকের ইউজারনেম (যেমন, 'john.doe')",
|
||||
"webhook_attendee_name": "প্রথম অংশগ্রহণকারীর নাম",
|
||||
"webhook_attendee_email": "প্রথম অংশগ্রহণকারীর ইমেইল",
|
||||
"webhook_attendee_timezone": "প্রথম অংশগ্রহণকারীর টাইমজোন",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "ভয়েস ব্যবহার করুন",
|
||||
"current_voice": "বর্তমান ভয়েস",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑ুষান্ত"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "No s'han trobat usuaris disponibles. Podries provar una altra franja horària?",
|
||||
"timeslot_unavailable_book_a_new_time": "La franja horària seleccionada ja no està disponible. <0>Si us plau, selecciona una nova hora</0>",
|
||||
"timeslot_unavailable_short": "Ocupat",
|
||||
"just_connected_description": "Acabes de connectar-te. Si us plau, reserva des dels espais de temps disponibles o torna-ho a provar més tard.",
|
||||
"unavailable_timeslot_title": "Franja horària no disponible",
|
||||
"booking_time_out_of_bounds_error": "El tipus d'esdeveniment no es pot reservar en aquest moment. Podries provar una altra franja horària?",
|
||||
"request_body_end_time_internal_error": "Error intern. El cos de la sol·licitud no conté l'hora de finalització",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Reunió secreta",
|
||||
"login_instead": "Inicia sessió en lloc seu",
|
||||
"create_account": "Crea un compte",
|
||||
"get_started": "Comença",
|
||||
"confirm_password": "Confirma la contrasenya",
|
||||
"reset_your_password": "Estableix la teva nova contrasenya amb les instruccions enviades al teu correu electrònic.",
|
||||
"org_banner_instructions": "Si us plau, puja una imatge de {{width}} d'amplada i {{height}} d'alçada.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Color del tipus d'esdeveniment (tema clar)",
|
||||
"dark_event_type_color": "Color del tipus d'esdeveniment (tema fosc)",
|
||||
"file_not_named": "L'arxiu no té el nom [idOrSlug]/[user]",
|
||||
"create_team": "Crea un equip",
|
||||
"create_team": "Crea equip",
|
||||
"name": "Nom",
|
||||
"nameless_team": "Equip sense nom",
|
||||
"oauth_clients": "Clients OAuth",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Assistents",
|
||||
"add_attendees": "Afegir assistents",
|
||||
"show_advanced_settings": "Mostrar configuració avançada",
|
||||
"hide_advanced_settings": "Amaga la configuració avançada",
|
||||
"event_name": "Nom de l'esdeveniment",
|
||||
"event_name_in_calendar": "Amb nom d'esdeveniment",
|
||||
"event_name_tooltip": "El nom que apareixerà als calendaris",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Contacta amb vendes",
|
||||
"error_404": "Error 404",
|
||||
"default": "Per defecte",
|
||||
"set_to_default": "Estableix per defecte",
|
||||
"set_to_default": "Estableix com a predeterminat",
|
||||
"new_schedule_btn": "Nou horari",
|
||||
"add_new_schedule": "Afegeix un nou horari",
|
||||
"add_new_calendar": "Afegeix un nou calendari",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Els membres del teu equip no podran veure altres membres quan això estigui activat.",
|
||||
"you_cannot_see_team_members": "No pots veure tots els membres d'un equip privat.",
|
||||
"you_cannot_see_teams_of_org": "No pots veure els equips d'una organització privada.",
|
||||
"allow_booker_to_select_duration": "Permetre que el reservant seleccioni la durada",
|
||||
"allow_multiple_durations": "Permet múltiples duracions",
|
||||
"impersonate_user_tip": "Tots els usos d'aquesta funcionalitat són auditats.",
|
||||
"impersonating_user_warning": "Suplantant l'usuari \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Fes clic aquí per aturar",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Estàs segur que vols eliminar la configuració de {{connectionType}}? Els membres del teu equip que utilitzin l'inici de sessió {{connectionType}} ja no podran accedir a Cal.com.",
|
||||
"organizer_timezone": "Zona horària de l'organitzador",
|
||||
"email_user_cta": "Veure invitació",
|
||||
"accept_invite": "Accepta la invitació",
|
||||
"join_now": "Uneix-te ara",
|
||||
"successfully_joined": "T'has unit correctament",
|
||||
"email_no_user_invite_heading_subteam": "Has estat convidat a unir-te a un equip de l'organització {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Has estat convidat a unir-te a una organització de {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} t'ha convidat a unir-te al seu equip a {{appName}}. {{appName}} és el programador de reunions que us permet a tu i al teu equip programar trobades sense el ping-pong dels correus electrònics.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "La confirmació de la reserva ha fallat",
|
||||
"not_enough_seats": "No hi ha prou places",
|
||||
"form_builder_field_already_exists": "Ja existeix un camp amb aquest nom",
|
||||
"guests_field_must_be_multiemail": "El camp de convidats ha de ser del tipus 'Múltiples correus electrònics'",
|
||||
"show_on_booking_page": "Mostra a la pàgina de reserva",
|
||||
"visit_cancelled_booking": "Pots visitar la pàgina de reserva cancel·lada",
|
||||
"get_started_zapier_templates": "Comença amb les plantilles de Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Enllaç d'invitació copiat",
|
||||
"invite_link_deleted": "Enllaç d'invitació eliminat",
|
||||
"api_key_deleted": "Clau API eliminada",
|
||||
"delete_api_key_warning": "Això eliminarà permanentment la clau API. Qualsevol aplicació que utilitzi aquesta clau perdrà immediatament l'accés al teu compte. Aquesta acció no es pot desfer.",
|
||||
"invite_link_updated": "Configuració de l'enllaç d'invitació desada",
|
||||
"link_expires_after": "Els enllaços caduquen després de...",
|
||||
"one_day": "1 dia",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "En continuar, acceptes les nostres",
|
||||
"terms": "Condicions",
|
||||
"always_show_x_days": "Sempre {{x}} dies disponibles",
|
||||
"always_show_x_days_description": "Mou automàticament la finestra de reserva de {{x}} dies cap endavant cada dia, assegurant que el teu calendari sempre mostri els propers {{x}} dies d'espais disponibles.",
|
||||
"team_subscription_cancelled_successfully": "Subscripció d'equip cancel·lada amb èxit",
|
||||
"team_subscription_cancellation_error": "Error en cancel·lar la subscripció d'equip",
|
||||
"unable_to_subscribe_to_the_platform": "S'ha produït un error en intentar subscriure's al pla de la plataforma, torna-ho a provar més tard",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Acaba amb",
|
||||
"filter_operator_is_empty": "És buit",
|
||||
"filter_operator_not_empty": "No és buit",
|
||||
"translate_description_button": "Tradueix el títol/descripció a l'idioma del navegador del visitant utilitzant IA",
|
||||
"translate_description_button": "Tradueix automàticament el títol i la descripció",
|
||||
"translate_description_button_description": "Tradueix automàticament els títols i les descripcions a l'idioma del navegador del visitant utilitzant IA.",
|
||||
"rr_distribution_method": "Distribució",
|
||||
"rr_distribution_method_description": "Permet optimitzar la distribució per maximitzar la disponibilitat o aconseguir una assignació més equilibrada",
|
||||
"rr_distribution_method_availability_title": "Maximitzar disponibilitat",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "La funció de seients no admet esdeveniments recurrents. Desactiveu la funció de seients per fer-lo recurrent.",
|
||||
"recurring_event_seats_error": "Els esdeveniments recurrents no admeten la funció de seients. Desactiveu la funció de seients o feu que l'esdeveniment no sigui recurrent.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Filtres desats",
|
||||
"rename": "Canviar el nom",
|
||||
"rename_segment": "Canviar el nom del segment",
|
||||
"delete_segment": "Eliminar segment",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Llista de selecció",
|
||||
"most_cancelled_bookings": "Més reserves cancel·lades",
|
||||
"interface_language": "Idioma de la interfície",
|
||||
"interface_language_description": "Estableix el teu idioma preferit per a la interfície de reserva",
|
||||
"visitors_browser_language": "Idioma del navegador del visitant",
|
||||
"name_or_email": "Nom o correu electrònic",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Si no es troba cap contacte, recorre al propietari del lead si existeix",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Gestiona els rols i permisos per a la teva organització",
|
||||
"learn_more_permissions": "Aprèn més sobre com funcionen els permisos",
|
||||
"role_name": "Nom del rol",
|
||||
"role_name_required": "El nom del rol és obligatori",
|
||||
"role_name_placeholder": "p. ex. Equip de vendes, Personal de suport",
|
||||
"role_description": "Descripció del rol",
|
||||
"role_description_placeholder": "Descriu per a què serveix aquest rol i quins permisos necessita",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rol creat correctament",
|
||||
"role_updated_successfully": "Rol actualitzat correctament",
|
||||
"delete_role": "Eliminar rol",
|
||||
"confirm_delete_role": "Estàs segur que vols eliminar aquest rol? Aquesta acció no es pot desfer.",
|
||||
"confirm_delete_role": "Esteu segur que voleu eliminar el rol: {{roleName}}? Aquesta acció no es pot desfer.",
|
||||
"role_deleted_successfully": "Rol eliminat correctament",
|
||||
"error_creating_role": "Error en crear el rol",
|
||||
"error_updating_role": "Error en actualitzar el rol",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Editar i modificar la configuració dels fluxos de treball",
|
||||
"pbac_desc_delete_workflows": "Eliminar fluxos de treball del sistema",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Disponibilitat",
|
||||
"pbac_resource_out_of_office": "Fora de l'oficina",
|
||||
"pbac_desc_create_webhooks": "Crea webhooks",
|
||||
"pbac_desc_view_webhooks": "Visualitza webhooks",
|
||||
"pbac_desc_update_webhooks": "Actualitza els webhooks",
|
||||
"pbac_desc_delete_webhooks": "Elimina els webhooks",
|
||||
"pbac_desc_create_availability": "Crear disponibilitat",
|
||||
"pbac_desc_view_availability": "Veure disponibilitat",
|
||||
"pbac_desc_update_availability": "Actualitzar disponibilitat",
|
||||
"pbac_desc_delete_availability": "Eliminar disponibilitat",
|
||||
"pbac_desc_create_out_of_office": "Crear fora de l'oficina",
|
||||
"pbac_desc_view_out_of_office": "Veure fora de l'oficina",
|
||||
"pbac_desc_update_out_of_office": "Actualitzar fora de l'oficina",
|
||||
"pbac_desc_delete_out_of_office": "Eliminar fora de l'oficina",
|
||||
"pbac_desc_manage_workflows": "Accés complet de gestió a tots els fluxos de treball",
|
||||
"pbac_desc_create_event_types": "Crear tipus d'esdeveniments",
|
||||
"pbac_desc_view_event_types": "Veure tipus d'esdeveniments",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Correu electrònic de l'organitzador",
|
||||
"webhook_organizer_timezone": "Zona horària de l'organitzador (p. ex., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Configuració regional de l'organitzador (p. ex., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Nom d'usuari de l'organitzador (p. ex., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Nom d'usuari de l'organitzador a la seva organització (p. ex., 'john.doe')",
|
||||
"webhook_attendee_name": "Nom del primer assistent",
|
||||
"webhook_attendee_email": "Correu electrònic del primer assistent",
|
||||
"webhook_attendee_timezone": "Zona horària del primer assistent",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Utilitza la veu",
|
||||
"current_voice": "Veu actual",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Afegiu les vostres noves cadenes a dalt ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Nebyli nalezeni žádní dostupní uživatelé. Můžete zkusit jiný časový slot?",
|
||||
"timeslot_unavailable_book_a_new_time": "Vybraný časový úsek již není k dispozici. <0>Prosím vyberte nový čas</0>",
|
||||
"timeslot_unavailable_short": "Obsazeno",
|
||||
"just_connected_description": "Právě jste se připojili. Prosím, rezervujte si termín z výše uvedených časových slotů nebo to zkuste později.",
|
||||
"unavailable_timeslot_title": "Nedostupný časový slot",
|
||||
"booking_time_out_of_bounds_error": "Tento typ události nelze v tomto čase rezervovat. Můžete zkusit jiný časový termín?",
|
||||
"request_body_end_time_internal_error": "Interní chyba. Tělo požadavku neobsahuje čas ukončení",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Tajná schůzka",
|
||||
"login_instead": "Namísto toho se přihlásit",
|
||||
"create_account": "Vytvořit účet",
|
||||
"get_started": "Začít",
|
||||
"confirm_password": "Potvrdit heslo",
|
||||
"reset_your_password": "Nastavte si nové heslo podle pokynů odeslaných na vaši e-mailovou adresu.",
|
||||
"org_banner_instructions": "Nahrajte prosím obrázek o šířce {{width}} a výšce {{height}}.",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Účastníci",
|
||||
"add_attendees": "Přidat účastníky",
|
||||
"show_advanced_settings": "Zobrazit pokročilá nastavení",
|
||||
"hide_advanced_settings": "Skrýt pokročilá nastavení",
|
||||
"event_name": "Název události",
|
||||
"event_name_in_calendar": "Název události v kalendáři",
|
||||
"event_name_tooltip": "Název, který se zobrazí v kalendářích",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Po zapnutí této funkce neuvidí členové vašeho týmu ostatní členy týmu.",
|
||||
"you_cannot_see_team_members": "V soukromém týmu nelze vidět všechny členy týmu.",
|
||||
"you_cannot_see_teams_of_org": "Nemůžete vidět týmy soukromé organizace.",
|
||||
"allow_booker_to_select_duration": "Umožnit rezervující osobě vybrat dobu trvání",
|
||||
"allow_multiple_durations": "Povolit více délek trvání",
|
||||
"impersonate_user_tip": "Všechna použití této funkce jsou kontrolována.",
|
||||
"impersonating_user_warning": "Zosobnění uživatelského jména „{{user}}“.",
|
||||
"impersonating_stop_instructions": "Kliknutím sem zastavíte",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Opravdu chcete odstranit konfiguraci {{connectionType}}? Členové vašeho týmu, kteří používají přihlášení {{connectionType}}, ztratí přístup k webu Cal.com.",
|
||||
"organizer_timezone": "Časové pásmo organizátora",
|
||||
"email_user_cta": "Zobrazit pozvánku",
|
||||
"accept_invite": "Přijmout pozvánku",
|
||||
"join_now": "Připojit se nyní",
|
||||
"successfully_joined": "Úspěšně připojeno",
|
||||
"email_no_user_invite_heading_subteam": "Byli jste pozváni k připojení do týmu organizace {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Byli jste pozváni k připojení do organizace {{appName}}",
|
||||
"email_no_user_invite_subheading": "Uživatel {{invitedBy}} vás pozval, abyste se připojili k jeho týmu v aplikaci {{appName}}. {{appName}} je plánovač událostí, který vám a vašemu týmu umožňuje plánovat schůzky bez e-mailového ping-pongu.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Potvrzení rezervace se nezdařilo",
|
||||
"not_enough_seats": "Nedostatek míst",
|
||||
"form_builder_field_already_exists": "Pole s tímto názvem již existuje",
|
||||
"guests_field_must_be_multiemail": "Pole pro hosty musí být typu 'Více e-mailů'",
|
||||
"show_on_booking_page": "Zobrazit na stránce rezervace",
|
||||
"visit_cancelled_booking": "Můžete navštívit stránku zrušené rezervace",
|
||||
"get_started_zapier_templates": "Začněte používat šablony Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Odkaz pozvánky byl zkopírován",
|
||||
"invite_link_deleted": "Odkaz pozvánky byl odstraněn",
|
||||
"api_key_deleted": "Klíč API odstraněn",
|
||||
"delete_api_key_warning": "Tímto trvale smažete API klíč. Všechny aplikace používající tento klíč okamžitě ztratí přístup k vašemu účtu. Tuto akci nelze vrátit zpět.",
|
||||
"invite_link_updated": "Nastavení odkazu pozvánky bylo uloženo",
|
||||
"link_expires_after": "Platnost odkazů vyprší za...",
|
||||
"one_day": "1 den",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Pokračováním souhlasíte s našimi",
|
||||
"terms": "Podmínkami",
|
||||
"always_show_x_days": "Vždy dostupné {{x}} dní",
|
||||
"always_show_x_days_description": "Automaticky posouvá {{x}}-denní rezervační okno každý den vpřed, což zajišťuje, že váš kalendář vždy zobrazuje dostupné termíny na příštích {{x}} dní.",
|
||||
"team_subscription_cancelled_successfully": "Předplatné týmu bylo úspěšně zrušeno",
|
||||
"team_subscription_cancellation_error": "Chyba při rušení předplatného týmu",
|
||||
"unable_to_subscribe_to_the_platform": "Při pokusu o přihlášení k plánu platformy došlo k chybě, zkuste to prosím znovu později",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Končí na",
|
||||
"filter_operator_is_empty": "Je prázdné",
|
||||
"filter_operator_not_empty": "Není prázdné",
|
||||
"translate_description_button": "Přeložit název/popis do jazyka prohlížeče návštěvníka pomocí AI",
|
||||
"translate_description_button": "Automaticky přeložit název a popis",
|
||||
"translate_description_button_description": "Automaticky překládá názvy a popisy do jazyka prohlížeče návštěvníka pomocí umělé inteligence.",
|
||||
"rr_distribution_method": "Distribuce",
|
||||
"rr_distribution_method_description": "Umožňuje optimalizovat distribuci pro maximální dostupnost nebo pro vyváženější přidělování.",
|
||||
"rr_distribution_method_availability_title": "Maximalizovat dostupnost",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Funkce míst nepodporuje opakující se události. Zakažte funkci míst pro vytvoření opakující se události.",
|
||||
"recurring_event_seats_error": "Opakující se událost nepodporuje funkci míst. Zakažte funkci míst nebo změňte událost na neopakující se.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Uložené filtry",
|
||||
"rename": "Přejmenovat",
|
||||
"rename_segment": "Přejmenovat segment",
|
||||
"delete_segment": "Smazat segment",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Výběrový seznam",
|
||||
"most_cancelled_bookings": "Nejvíce zrušených rezervací",
|
||||
"interface_language": "Jazyk rozhraní",
|
||||
"interface_language_description": "Nastavte preferovaný jazyk pro rezervační rozhraní",
|
||||
"visitors_browser_language": "Jazyk prohlížeče návštěvníka",
|
||||
"name_or_email": "Jméno nebo e-mail",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Pokud není nalezen žádný kontakt, použít vlastníka leadu, pokud existuje",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Správa rolí a oprávnění pro vaši organizaci",
|
||||
"learn_more_permissions": "Zjistěte více o tom, jak fungují oprávnění",
|
||||
"role_name": "Název role",
|
||||
"role_name_required": "Název role je povinný",
|
||||
"role_name_placeholder": "např. Prodejní tým, Podpora",
|
||||
"role_description": "Popis role",
|
||||
"role_description_placeholder": "Popište, k čemu tato role slouží a jaká oprávnění potřebuje",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Role byla úspěšně vytvořena",
|
||||
"role_updated_successfully": "Role byla úspěšně aktualizována",
|
||||
"delete_role": "Odstranit roli",
|
||||
"confirm_delete_role": "Opravdu chcete tuto roli odstranit? Tuto akci nelze vrátit zpět.",
|
||||
"confirm_delete_role": "Opravdu chcete smazat roli: {{roleName}}? Tuto akci nelze vrátit zpět.",
|
||||
"role_deleted_successfully": "Role byla úspěšně odstraněna",
|
||||
"error_creating_role": "Chyba při vytváření role",
|
||||
"error_updating_role": "Chyba při aktualizaci role",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Upravovat a měnit nastavení pracovních postupů",
|
||||
"pbac_desc_delete_workflows": "Odstraňovat pracovní postupy ze systému",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Dostupnost",
|
||||
"pbac_resource_out_of_office": "Mimo kancelář",
|
||||
"pbac_desc_create_webhooks": "Vytvářet webhooky",
|
||||
"pbac_desc_view_webhooks": "Zobrazit webhooky",
|
||||
"pbac_desc_update_webhooks": "Aktualizovat webhooky",
|
||||
"pbac_desc_delete_webhooks": "Smazat webhooky",
|
||||
"pbac_desc_create_availability": "Vytvořit dostupnost",
|
||||
"pbac_desc_view_availability": "Zobrazit dostupnost",
|
||||
"pbac_desc_update_availability": "Aktualizovat dostupnost",
|
||||
"pbac_desc_delete_availability": "Smazat dostupnost",
|
||||
"pbac_desc_create_out_of_office": "Vytvořit mimo kancelář",
|
||||
"pbac_desc_view_out_of_office": "Zobrazit mimo kancelář",
|
||||
"pbac_desc_update_out_of_office": "Aktualizovat mimo kancelář",
|
||||
"pbac_desc_delete_out_of_office": "Smazat mimo kancelář",
|
||||
"pbac_desc_manage_workflows": "Plný přístup ke správě všech pracovních postupů",
|
||||
"pbac_desc_create_event_types": "Vytvářet typy událostí",
|
||||
"pbac_desc_view_event_types": "Zobrazit typy událostí",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "E-mail organizátora",
|
||||
"webhook_organizer_timezone": "Časové pásmo organizátora (např. 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Lokalizace organizátora (např. 'en', 'fr')",
|
||||
"webhook_organizer_username": "Uživatelské jméno organizátora (např. 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Uživatelské jméno organizátora v jeho organizaci (např. 'john.doe')",
|
||||
"webhook_attendee_name": "Jméno prvního účastníka",
|
||||
"webhook_attendee_email": "E-mail prvního účastníka",
|
||||
"webhook_attendee_timezone": "Časové pásmo prvního účastníka",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Použít hlas",
|
||||
"current_voice": "Aktuální hlas",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Ingen tilgængelige brugere fundet. Kan du prøve et andet tidsrum?",
|
||||
"timeslot_unavailable_book_a_new_time": "Det valgte tidspunkt er ikke længere tilgængeligt. <0>Vælg venligst et nyt tidspunkt</0>",
|
||||
"timeslot_unavailable_short": "Optaget",
|
||||
"just_connected_description": "Du har lige forbundet. Book venligst fra de ovenstående tidsrum eller prøv igen senere.",
|
||||
"unavailable_timeslot_title": "Utilgængeligt tidsrum",
|
||||
"booking_time_out_of_bounds_error": "Begivenheden kan ikke bookes på dette tidspunkt. Kunne du prøve et andet tidspunkt?",
|
||||
"request_body_end_time_internal_error": "Intern fejl. Anmodningskroppen indeholder ikke sluttidspunkt",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Skjult Møde",
|
||||
"login_instead": "Log ind i stedet",
|
||||
"create_account": "Opret Konto",
|
||||
"get_started": "Kom i gang",
|
||||
"confirm_password": "Bekræft adgangskode",
|
||||
"reset_your_password": "Indstil din nye adgangskode med instruktionerne sendt til din e-mailadresse.",
|
||||
"org_banner_instructions": "Upload venligst et billede med {{width}} bredde og {{height}} højde.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Begivenhedstype farve (Lyst tema)",
|
||||
"dark_event_type_color": "Begivenhedstype farve (Mørkt tema)",
|
||||
"file_not_named": "Filen er ikke navngivet [idOrSlug]/[user]",
|
||||
"create_team": "Opret Team",
|
||||
"create_team": "Opret team",
|
||||
"name": "Navn",
|
||||
"nameless_team": "Unavngivet team",
|
||||
"oauth_clients": "OAuth-klienter",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Deltagere",
|
||||
"add_attendees": "Tilføj deltagere",
|
||||
"show_advanced_settings": "Vis avancerede indstillinger",
|
||||
"hide_advanced_settings": "Skjul avancerede indstillinger",
|
||||
"event_name": "Begivenhedsnavn",
|
||||
"event_name_in_calendar": "Begivenhedsnavn i kalender",
|
||||
"event_name_tooltip": "Navnet, der vises i kalendere",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Dine teammedlemmer vil ikke kunne se andre teammedlemmer, når dette er slået til.",
|
||||
"you_cannot_see_team_members": "Du kan ikke se alle teammedlemmer i et privat team.",
|
||||
"you_cannot_see_teams_of_org": "Du kan ikke se teams i en privat organisation.",
|
||||
"allow_booker_to_select_duration": "Tillad bookeren at vælge varighed",
|
||||
"allow_multiple_durations": "Tillad flere varigheder",
|
||||
"impersonate_user_tip": "Al brug af denne funktion er revideret.",
|
||||
"impersonating_user_warning": "Efterligner brugernavnet \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Klik her for at stoppe",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Er du sikker på, at du vil slette {{connectionType}} konfigurationen? Dine teammedlemmer, der bruger {{connectionType}} login, vil ikke længere kunne få adgang til Cal.com.",
|
||||
"organizer_timezone": "Arrangørens tidszone",
|
||||
"email_user_cta": "Vis Invitation",
|
||||
"accept_invite": "Acceptér invitation",
|
||||
"join_now": "Deltag nu",
|
||||
"successfully_joined": "Deltog med succes",
|
||||
"email_no_user_invite_heading_subteam": "Du er blevet inviteret til at blive medlem af et team i {{parentTeamName}}-organisationen.",
|
||||
"email_no_user_invite_heading_org": "Du er blevet inviteret til at blive medlem af en {{appName}}-organisation.",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} har inviteret dig til at deltage i deres team på {{appName}}. {{appName}} er den begivenheds-jonglerende planlægger, der gør det muligt for dig og dit team at planlægge møder uden e-mail tennis.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Reservationsbekræftelse mislykkedes",
|
||||
"not_enough_seats": "Ikke nok pladser",
|
||||
"form_builder_field_already_exists": "Et felt med dette navn findes allerede",
|
||||
"guests_field_must_be_multiemail": "Gæstefeltet skal være af typen 'Flere e-mails'",
|
||||
"show_on_booking_page": "Vis på bookingsiden",
|
||||
"visit_cancelled_booking": "Du kan besøge siden for den aflyste booking",
|
||||
"get_started_zapier_templates": "Kom i gang med Zapier skabeloner",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Invitationslink kopieret",
|
||||
"invite_link_deleted": "Invitationslink slettet",
|
||||
"api_key_deleted": "API-nøgle slettet",
|
||||
"delete_api_key_warning": "Dette vil permanent slette API-nøglen. Alle applikationer, der bruger denne nøgle, vil straks miste adgang til din konto. Denne handling kan ikke fortrydes.",
|
||||
"invite_link_updated": "Linkindstillinger gemt",
|
||||
"link_expires_after": "Links indstillet til at udløbe efter...",
|
||||
"one_day": "1 dag",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "Virksomhed",
|
||||
"enterprise_description": "Opgrader til Enterprise for at oprette din organisation",
|
||||
"create_your_org": "Opret din Organisation",
|
||||
"create_org": "Opret Organisation",
|
||||
"create_org": "Opret organisation",
|
||||
"create_your_org_description": "Opgrader til Organisationer og få et subdomæne, samlet fakturering, Insights, omfattende whitelabeling og mere",
|
||||
"create_your_enterprise_description": "Opgrader til Enterprise og få adgang til Active Directory Sync, SCIM Automatisk Brugerprovisionering, Cal.ai Voice Agents, Admin API'er og mere!",
|
||||
"other_payment_app_enabled": "Du kan kun aktivere én betalingsapp pr. begivenhedstype",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Ved at fortsætte accepterer du vores",
|
||||
"terms": "Vilkår",
|
||||
"always_show_x_days": "Vis altid {{x}} dage tilgængelige",
|
||||
"always_show_x_days_description": "Flytter automatisk {{x}}-dages bookingvinduet frem hver dag, så din kalender altid viser de næste {{x}} dages tilgængelige tidsrum.",
|
||||
"team_subscription_cancelled_successfully": "Teamabonnement annulleret med succes",
|
||||
"team_subscription_cancellation_error": "Fejl under annullering af teamabonnement",
|
||||
"unable_to_subscribe_to_the_platform": "Der opstod en fejl under forsøget på at abonnere på platformplanen, prøv venligst igen senere",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Slutter med",
|
||||
"filter_operator_is_empty": "Er tom",
|
||||
"filter_operator_not_empty": "Er ikke tom",
|
||||
"translate_description_button": "Oversæt titel/beskrivelse til besøgendes browsersprog ved hjælp af AI",
|
||||
"translate_description_button": "Autooversæt titel og beskrivelse",
|
||||
"translate_description_button_description": "Oversæt automatisk titler og beskrivelser til besøgendes browsersprog ved hjælp af AI.",
|
||||
"rr_distribution_method": "Distribution",
|
||||
"rr_distribution_method_description": "Gør det muligt at optimere distributionen for maksimal tilgængelighed eller for at opnå en mere balanceret tildeling.",
|
||||
"rr_distribution_method_availability_title": "Maksimer tilgængelighed",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Sædefunktionen understøtter ikke tilbagevendende begivenheder. Deaktiver sædefunktionen for at gøre den tilbagevendende.",
|
||||
"recurring_event_seats_error": "Tilbagevendende begivenheder understøtter ikke sædefunktionen. Deaktiver sædefunktionen eller gør begivenheden ikke-tilbagevendende.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Gemte filtre",
|
||||
"rename": "Omdøb",
|
||||
"rename_segment": "Omdøb segment",
|
||||
"delete_segment": "Slet segment",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Valgliste",
|
||||
"most_cancelled_bookings": "Flest annullerede",
|
||||
"interface_language": "Grænsefladesprog",
|
||||
"interface_language_description": "Indstil dit foretrukne sprog for bookinggrænsefladen",
|
||||
"visitors_browser_language": "Besøgerens browsersprog",
|
||||
"name_or_email": "Navn eller e-mail",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Hvis ingen kontakt findes, fald tilbage til lead-ejeren, hvis denne eksisterer",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Administrer roller og tilladelser for din organisation",
|
||||
"learn_more_permissions": "Lær mere om, hvordan tilladelser fungerer",
|
||||
"role_name": "Rollenavn",
|
||||
"role_name_required": "Rollenavn er påkrævet",
|
||||
"role_name_placeholder": "f.eks. Salgsteam, Supportpersonale",
|
||||
"role_description": "Rollebekskrivelse",
|
||||
"role_description_placeholder": "Beskriv, hvad denne rolle er til, og hvilke tilladelser den har brug for",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rolle oprettet med succes",
|
||||
"role_updated_successfully": "Rolle opdateret med succes",
|
||||
"delete_role": "Slet rolle",
|
||||
"confirm_delete_role": "Er du sikker på, at du vil slette denne rolle? Denne handling kan ikke fortrydes.",
|
||||
"confirm_delete_role": "Er du sikker på, at du vil slette rollen: {{roleName}}? Denne handling kan ikke fortrydes.",
|
||||
"role_deleted_successfully": "Rolle slettet med succes",
|
||||
"error_creating_role": "Fejl ved oprettelse af rolle",
|
||||
"error_updating_role": "Fejl ved opdatering af rolle",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Rediger og tilpas workflowindstillinger",
|
||||
"pbac_desc_delete_workflows": "Fjern workflows fra systemet",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Tilgængelighed",
|
||||
"pbac_resource_out_of_office": "Ude af kontoret",
|
||||
"pbac_desc_create_webhooks": "Opret webhooks",
|
||||
"pbac_desc_view_webhooks": "Se webhooks",
|
||||
"pbac_desc_update_webhooks": "Opdater webhooks",
|
||||
"pbac_desc_delete_webhooks": "Slet webhooks",
|
||||
"pbac_desc_create_availability": "Opret tilgængelighed",
|
||||
"pbac_desc_view_availability": "Se tilgængelighed",
|
||||
"pbac_desc_update_availability": "Opdater tilgængelighed",
|
||||
"pbac_desc_delete_availability": "Slet tilgængelighed",
|
||||
"pbac_desc_create_out_of_office": "Opret fravær",
|
||||
"pbac_desc_view_out_of_office": "Se fravær",
|
||||
"pbac_desc_update_out_of_office": "Opdater fravær",
|
||||
"pbac_desc_delete_out_of_office": "Slet fravær",
|
||||
"pbac_desc_manage_workflows": "Fuld administrationsadgang til alle workflows",
|
||||
"pbac_desc_create_event_types": "Opret begivenhedstyper",
|
||||
"pbac_desc_view_event_types": "Vis begivenhedstyper",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Arrangørens e-mail",
|
||||
"webhook_organizer_timezone": "Arrangørens tidszone (f.eks. 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Arrangørens lokalitet (f.eks. 'en', 'fr')",
|
||||
"webhook_organizer_username": "Brugernavn for arrangøren (f.eks. 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Brugernavn for arrangøren i deres organisation (f.eks. 'john.doe')",
|
||||
"webhook_attendee_name": "Navn på den første deltager",
|
||||
"webhook_attendee_email": "E-mail på den første deltager",
|
||||
"webhook_attendee_timezone": "Tidszone for den første deltager",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Brug stemme",
|
||||
"current_voice": "Nuværende stemme",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Keine verfügbaren Benutzer gefunden. Können Sie es mit einem anderen Zeitfenster versuchen?",
|
||||
"timeslot_unavailable_book_a_new_time": "Der ausgewählte Zeitraum ist nicht mehr verfügbar. <0>Bitte wähle eine neue Zeit</0>",
|
||||
"timeslot_unavailable_short": "Belegt",
|
||||
"just_connected_description": "Sie haben gerade eine Verbindung hergestellt. Bitte buchen Sie aus den obigen Zeitfenstern oder versuchen Sie es später erneut.",
|
||||
"unavailable_timeslot_title": "Nicht verfügbarer Zeitslot",
|
||||
"booking_time_out_of_bounds_error": "Der Termin kann zu diesem Zeitpunkt nicht gebucht werden. Möchten Sie einen anderen Zeitslot versuchen?",
|
||||
"request_body_end_time_internal_error": "Interner Fehler. Request-Body enthält keinen Endzeitpunkt",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Geheimer Termin",
|
||||
"login_instead": "Stattdessen einloggen",
|
||||
"create_account": "Konto erstellen",
|
||||
"get_started": "Loslegen",
|
||||
"confirm_password": "Passwort bestätigen",
|
||||
"reset_your_password": "Legen Sie Ihr neues Passwort mit den Anweisungen fest, die an Ihre E-Mail-Adresse gesendet wurden.",
|
||||
"org_banner_instructions": "Bitte laden Sie Bild mit einer Breite von {{width}} und einer Höhe von {{height}} hoch.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Ereignistyp-Farbe (Helles Design)",
|
||||
"dark_event_type_color": "Ereignistyp-Farbe (Dunkles Design)",
|
||||
"file_not_named": "Datei heißt nicht [idOrSlug]/[user]",
|
||||
"create_team": "Team anlegen",
|
||||
"create_team": "Team erstellen",
|
||||
"name": "Name",
|
||||
"nameless_team": "Namenloses Team",
|
||||
"oauth_clients": "OAuth-Clients",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Teilnehmende",
|
||||
"add_attendees": "Teilnehmende hinzufügen",
|
||||
"show_advanced_settings": "Erweiterte Einstellungen anzeigen",
|
||||
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
|
||||
"event_name": "Termin-Name",
|
||||
"event_name_in_calendar": "Ereignisname im Kalender",
|
||||
"event_name_tooltip": "Der Name, der in Kalendern angezeigt wird",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Vertrieb kontaktieren",
|
||||
"error_404": "Fehler 404",
|
||||
"default": "Standard",
|
||||
"set_to_default": "Auf Standard setzen",
|
||||
"set_to_default": "Als Standard festlegen",
|
||||
"new_schedule_btn": "Neuer Verfügbarkeitsplan",
|
||||
"add_new_schedule": "Neuen Verfügbarkeitsplan hinzufügen",
|
||||
"add_new_calendar": "Neuen Kalender hinzufügen",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Wenn diese Einstellung aktiv ist, können deine Teammitglieder keine anderen Teammitglieder sehen.",
|
||||
"you_cannot_see_team_members": "Sie können nicht alle Teammitglieder eines privaten Teams sehen.",
|
||||
"you_cannot_see_teams_of_org": "Du kannst keine Teams einer privaten Organisation sehen.",
|
||||
"allow_booker_to_select_duration": "Bucher erlauben, die Länge zu wählen",
|
||||
"allow_multiple_durations": "Mehrere Dauern erlauben",
|
||||
"impersonate_user_tip": "Alle Verwendungen dieser Funktion werden geprüft.",
|
||||
"impersonating_user_warning": "Imitiere den Account {{user}}.",
|
||||
"impersonating_stop_instructions": "Klicken Sie hier, um zu stoppen",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Sind Sie sicher, dass Sie die {{connectionType}}-Konfiguration löschen möchten? Ihre Teammitglieder, die sich über {{connectionType}} anmelden, werden keinen Zugriff mehr auf Cal.com haben.",
|
||||
"organizer_timezone": "Zeitzone des Veranstalters",
|
||||
"email_user_cta": "Einladung anzeigen",
|
||||
"accept_invite": "Einladung annehmen",
|
||||
"join_now": "Jetzt beitreten",
|
||||
"successfully_joined": "Erfolgreich beigetreten",
|
||||
"email_no_user_invite_heading_subteam": "Du wurdest eingeladen, einem Team der Organisation {{parentTeamName}} beizutreten",
|
||||
"email_no_user_invite_heading_org": "Du wurdest eingeladen, einer {{appName}}-Organisation beizutreten",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} hat Sie eingeladen, dem Team in {{appName}} beizutreten. {{appName}} ist der Event-Planer, der es Ihnen und Ihrem Team ermöglicht, Meetings ohne ständiges hin und her zu planen.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Buchungsbestätigung fehlgeschlagen",
|
||||
"not_enough_seats": "Nicht genügend Plätze",
|
||||
"form_builder_field_already_exists": "Ein Feld mit diesem Namen ist bereits vorhanden",
|
||||
"guests_field_must_be_multiemail": "Das Gästefeld muss vom Typ 'Mehrere E-Mails' sein",
|
||||
"show_on_booking_page": "Auf der Buchungsseite anzeigen",
|
||||
"visit_cancelled_booking": "Sie können die Seite der stornierten Buchung besuchen",
|
||||
"get_started_zapier_templates": "Legen Sie mit Zapier-Vorlagen los",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Einladungslink kopiert",
|
||||
"invite_link_deleted": "Einladungslink gelöscht",
|
||||
"api_key_deleted": "API-Schlüssel gelöscht",
|
||||
"delete_api_key_warning": "Dies wird den API-Schlüssel dauerhaft löschen. Alle Anwendungen, die diesen Schlüssel verwenden, verlieren sofort den Zugriff auf Ihr Konto. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"invite_link_updated": "Einladungslink-Einstellungen gespeichert",
|
||||
"link_expires_after": "Links verfallen nach...",
|
||||
"one_day": "1 Tag",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Indem Sie fortfahren, stimmen Sie unseren",
|
||||
"terms": "Nutzungsbedingungen",
|
||||
"always_show_x_days": "Immer für {{x}} Tage verfügbar",
|
||||
"always_show_x_days_description": "Verschiebt das {{x}}-Tage-Buchungsfenster automatisch jeden Tag nach vorne und stellt sicher, dass Ihr Kalender immer die nächsten {{x}} Tage mit verfügbaren Zeitfenstern anzeigt.",
|
||||
"team_subscription_cancelled_successfully": "Team-Abonnement erfolgreich gekündigt",
|
||||
"team_subscription_cancellation_error": "Fehler bei der Kündigung des Team-Abonnements",
|
||||
"unable_to_subscribe_to_the_platform": "Beim Abonnieren des Platform-Plans ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Endet mit",
|
||||
"filter_operator_is_empty": "Ist leer",
|
||||
"filter_operator_not_empty": "Ist nicht leer",
|
||||
"translate_description_button": "Titel/Beschreibung mit KI in die Browsersprache des Besuchers übersetzen",
|
||||
"translate_description_button": "Titel und Beschreibung automatisch übersetzen",
|
||||
"translate_description_button_description": "Titel und Beschreibungen automatisch in die Browsersprache des Besuchers mittels KI übersetzen.",
|
||||
"rr_distribution_method": "Verteilung",
|
||||
"rr_distribution_method_description": "Ermöglicht die Optimierung der Verteilung für maximale Verfügbarkeit oder eine ausgewogenere Zuweisung.",
|
||||
"rr_distribution_method_availability_title": "Verfügbarkeit maximieren",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Die Sitzplatzfunktion unterstützt keine wiederkehrenden Events. Deaktivieren Sie die Sitzplatzfunktion, um es wiederkehrend zu machen.",
|
||||
"recurring_event_seats_error": "Wiederkehrende Events unterstützen die Sitzplatzfunktion nicht. Deaktivieren Sie die Sitzplatzfunktion oder machen Sie das Event nicht wiederkehrend.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Gespeicherte Filter",
|
||||
"rename": "Umbenennen",
|
||||
"rename_segment": "Segment umbenennen",
|
||||
"delete_segment": "Segment löschen",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Auswahlliste",
|
||||
"most_cancelled_bookings": "Meiste stornierte Buchungen",
|
||||
"interface_language": "Oberflächensprache",
|
||||
"interface_language_description": "Legen Sie Ihre bevorzugte Sprache für die Buchungsoberfläche fest",
|
||||
"visitors_browser_language": "Browsersprache des Besuchers",
|
||||
"name_or_email": "Name oder E-Mail",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Wenn kein Kontakt gefunden wird, auf den Lead-Eigentümer zurückgreifen, falls vorhanden",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Verwalte Rollen und Berechtigungen für deine Organisation",
|
||||
"learn_more_permissions": "Erfahre mehr darüber, wie Berechtigungen funktionieren",
|
||||
"role_name": "Rollenname",
|
||||
"role_name_required": "Rollenname ist erforderlich",
|
||||
"role_name_placeholder": "z.B. Vertriebsteam, Support-Mitarbeiter",
|
||||
"role_description": "Rollenbeschreibung",
|
||||
"role_description_placeholder": "Beschreibe, wofür diese Rolle gedacht ist und welche Berechtigungen sie benötigt",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rolle erfolgreich erstellt",
|
||||
"role_updated_successfully": "Rolle erfolgreich aktualisiert",
|
||||
"delete_role": "Rolle löschen",
|
||||
"confirm_delete_role": "Sind Sie sicher, dass Sie diese Rolle löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"confirm_delete_role": "Sind Sie sicher, dass Sie die Rolle löschen möchten: {{roleName}}. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"role_deleted_successfully": "Rolle erfolgreich gelöscht",
|
||||
"error_creating_role": "Fehler beim Erstellen der Rolle",
|
||||
"error_updating_role": "Fehler beim Aktualisieren der Rolle",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Workflow-Einstellungen bearbeiten und ändern",
|
||||
"pbac_desc_delete_workflows": "Workflows aus dem System entfernen",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Verfügbarkeit",
|
||||
"pbac_resource_out_of_office": "Abwesenheit",
|
||||
"pbac_desc_create_webhooks": "Webhooks erstellen",
|
||||
"pbac_desc_view_webhooks": "Webhooks anzeigen",
|
||||
"pbac_desc_update_webhooks": "Webhooks aktualisieren",
|
||||
"pbac_desc_delete_webhooks": "Webhooks löschen",
|
||||
"pbac_desc_create_availability": "Verfügbarkeit erstellen",
|
||||
"pbac_desc_view_availability": "Verfügbarkeit anzeigen",
|
||||
"pbac_desc_update_availability": "Verfügbarkeit aktualisieren",
|
||||
"pbac_desc_delete_availability": "Verfügbarkeit löschen",
|
||||
"pbac_desc_create_out_of_office": "Abwesenheit erstellen",
|
||||
"pbac_desc_view_out_of_office": "Abwesenheit anzeigen",
|
||||
"pbac_desc_update_out_of_office": "Abwesenheit aktualisieren",
|
||||
"pbac_desc_delete_out_of_office": "Abwesenheit löschen",
|
||||
"pbac_desc_manage_workflows": "Vollständiger Verwaltungszugriff auf alle Workflows",
|
||||
"pbac_desc_create_event_types": "Ereignistypen erstellen",
|
||||
"pbac_desc_view_event_types": "Ereignistypen anzeigen",
|
||||
@@ -3560,8 +3583,8 @@
|
||||
"rr_load_balancing_disabled_with_groups": "Lastausgleich ist mit Round-Robin-Gruppen nicht verfügbar",
|
||||
"round_robin_settings_description": "Passen Sie die Standard-Round-Robin-Einstellungen für dieses Team an",
|
||||
"round_robin_settings_updated_successfully": "Round-Robin-Einstellungen erfolgreich aktualisiert",
|
||||
"booking_creation_time": "Zeitpunkt der Buchungserstellung",
|
||||
"meeting_start_time": "Beginn des Meetings",
|
||||
"booking_creation_time": "Erstellungszeit der Buchung",
|
||||
"meeting_start_time": "Besprechungsstartzeit",
|
||||
"members_affected_by_disabling_delegation_credential": "Betroffene Mitglieder",
|
||||
"no_members_affected_by_disabling_delegation_credential": "Keine Mitglieder von der Deaktivierung der Delegationsanmeldedaten betroffen",
|
||||
"download_expense_log": "Ausgabenprotokoll herunterladen",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "E-Mail des Organisators",
|
||||
"webhook_organizer_timezone": "Zeitzone des Organisators (z.B. 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Sprache des Organisators (z.B. 'en', 'fr')",
|
||||
"webhook_organizer_username": "Benutzername des Organisators (z.B. 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Benutzername des Organisators in seiner Organisation (z.B. 'john.doe')",
|
||||
"webhook_attendee_name": "Name des ersten Teilnehmers",
|
||||
"webhook_attendee_email": "E-Mail des ersten Teilnehmers",
|
||||
"webhook_attendee_timezone": "Zeitzone des ersten Teilnehmers",
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Δεν βρέθηκαν διαθέσιμοι χρήστες. Θα μπορούσατε να δοκιμάσετε άλλη χρονική θυρίδα;",
|
||||
"timeslot_unavailable_book_a_new_time": "Η επιλεγμένη χρονοθυρίδα δεν είναι πλέον διαθέσιμη. <0>Παρακαλώ επιλέξτε νέο χρόνο</0>",
|
||||
"timeslot_unavailable_short": "Κατειλημμένο",
|
||||
"just_connected_description": "Μόλις συνδεθήκατε. Παρακαλώ κάντε κράτηση από τις παραπάνω διαθέσιμες ώρες ή δοκιμάστε ξανά αργότερα.",
|
||||
"unavailable_timeslot_title": "Μη διαθέσιμη χρονοθυρίδα",
|
||||
"booking_time_out_of_bounds_error": "Ο τύπος εκδήλωσης δεν μπορεί να κρατηθεί αυτή τη στιγμή. Θα μπορούσατε να δοκιμάσετε μια άλλη χρονική θυρίδα;",
|
||||
"request_body_end_time_internal_error": "Εσωτερικό σφάλμα. Το σώμα του αιτήματος δεν περιέχει ώρα λήξης",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Μυστική συνάντηση",
|
||||
"login_instead": "Σύνδεση αντ' αυτού",
|
||||
"create_account": "Δημιουργία Λογαριασμού",
|
||||
"get_started": "Ξεκινήστε",
|
||||
"confirm_password": "Επιβεβαίωση κωδικού πρόσβασης",
|
||||
"reset_your_password": "Ορίστε τον νέο σας κωδικό πρόσβασης με τις οδηγίες που στάλθηκαν στη διεύθυνση email σας.",
|
||||
"org_banner_instructions": "Παρακαλούμε ανεβάστε εικόνα με πλάτος {{width}} και ύψος {{height}}.",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Συμμετέχοντες",
|
||||
"add_attendees": "Προσθήκη συμμετεχόντων",
|
||||
"show_advanced_settings": "Εμφάνιση προχωρημένων ρυθμίσεων",
|
||||
"hide_advanced_settings": "Απόκρυψη προχωρημένων ρυθμίσεων",
|
||||
"event_name": "Όνομα εκδήλωσης",
|
||||
"event_name_in_calendar": "Με όνομα εκδήλωσης",
|
||||
"event_name_tooltip": "Το όνομα που θα εμφανίζεται στα ημερολόγια",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Επικοινωνία με Πωλήσεις",
|
||||
"error_404": "Σφάλμα 404",
|
||||
"default": "Προεπιλογή",
|
||||
"set_to_default": "Ορισμός ως Προεπιλογή",
|
||||
"set_to_default": "Ορισμός ως προεπιλογή",
|
||||
"new_schedule_btn": "Νέο πρόγραμμα",
|
||||
"add_new_schedule": "Προσθήκη νέου προγράμματος",
|
||||
"add_new_calendar": "Προσθήκη νέου ημερολογίου",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Τα μέλη της ομάδας σας δεν θα μπορούν να βλέπουν άλλα μέλη της ομάδας όταν αυτό είναι ενεργοποιημένο.",
|
||||
"you_cannot_see_team_members": "Δεν μπορείτε να δείτε όλα τα μέλη μιας ιδιωτικής ομάδας.",
|
||||
"you_cannot_see_teams_of_org": "Δεν μπορείτε να δείτε τις ομάδες ενός ιδιωτικού οργανισμού.",
|
||||
"allow_booker_to_select_duration": "Επιτρέψτε στον χρήστη να επιλέξει διάρκεια",
|
||||
"allow_multiple_durations": "Επιτρέψτε πολλαπλές διάρκειες",
|
||||
"impersonate_user_tip": "Όλες οι χρήσεις αυτής της λειτουργίας καταγράφονται.",
|
||||
"impersonating_user_warning": "Μιμείστε τον χρήστη \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Κάντε κλικ εδώ για διακοπή",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη διαμόρφωση {{connectionType}}; Τα μέλη της ομάδας σας που χρησιμοποιούν σύνδεση {{connectionType}} δεν θα μπορούν πλέον να έχουν πρόσβαση στο Cal.com.",
|
||||
"organizer_timezone": "Ζώνη ώρας διοργανωτή",
|
||||
"email_user_cta": "Προβολή πρόσκλησης",
|
||||
"accept_invite": "Αποδοχή Πρόσκλησης",
|
||||
"join_now": "Συμμετοχή Τώρα",
|
||||
"successfully_joined": "Επιτυχής συμμετοχή",
|
||||
"email_no_user_invite_heading_subteam": "Έχετε προσκληθεί να συμμετάσχετε σε μια ομάδα του οργανισμού {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Έχετε προσκληθεί να συμμετάσχετε σε έναν οργανισμό {{appName}}",
|
||||
"email_no_user_invite_subheading": "Ο/Η {{invitedBy}} σας έχει προσκαλέσει να συμμετάσχετε στην ομάδα τους στο {{appName}}. Το {{appName}} είναι ο προγραμματιστής συναντήσεων που επιτρέπει σε εσάς και την ομάδα σας να κανονίζετε συναντήσεις χωρίς ατελείωτα email.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Η επιβεβαίωση της κράτησης απέτυχε",
|
||||
"not_enough_seats": "Δεν υπάρχουν αρκετές θέσεις",
|
||||
"form_builder_field_already_exists": "Υπάρχει ήδη ένα πεδίο με αυτό το όνομα",
|
||||
"guests_field_must_be_multiemail": "Το πεδίο επισκεπτών πρέπει να είναι τύπου 'Πολλαπλά emails'",
|
||||
"show_on_booking_page": "Εμφάνιση στη σελίδα κράτησης",
|
||||
"visit_cancelled_booking": "Μπορείτε να επισκεφθείτε τη σελίδα ακυρωμένων κρατήσεων",
|
||||
"get_started_zapier_templates": "Ξεκινήστε με τα πρότυπα του Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Ο σύνδεσμος πρόσκλησης αντιγράφηκε",
|
||||
"invite_link_deleted": "Ο σύνδεσμος πρόσκλησης διαγράφηκε",
|
||||
"api_key_deleted": "Το κλειδί API διαγράφηκε",
|
||||
"delete_api_key_warning": "Αυτό θα διαγράψει μόνιμα το κλειδί API. Οποιεσδήποτε εφαρμογές χρησιμοποιούν αυτό το κλειδί θα χάσουν αμέσως την πρόσβαση στον λογαριασμό σας. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"invite_link_updated": "Οι ρυθμίσεις του συνδέσμου πρόσκλησης αποθηκεύτηκαν",
|
||||
"link_expires_after": "Οι σύνδεσμοι λήγουν μετά από...",
|
||||
"one_day": "1 ημέρα",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "Enterprise",
|
||||
"enterprise_description": "Αναβαθμίστε σε Enterprise για να δημιουργήσετε τον οργανισμό σας",
|
||||
"create_your_org": "Δημιουργήστε τον Οργανισμό σας",
|
||||
"create_org": "Δημιουργία Οργανισμού",
|
||||
"create_org": "Δημιουργία οργανισμού",
|
||||
"create_your_org_description": "Αναβαθμίστε σε Οργανισμούς και λάβετε υποτομέα, ενοποιημένη τιμολόγηση, Αναλύσεις, εκτεταμένο white-labeling και άλλα",
|
||||
"create_your_enterprise_description": "Αναβαθμίστε σε Enterprise και αποκτήστε πρόσβαση σε Συγχρονισμό Active Directory, Αυτόματη παροχή χρηστών SCIM, Φωνητικούς πράκτορες Cal.ai, APIs διαχειριστή και άλλα!",
|
||||
"other_payment_app_enabled": "Μπορείτε να ενεργοποιήσετε μόνο μία εφαρμογή πληρωμών ανά τύπο εκδήλωσης",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Προχωρώντας, συμφωνείτε με τους",
|
||||
"terms": "Όρους",
|
||||
"always_show_x_days": "Πάντα {{x}} διαθέσιμες ημέρες",
|
||||
"always_show_x_days_description": "Μετακινεί αυτόματα το παράθυρο κρατήσεων {{x}} ημερών κάθε μέρα, διασφαλίζοντας ότι το ημερολόγιό σας δείχνει πάντα τις επόμενες {{x}} ημέρες διαθέσιμων χρονοθυρίδων.",
|
||||
"team_subscription_cancelled_successfully": "Η συνδρομή ομάδας ακυρώθηκε με επιτυχία",
|
||||
"team_subscription_cancellation_error": "Σφάλμα κατά την ακύρωση της συνδρομής ομάδας",
|
||||
"unable_to_subscribe_to_the_platform": "Παρουσιάστηκε σφάλμα κατά την εγγραφή στο πρόγραμμα της πλατφόρμας, παρακαλώ δοκιμάστε ξανά αργότερα",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Τελειώνει με",
|
||||
"filter_operator_is_empty": "Είναι κενό",
|
||||
"filter_operator_not_empty": "Δεν είναι κενό",
|
||||
"translate_description_button": "Μετάφραση τίτλου/περιγραφής στη γλώσσα του προγράμματος περιήγησης του επισκέπτη με χρήση AI",
|
||||
"translate_description_button": "Αυτόματη μετάφραση τίτλου και περιγραφής",
|
||||
"translate_description_button_description": "Αυτόματη μετάφραση τίτλων και περιγραφών στη γλώσσα του προγράμματος περιήγησης του επισκέπτη χρησιμοποιώντας AI.",
|
||||
"rr_distribution_method": "Κατανομή",
|
||||
"rr_distribution_method_description": "Επιτρέπει τη βελτιστοποίηση της κατανομής για μέγιστη διαθεσιμότητα ή για πιο ισορροπημένη ανάθεση.",
|
||||
"rr_distribution_method_availability_title": "Μεγιστοποίηση διαθεσιμότητας",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Η λειτουργία θέσεων δεν υποστηρίζει επαναλαμβανόμενη εκδήλωση. Απενεργοποιήστε τη λειτουργία θέσεων για να την κάνετε επαναλαμβανόμενη.",
|
||||
"recurring_event_seats_error": "Η επαναλαμβανόμενη εκδήλωση δεν υποστηρίζει τη λειτουργία θέσεων. Απενεργοποιήστε τη λειτουργία θέσεων ή κάντε την εκδήλωση μη επαναλαμβανόμενη.",
|
||||
"segment": "Τμήμα",
|
||||
"saved_filters": "Αποθηκευμένα φίλτρα",
|
||||
"rename": "Μετονομασία",
|
||||
"rename_segment": "Μετονομασία τμήματος",
|
||||
"delete_segment": "Διαγραφή τμήματος",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Λίστα επιλογών",
|
||||
"most_cancelled_bookings": "Περισσότερες ακυρωμένες κρατήσεις",
|
||||
"interface_language": "Γλώσσα διεπαφής",
|
||||
"interface_language_description": "Ορίστε την προτιμώμενη γλώσσα για το περιβάλλον κρατήσεων",
|
||||
"visitors_browser_language": "Γλώσσα προγράμματος περιήγησης επισκέπτη",
|
||||
"name_or_email": "Όνομα ή Email",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Αν δεν βρεθεί επαφή, επιστροφή στον ιδιοκτήτη του lead εάν υπάρχει",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Διαχειριστείτε ρόλους και δικαιώματα για τον οργανισμό σας",
|
||||
"learn_more_permissions": "Μάθετε περισσότερα για το πώς λειτουργούν τα Δικαιώματα",
|
||||
"role_name": "Όνομα ρόλου",
|
||||
"role_name_required": "Το όνομα ρόλου είναι υποχρεωτικό",
|
||||
"role_name_placeholder": "π.χ. Ομάδα Πωλήσεων, Προσωπικό Υποστήριξης",
|
||||
"role_description": "Περιγραφή ρόλου",
|
||||
"role_description_placeholder": "Περιγράψτε για ποιο σκοπό προορίζεται αυτός ο ρόλος και ποια δικαιώματα χρειάζεται",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Ο ρόλος δημιουργήθηκε με επιτυχία",
|
||||
"role_updated_successfully": "Ο ρόλος ενημερώθηκε με επιτυχία",
|
||||
"delete_role": "Διαγραφή ρόλου",
|
||||
"confirm_delete_role": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον ρόλο; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"confirm_delete_role": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον ρόλο: {{roleName}}; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"role_deleted_successfully": "Ο ρόλος διαγράφηκε με επιτυχία",
|
||||
"error_creating_role": "Σφάλμα κατά τη δημιουργία ρόλου",
|
||||
"error_updating_role": "Σφάλμα κατά την ενημέρωση ρόλου",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Επεξεργασία και τροποποίηση ρυθμίσεων ροών εργασίας",
|
||||
"pbac_desc_delete_workflows": "Αφαίρεση ροών εργασίας από το σύστημα",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Διαθεσιμότητα",
|
||||
"pbac_resource_out_of_office": "Εκτός Γραφείου",
|
||||
"pbac_desc_create_webhooks": "Δημιουργία webhooks",
|
||||
"pbac_desc_view_webhooks": "Προβολή webhooks",
|
||||
"pbac_desc_update_webhooks": "Ενημέρωση webhooks",
|
||||
"pbac_desc_delete_webhooks": "Διαγραφή webhooks",
|
||||
"pbac_desc_create_availability": "Δημιουργία διαθεσιμότητας",
|
||||
"pbac_desc_view_availability": "Προβολή διαθεσιμότητας",
|
||||
"pbac_desc_update_availability": "Ενημέρωση διαθεσιμότητας",
|
||||
"pbac_desc_delete_availability": "Διαγραφή διαθεσιμότητας",
|
||||
"pbac_desc_create_out_of_office": "Δημιουργία απουσίας",
|
||||
"pbac_desc_view_out_of_office": "Προβολή απουσίας",
|
||||
"pbac_desc_update_out_of_office": "Ενημέρωση απουσίας",
|
||||
"pbac_desc_delete_out_of_office": "Διαγραφή απουσίας",
|
||||
"pbac_desc_manage_workflows": "Πλήρης πρόσβαση διαχείρισης σε όλες τις ροές εργασίας",
|
||||
"pbac_desc_create_event_types": "Δημιουργία τύπων εκδηλώσεων",
|
||||
"pbac_desc_view_event_types": "Προβολή τύπων εκδηλώσεων",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Email του διοργανωτή",
|
||||
"webhook_organizer_timezone": "Ζώνη ώρας του διοργανωτή (π.χ., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Τοπικές ρυθμίσεις του διοργανωτή (π.χ., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Όνομα χρήστη του διοργανωτή (π.χ., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Όνομα χρήστη του διοργανωτή στον οργανισμό του (π.χ., 'john.doe')",
|
||||
"webhook_attendee_name": "Όνομα του πρώτου συμμετέχοντα",
|
||||
"webhook_attendee_email": "Email του πρώτου συμμετέχοντα",
|
||||
"webhook_attendee_timezone": "Ζώνη ώρας του πρώτου συμμετέχοντα",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Χρήση φωνής",
|
||||
"current_voice": "Τρέχουσα φωνή",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Προσθέστε τις νέες συμβολοσειρές σας πάνω από εδώ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "No available users found. Could you try another time slot?",
|
||||
"timeslot_unavailable_book_a_new_time": "The selected time slot is no longer available. <0>Please select a new time</0>",
|
||||
"timeslot_unavailable_short": "Taken",
|
||||
"just_connected_description": "You’ve just connected. Please book from above slots or retry later.",
|
||||
"unavailable_timeslot_title": "Unavailable timeslot",
|
||||
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
|
||||
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
|
||||
@@ -2152,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
|
||||
"organizer_timezone": "Organizer timezone",
|
||||
"email_user_cta": "View Invitation",
|
||||
"accept_invite": "Accept Invite",
|
||||
"join_now": "Join Now",
|
||||
"successfully_joined": "Successfully joined",
|
||||
"email_no_user_invite_heading_subteam": "You've been invited to join a team of {{parentTeamName}} organization",
|
||||
"email_no_user_invite_heading_org": "You've been invited to join a {{appName}} organization",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
@@ -2382,6 +2386,7 @@
|
||||
"invite_link_copied": "Invite link copied",
|
||||
"invite_link_deleted": "Invite link deleted",
|
||||
"api_key_deleted": "API Key deleted",
|
||||
"delete_api_key_warning": "This will permanently delete the API key. Any applications using this key will immediately lose access to your account. This action cannot be undone.",
|
||||
"invite_link_updated": "Invite link settings saved",
|
||||
"link_expires_after": "Links set to expire after...",
|
||||
"one_day": "1 day",
|
||||
@@ -3459,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Manage roles and permissions for your organization",
|
||||
"learn_more_permissions": "Learn more about how Permissions work",
|
||||
"role_name": "Role name",
|
||||
"role_name_required": "Role name is required",
|
||||
"role_name_placeholder": "e.g. Sales Team, Support Staff",
|
||||
"role_description": "Role description",
|
||||
"role_description_placeholder": "Describe what this role is for and what permissions it needs",
|
||||
@@ -3497,7 +3503,7 @@
|
||||
"role_created_successfully": "Role created successfully",
|
||||
"role_updated_successfully": "Role updated successfully",
|
||||
"delete_role": "Delete role",
|
||||
"confirm_delete_role": "Are you sure you want to delete this role? This action cannot be undone.",
|
||||
"confirm_delete_role": "Are you sure you want to delete role: {{roleName}}. This action cannot be undone.",
|
||||
"role_deleted_successfully": "Role deleted successfully",
|
||||
"error_creating_role": "Error creating role",
|
||||
"error_updating_role": "Error updating role",
|
||||
@@ -3671,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Email of the organizer",
|
||||
"webhook_organizer_timezone": "Timezone of the organizer (e.g., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Locale of the organizer (e.g., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Username of the organizer (e.g., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Username of the organizer in their organization (e.g., 'john.doe')",
|
||||
"webhook_attendee_name": "Name of the first attendee",
|
||||
"webhook_attendee_email": "Email of the first attendee",
|
||||
"webhook_attendee_timezone": "Timezone of the first attendee",
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "No se encontraron usuarios disponibles. ¿Podrías intentar en otro horario?",
|
||||
"timeslot_unavailable_book_a_new_time": "El horario seleccionado ya no está disponible. <0>Por favor selecciona un nuevo horario</0>",
|
||||
"timeslot_unavailable_short": "Ocupado",
|
||||
"just_connected_description": "Acabas de conectarte. Por favor, reserva desde los espacios disponibles arriba o inténtalo más tarde.",
|
||||
"unavailable_timeslot_title": "Horario no disponible",
|
||||
"booking_time_out_of_bounds_error": "El evento no se puede agendar en este momento. ¿Podrías intentar con otro horario?",
|
||||
"request_body_end_time_internal_error": "Error interno. El cuerpo de la solicitud no contiene la hora de finalización",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Reunión Secreta",
|
||||
"login_instead": "Inicia sesión en su lugar",
|
||||
"create_account": "Crear cuenta",
|
||||
"get_started": "Comenzar",
|
||||
"confirm_password": "Confirmar contraseña",
|
||||
"reset_your_password": "Establece tu nueva contraseña con las instrucciones enviadas a tu dirección de correo electrónico.",
|
||||
"org_banner_instructions": "Por favor, sube una imagen de {{width}} de ancho y {{height}} de alto.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Color del Tipo de Evento (Tema Claro)",
|
||||
"dark_event_type_color": "Color del Tipo de Evento (Tema Oscuro)",
|
||||
"file_not_named": "El archivo no está nombrado [idOrSlug]/[user]",
|
||||
"create_team": "Crear Equipo",
|
||||
"create_team": "Crear equipo",
|
||||
"name": "Nombre",
|
||||
"nameless_team": "Equipo sin nombre",
|
||||
"oauth_clients": "Clientes OAuth",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Asistentes",
|
||||
"add_attendees": "Agregar asistentes",
|
||||
"show_advanced_settings": "Mostrar configuraciones avanzadas",
|
||||
"hide_advanced_settings": "Ocultar configuración avanzada",
|
||||
"event_name": "Nombre del evento",
|
||||
"event_name_in_calendar": "Con nombre del evento",
|
||||
"event_name_tooltip": "El nombre que aparecerá en los calendarios",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Los miembros de tu equipo no podrán ver a otros miembros del equipo cuando esto esté activado.",
|
||||
"you_cannot_see_team_members": "No puedes ver a todos los miembros de un equipo privado.",
|
||||
"you_cannot_see_teams_of_org": "No puedes ver los equipos de una organización privada.",
|
||||
"allow_booker_to_select_duration": "Permitir al reservador seleccionar la duración",
|
||||
"allow_multiple_durations": "Permitir múltiples duraciones",
|
||||
"impersonate_user_tip": "Todos los usos de esta función son auditados.",
|
||||
"impersonating_user_warning": "Suplantando al usuario \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Haz clic aquí para detener",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "¿Estás seguro de que deseas eliminar la configuración de {{connectionType}}? Los miembros de tu equipo que usan el inicio de sesión de {{connectionType}} ya no podrán acceder a Cal.com.",
|
||||
"organizer_timezone": "Zona horaria del organizador",
|
||||
"email_user_cta": "Ver invitación",
|
||||
"accept_invite": "Aceptar invitación",
|
||||
"join_now": "Unirse ahora",
|
||||
"successfully_joined": "Te has unido exitosamente",
|
||||
"email_no_user_invite_heading_subteam": "Has sido invitado a unirte a un equipo de la organización {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Has sido invitado a unirte a una organización de {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} te ha invitado a unirte a su equipo en {{appName}}. {{appName}} es el programador de eventos que te permite a ti y a tu equipo agendar reuniones sin el ir y venir de correos electrónicos.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "La confirmación de la reserva falló",
|
||||
"not_enough_seats": "No hay suficientes asientos",
|
||||
"form_builder_field_already_exists": "Ya existe un campo con este nombre",
|
||||
"guests_field_must_be_multiemail": "El campo de invitados debe ser de tipo 'Múltiples correos electrónicos'",
|
||||
"show_on_booking_page": "Mostrar en la página de reserva",
|
||||
"visit_cancelled_booking": "Puedes visitar la página de la reserva cancelada",
|
||||
"get_started_zapier_templates": "Comienza con las plantillas de Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Enlace de invitación copiado",
|
||||
"invite_link_deleted": "Enlace de invitación eliminado",
|
||||
"api_key_deleted": "Clave API eliminada",
|
||||
"delete_api_key_warning": "Esto eliminará permanentemente la clave API. Cualquier aplicación que use esta clave perderá inmediatamente el acceso a tu cuenta. Esta acción no se puede deshacer.",
|
||||
"invite_link_updated": "Configuración del enlace de invitación guardada",
|
||||
"link_expires_after": "Los enlaces están configurados para expirar después de...",
|
||||
"one_day": "1 día",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "Empresa",
|
||||
"enterprise_description": "Actualiza a Enterprise para crear tu Organización",
|
||||
"create_your_org": "Crea tu Organización",
|
||||
"create_org": "Crear Organización",
|
||||
"create_org": "Crear organización",
|
||||
"create_your_org_description": "Actualiza a Organizaciones y recibe un subdominio, facturación unificada, Insights, etiquetado blanco extensivo y más",
|
||||
"create_your_enterprise_description": "Actualiza a Enterprise y obtén acceso a la sincronización de Active Directory, aprovisionamiento automático de usuarios SCIM, agentes de voz Cal.ai, APIs de administración y más",
|
||||
"other_payment_app_enabled": "Solo puedes habilitar una aplicación de pago por tipo de evento",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Al continuar, aceptas nuestros",
|
||||
"terms": "Términos",
|
||||
"always_show_x_days": "Siempre {{x}} días disponibles",
|
||||
"always_show_x_days_description": "Mueve automáticamente la ventana de reservas de {{x}} días hacia adelante cada día, asegurando que tu calendario siempre muestre los próximos {{x}} días de espacios disponibles.",
|
||||
"team_subscription_cancelled_successfully": "Suscripción del equipo cancelada con éxito",
|
||||
"team_subscription_cancellation_error": "Error al cancelar la suscripción del equipo",
|
||||
"unable_to_subscribe_to_the_platform": "Ocurrió un error al intentar suscribirse al plan de la plataforma, por favor intenta de nuevo más tarde",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Termina con",
|
||||
"filter_operator_is_empty": "Está vacío",
|
||||
"filter_operator_not_empty": "No está vacío",
|
||||
"translate_description_button": "Traducir título/descripción al idioma del navegador del visitante usando IA",
|
||||
"translate_description_button": "Traducir automáticamente título y descripción",
|
||||
"translate_description_button_description": "Traduce automáticamente títulos y descripciones al idioma del navegador del visitante usando IA.",
|
||||
"rr_distribution_method": "Distribución",
|
||||
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
|
||||
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "La función de asientos no admite eventos recurrentes. Desactiva la función de asientos para hacerlo recurrente.",
|
||||
"recurring_event_seats_error": "Los eventos recurrentes no admiten la función de asientos. Desactiva la función de asientos o haz que el evento no sea recurrente.",
|
||||
"segment": "Segmento",
|
||||
"saved_filters": "Filtros guardados",
|
||||
"rename": "Renombrar",
|
||||
"rename_segment": "Renombrar segmento",
|
||||
"delete_segment": "Eliminar segmento",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Lista de selección",
|
||||
"most_cancelled_bookings": "Más cancelaciones",
|
||||
"interface_language": "Idioma de la interfaz",
|
||||
"interface_language_description": "Establece tu idioma preferido para la interfaz de reservas",
|
||||
"visitors_browser_language": "Idioma del navegador del visitante",
|
||||
"name_or_email": "Nombre o correo electrónico",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Si no se encuentra ningún contacto, recurrir al propietario del lead si existe",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Administra roles y permisos para tu organización",
|
||||
"learn_more_permissions": "Aprende más sobre cómo funcionan los permisos",
|
||||
"role_name": "Nombre del rol",
|
||||
"role_name_required": "El nombre del rol es obligatorio",
|
||||
"role_name_placeholder": "p. ej. Equipo de ventas, Personal de soporte",
|
||||
"role_description": "Descripción del rol",
|
||||
"role_description_placeholder": "Describe para qué es este rol y qué permisos necesita",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rol creado exitosamente",
|
||||
"role_updated_successfully": "Rol actualizado exitosamente",
|
||||
"delete_role": "Eliminar rol",
|
||||
"confirm_delete_role": "¿Estás seguro de que quieres eliminar este rol? Esta acción no se puede deshacer.",
|
||||
"confirm_delete_role": "¿Estás seguro de que quieres eliminar el rol: {{roleName}}? Esta acción no se puede deshacer.",
|
||||
"role_deleted_successfully": "Rol eliminado exitosamente",
|
||||
"error_creating_role": "Error al crear el rol",
|
||||
"error_updating_role": "Error al actualizar el rol",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Editar y modificar configuraciones de flujos de trabajo",
|
||||
"pbac_desc_delete_workflows": "Eliminar flujos de trabajo del sistema",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Disponibilidad",
|
||||
"pbac_resource_out_of_office": "Fuera de la oficina",
|
||||
"pbac_desc_create_webhooks": "Crear webhooks",
|
||||
"pbac_desc_view_webhooks": "Ver webhooks",
|
||||
"pbac_desc_update_webhooks": "Actualizar webhooks",
|
||||
"pbac_desc_delete_webhooks": "Eliminar webhooks",
|
||||
"pbac_desc_create_availability": "Crear disponibilidad",
|
||||
"pbac_desc_view_availability": "Ver disponibilidad",
|
||||
"pbac_desc_update_availability": "Actualizar disponibilidad",
|
||||
"pbac_desc_delete_availability": "Eliminar disponibilidad",
|
||||
"pbac_desc_create_out_of_office": "Crear ausencia",
|
||||
"pbac_desc_view_out_of_office": "Ver ausencia",
|
||||
"pbac_desc_update_out_of_office": "Actualizar ausencia",
|
||||
"pbac_desc_delete_out_of_office": "Eliminar ausencia",
|
||||
"pbac_desc_manage_workflows": "Acceso completo de gestión a todos los flujos de trabajo",
|
||||
"pbac_desc_create_event_types": "Crear tipos de eventos",
|
||||
"pbac_desc_view_event_types": "Ver tipos de eventos",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Correo electrónico del organizador",
|
||||
"webhook_organizer_timezone": "Zona horaria del organizador (por ejemplo, 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Configuración regional del organizador (por ejemplo, 'en', 'fr')",
|
||||
"webhook_organizer_username": "Nombre de usuario del organizador (p. ej., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Nombre de usuario del organizador en su organización (p. ej., 'john.doe')",
|
||||
"webhook_attendee_name": "Nombre del primer asistente",
|
||||
"webhook_attendee_email": "Correo electrónico del primer asistente",
|
||||
"webhook_attendee_timezone": "Zona horaria del primer asistente",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Usar voz",
|
||||
"current_voice": "Voz actual",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "No se encontraron usuarios disponibles. ¿Podrías intentar en otro horario?",
|
||||
"timeslot_unavailable_book_a_new_time": "El horario seleccionado ya no está disponible. <0>Por favor selecciona un nuevo horario</0>",
|
||||
"timeslot_unavailable_short": "Ocupado",
|
||||
"just_connected_description": "Acabas de conectarte. Por favor, reserva desde los espacios de arriba o inténtalo más tarde.",
|
||||
"unavailable_timeslot_title": "Horario no disponible",
|
||||
"booking_time_out_of_bounds_error": "Este tipo de evento no se puede reservar en este momento. ¿Podrías intentar con otro horario?",
|
||||
"request_body_end_time_internal_error": "Error interno. El cuerpo de la solicitud no contiene la hora de finalización",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Reunión Secreta",
|
||||
"login_instead": "Iniciar Sesión en su lugar",
|
||||
"create_account": "Crear Cuenta",
|
||||
"get_started": "Comenzar",
|
||||
"confirm_password": "Confirmar Contraseña",
|
||||
"reset_your_password": "Establezca su nueva contraseña con las instrucciones enviadas a su dirección de correo electrónico.",
|
||||
"org_banner_instructions": "Por favor, sube una imagen de {{width}} de ancho y {{height}} de alto.",
|
||||
@@ -958,7 +960,7 @@
|
||||
"light_event_type_color": "Color del tipo de evento (Tema claro)",
|
||||
"dark_event_type_color": "Color del tipo de evento (Tema oscuro)",
|
||||
"file_not_named": "El archivo no se llama [idOrSlug] / [user]",
|
||||
"create_team": "Crear Equipo",
|
||||
"create_team": "Crear equipo",
|
||||
"name": "Nombre",
|
||||
"nameless_team": "Equipo sin nombre",
|
||||
"oauth_clients": "Clientes OAuth",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Asistentes",
|
||||
"add_attendees": "Añadir Asistentes",
|
||||
"show_advanced_settings": "Mostrar Opciones Avanzadas",
|
||||
"hide_advanced_settings": "Ocultar ajustes avanzados",
|
||||
"event_name": "Nombre del Evento",
|
||||
"event_name_in_calendar": "Nombre del evento en el calendario",
|
||||
"event_name_tooltip": "Nombre que aparecerá en los calendarios",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Los miembros de su equipo no podrán ver a otros miembros del equipo cuando esto esté activado.",
|
||||
"you_cannot_see_team_members": "No puede ver a todos los miembros de un equipo privado.",
|
||||
"you_cannot_see_teams_of_org": "No puedes ver los equipos de una organización privada.",
|
||||
"allow_booker_to_select_duration": "Permitir que el encargado de las reservas seleccione la duración",
|
||||
"allow_multiple_durations": "Permitir múltiples duraciones",
|
||||
"impersonate_user_tip": "Todos los usos de esta función son revisados.",
|
||||
"impersonating_user_warning": "Suplantando nombre de usuario \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Haga clic aquí para detener.",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "¿Está seguro de que desea eliminar la configuración de {{connectionType}}? Los miembros de su equipo que utilicen el inicio de sesión {{connectionType}} ya no podrán acceder a Cal.com.",
|
||||
"organizer_timezone": "Zona horaria del organizador",
|
||||
"email_user_cta": "Ver la invitación",
|
||||
"accept_invite": "Aceptar invitación",
|
||||
"join_now": "Unirse ahora",
|
||||
"successfully_joined": "Unido con éxito",
|
||||
"email_no_user_invite_heading_subteam": "Has sido invitado a unirte a un equipo de la organización {{parentTeamName}}",
|
||||
"email_no_user_invite_heading_org": "Has sido invitado a unirte a una organización de {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} lo invitó a unirse a su equipo en {{appName}}. {{appName}} es el programador de eventos que les permite a usted y a su equipo programar reuniones sin necesidad de enviar correos electrónicos.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Error al confirmar la reserva",
|
||||
"not_enough_seats": "No hay suficientes plazas",
|
||||
"form_builder_field_already_exists": "Ya existe un campo con este nombre",
|
||||
"guests_field_must_be_multiemail": "El campo de invitados debe ser de tipo 'Múltiples correos electrónicos'",
|
||||
"show_on_booking_page": "Mostrar en la página de reserva",
|
||||
"visit_cancelled_booking": "Puedes visitar la página de la reserva cancelada",
|
||||
"get_started_zapier_templates": "Comience con las plantillas de Zapier",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Enlace de invitación copiado",
|
||||
"invite_link_deleted": "Enlace de invitación eliminado",
|
||||
"api_key_deleted": "Clave API eliminada",
|
||||
"delete_api_key_warning": "Esto eliminará permanentemente la clave API. Cualquier aplicación que use esta clave perderá inmediatamente el acceso a tu cuenta. Esta acción no se puede deshacer.",
|
||||
"invite_link_updated": "Configuración de enlace de invitación guardada",
|
||||
"link_expires_after": "Enlaces establecidos para expirar después de...",
|
||||
"one_day": "1 día",
|
||||
@@ -2688,7 +2696,7 @@
|
||||
"enterprise": "Empresa",
|
||||
"enterprise_description": "Actualiza a Enterprise para crear tu Organización",
|
||||
"create_your_org": "Crea tu Organización",
|
||||
"create_org": "Crear Organización",
|
||||
"create_org": "Crear organización",
|
||||
"create_your_org_description": "Actualiza a Organizaciones y recibe un subdominio, facturación unificada, Insights, personalización extensa y más",
|
||||
"create_your_enterprise_description": "Actualiza a Enterprise y obtén acceso a la sincronización de Active Directory, aprovisionamiento automático de usuarios SCIM, agentes de voz de Cal.ai, APIs de administración y más",
|
||||
"other_payment_app_enabled": "Solo puedes habilitar una aplicación de pago por tipo de evento",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Al continuar, aceptas nuestros",
|
||||
"terms": "Términos",
|
||||
"always_show_x_days": "Siempre {{x}} días disponibles",
|
||||
"always_show_x_days_description": "Mueve automáticamente la ventana de reservas de {{x}} días hacia adelante cada día, asegurando que tu calendario siempre muestre los próximos {{x}} días de espacios disponibles.",
|
||||
"team_subscription_cancelled_successfully": "Suscripción del equipo cancelada con éxito",
|
||||
"team_subscription_cancellation_error": "Error al cancelar la suscripción del equipo",
|
||||
"unable_to_subscribe_to_the_platform": "Ocurrió un error al intentar suscribirse al plan de la plataforma, por favor intente de nuevo más tarde",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Termina con",
|
||||
"filter_operator_is_empty": "Está vacío",
|
||||
"filter_operator_not_empty": "No está vacío",
|
||||
"translate_description_button": "Traducir título/descripción al idioma del navegador del visitante usando IA",
|
||||
"translate_description_button": "Auto traducir título y descripción",
|
||||
"translate_description_button_description": "Traduce automáticamente títulos y descripciones al idioma del navegador del visitante usando IA.",
|
||||
"rr_distribution_method": "Distribución",
|
||||
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
|
||||
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "La función de asientos no admite eventos recurrentes. Desactiva la función de asientos para hacerlo recurrente.",
|
||||
"recurring_event_seats_error": "Los eventos recurrentes no admiten la función de asientos. Desactiva la función de asientos o haz que el evento no sea recurrente.",
|
||||
"segment": "Segmento",
|
||||
"saved_filters": "Filtros guardados",
|
||||
"rename": "Renombrar",
|
||||
"rename_segment": "Renombrar segmento",
|
||||
"delete_segment": "Eliminar segmento",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Lista de selección",
|
||||
"most_cancelled_bookings": "Más cancelaciones",
|
||||
"interface_language": "Idioma de la interfaz",
|
||||
"interface_language_description": "Establece tu idioma preferido para la interfaz de reservas",
|
||||
"visitors_browser_language": "Idioma del navegador del visitante",
|
||||
"name_or_email": "Nombre o correo electrónico",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Si no se encuentra ningún contacto, recurrir al propietario del lead si existe",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Gestiona roles y permisos para tu organización",
|
||||
"learn_more_permissions": "Aprende más sobre cómo funcionan los permisos",
|
||||
"role_name": "Nombre del rol",
|
||||
"role_name_required": "El nombre del rol es obligatorio",
|
||||
"role_name_placeholder": "p. ej. Equipo de ventas, Personal de soporte",
|
||||
"role_description": "Descripción del rol",
|
||||
"role_description_placeholder": "Describe para qué sirve este rol y qué permisos necesita",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rol creado con éxito",
|
||||
"role_updated_successfully": "Rol actualizado con éxito",
|
||||
"delete_role": "Eliminar rol",
|
||||
"confirm_delete_role": "¿Está seguro de que desea eliminar este rol? Esta acción no se puede deshacer.",
|
||||
"confirm_delete_role": "¿Estás seguro de que quieres eliminar el rol: {{roleName}}? Esta acción no se puede deshacer.",
|
||||
"role_deleted_successfully": "Rol eliminado con éxito",
|
||||
"error_creating_role": "Error al crear el rol",
|
||||
"error_updating_role": "Error al actualizar el rol",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Editar y modificar ajustes de flujos de trabajo",
|
||||
"pbac_desc_delete_workflows": "Eliminar flujos de trabajo del sistema",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Disponibilidad",
|
||||
"pbac_resource_out_of_office": "Fuera de la oficina",
|
||||
"pbac_desc_create_webhooks": "Crear webhooks",
|
||||
"pbac_desc_view_webhooks": "Ver webhooks",
|
||||
"pbac_desc_update_webhooks": "Actualizar webhooks",
|
||||
"pbac_desc_delete_webhooks": "Eliminar webhooks",
|
||||
"pbac_desc_create_availability": "Crear disponibilidad",
|
||||
"pbac_desc_view_availability": "Ver disponibilidad",
|
||||
"pbac_desc_update_availability": "Actualizar disponibilidad",
|
||||
"pbac_desc_delete_availability": "Eliminar disponibilidad",
|
||||
"pbac_desc_create_out_of_office": "Crear fuera de la oficina",
|
||||
"pbac_desc_view_out_of_office": "Ver fuera de la oficina",
|
||||
"pbac_desc_update_out_of_office": "Actualizar fuera de la oficina",
|
||||
"pbac_desc_delete_out_of_office": "Eliminar fuera de la oficina",
|
||||
"pbac_desc_manage_workflows": "Acceso completo de gestión a todos los flujos de trabajo",
|
||||
"pbac_desc_create_event_types": "Crear tipos de eventos",
|
||||
"pbac_desc_view_event_types": "Ver tipos de eventos",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Correo electrónico del organizador",
|
||||
"webhook_organizer_timezone": "Zona horaria del organizador (p. ej., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Configuración regional del organizador (p. ej., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Nombre de usuario del organizador (p. ej., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Nombre de usuario del organizador en su organización (p. ej., 'john.doe')",
|
||||
"webhook_attendee_name": "Nombre del primer asistente",
|
||||
"webhook_attendee_email": "Correo electrónico del primer asistente",
|
||||
"webhook_attendee_timezone": "Zona horaria del primer asistente",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Usar voz",
|
||||
"current_voice": "Voz actual",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Saadaolevaid kasutajaid ei leitud. Kas saaksite proovida teist ajavahemikku?",
|
||||
"timeslot_unavailable_book_a_new_time": "Valitud ajavahemik pole enam saadaval. <0>Palun vali uus aeg</0>",
|
||||
"timeslot_unavailable_short": "Võetud",
|
||||
"just_connected_description": "Olete just ühenduse loonud. Palun broneerige ülaltoodud ajavahemikest või proovige hiljem uuesti.",
|
||||
"unavailable_timeslot_title": "Mittesaadaval ajaperiood",
|
||||
"booking_time_out_of_bounds_error": "Sündmust ei saa sellel ajal broneerida. Kas proovid mõnda teist aega?",
|
||||
"request_body_end_time_internal_error": "Sisemine viga. Taotluse sisu ei sisalda lõpuaega",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Salajane koosolek",
|
||||
"login_instead": "Selle asemel logi sisse",
|
||||
"create_account": "Loo konto",
|
||||
"get_started": "Alusta",
|
||||
"confirm_password": "Kinnita salasõna",
|
||||
"reset_your_password": "Seadke oma uus parool teie e-posti aadressile saadetud juhiste järgi.",
|
||||
"org_banner_instructions": "Palun laadige üles pilt laiusega {{width}} ja kõrgusega {{height}}.",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Osalejad",
|
||||
"add_attendees": "Lisa osalejaid",
|
||||
"show_advanced_settings": "Kuva täpsemad seaded",
|
||||
"hide_advanced_settings": "Peida täpsemad seaded",
|
||||
"event_name": "Sündmuse nimi",
|
||||
"event_name_in_calendar": "Sündmuse nimega",
|
||||
"event_name_tooltip": "Kalendrites kuvatav nimi",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Võtke ühendust müügiga",
|
||||
"error_404": "Viga 404",
|
||||
"default": "Vaikimisi",
|
||||
"set_to_default": "Set to Default",
|
||||
"set_to_default": "Määra vaikimisi",
|
||||
"new_schedule_btn": "Uus ajakava",
|
||||
"add_new_schedule": "Lisa uus ajakava",
|
||||
"add_new_calendar": "Lisa uus kalender",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Teie meeskonnaliikmed ei näe teisi meeskonnaliikmeid, kui see on sisse lülitatud.",
|
||||
"you_cannot_see_team_members": "Te ei saa näha erameeskonna kõiki meeskonnaliikmeid.",
|
||||
"you_cannot_see_teams_of_org": "Te ei saa näha eraorganisatsiooni meeskondi.",
|
||||
"allow_booker_to_select_duration": "Luba broneerijal valida kestus",
|
||||
"allow_multiple_durations": "Luba mitu kestust",
|
||||
"impersonate_user_tip": "Kõik selle funktsiooni kasutused on auditeeritud.",
|
||||
"impersonating_user_warning": "Esitan end kasutajanimena \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Peatamiseks klõpsake siin",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Kas soovite kindlasti konfiguratsiooni {{connectionType}} kustutada? Teie meeskonnaliikmed, kes kasutavad {{connectionType}} sisselogimist, ei pääse enam saidile Cal.com juurde.",
|
||||
"organizer_timezone": "Korraldaja ajavöönd",
|
||||
"email_user_cta": "Vaata kutset",
|
||||
"accept_invite": "Võta kutse vastu",
|
||||
"join_now": "Liitu kohe",
|
||||
"successfully_joined": "Liitumine õnnestus",
|
||||
"email_no_user_invite_heading_subteam": "Teid on kutsutud liituma {{parentTeamName}} organisatsiooni meeskonnaga.",
|
||||
"email_no_user_invite_heading_org": "Teid on kutsutud liituma {{appName}} organisatsiooniga.",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} kutsus teid liituma oma meeskonnaga rakenduses {{appName}}. {{appName}} on sündmustega žongleerimise ajakava, mis võimaldab teil ja teie meeskonnal koosolekuid ajastada ilma meilitenniseta.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Broneeringu kinnitamine ebaõnnestus",
|
||||
"not_enough_seats": "Pole piisavalt kohti",
|
||||
"form_builder_field_already_exists": "Selle nimega väli on juba olemas",
|
||||
"guests_field_must_be_multiemail": "Külaliste väli peab olema tüüpi 'Mitu e-posti'",
|
||||
"show_on_booking_page": "Näita broneerimislehel",
|
||||
"visit_cancelled_booking": "Saate külastada tühistatud broneeringu lehte",
|
||||
"get_started_zapier_templates": "Alustage Zapieri mallidega",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Kutse link kopeeritud",
|
||||
"invite_link_deleted": "Kutse link kustutatud",
|
||||
"api_key_deleted": "API võti kustutatud",
|
||||
"delete_api_key_warning": "See kustutab API-võtme jäädavalt. Kõik rakendused, mis seda võtit kasutavad, kaotavad kohe juurdepääsu teie kontole. Seda toimingut ei saa tagasi võtta.",
|
||||
"invite_link_updated": "Kutse lingi seaded on salvestatud",
|
||||
"link_expires_after": "Lingid aeguma pärast...",
|
||||
"one_day": "1 päev",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Jätkates nõustute meie",
|
||||
"terms": "Tingimustega",
|
||||
"always_show_x_days": "Alati {{x}} päeva saadaval",
|
||||
"always_show_x_days_description": "Liigutab automaatselt {{x}}-päevase broneerimisakna iga päev edasi, tagades, et teie kalender näitab alati järgmise {{x}} päeva saadavalolevaid aegu.",
|
||||
"team_subscription_cancelled_successfully": "Meeskonna tellimus tühistati edukalt",
|
||||
"team_subscription_cancellation_error": "Viga meeskonna tellimuse tühistamisel",
|
||||
"unable_to_subscribe_to_the_platform": "Platvormipaketi tellimisel ilmnes viga, proovige hiljem uuesti",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Lõpeb",
|
||||
"filter_operator_is_empty": "On tühi",
|
||||
"filter_operator_not_empty": "Ei ole tühi",
|
||||
"translate_description_button": "Tõlgi pealkiri/kirjeldus tehisintellekti abil külastaja brauseri keelde",
|
||||
"translate_description_button": "Tõlgi pealkiri ja kirjeldus automaatselt",
|
||||
"translate_description_button_description": "Tõlgib pealkirjad ja kirjeldused automaatselt külastaja brauseri keelde, kasutades tehisintellekti.",
|
||||
"rr_distribution_method": "Jaotamine",
|
||||
"rr_distribution_method_description": "Võimaldab optimeerida jaotust maksimaalseks kättesaadavuseks või tasakaalustatumaks määramiseks.",
|
||||
"rr_distribution_method_availability_title": "Maksimeeri kättesaadavus",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Kohtade funktsioon ei toeta korduvat sündmust. Keelake kohtade funktsioon, et muuta see korduvaks.",
|
||||
"recurring_event_seats_error": "Korduv sündmus ei toeta kohtade funktsiooni. Keelake kohtade funktsioon või muutke sündmus mittekorduvaks.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Salvestatud filtrid",
|
||||
"rename": "Nimeta ümber",
|
||||
"rename_segment": "Nimeta segment ümber",
|
||||
"delete_segment": "Kustuta segment",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Valikloend",
|
||||
"most_cancelled_bookings": "Kõige rohkem tühistatud broneeringuid",
|
||||
"interface_language": "Liidese keel",
|
||||
"interface_language_description": "Määra broneerimisliidese eelistatud keel",
|
||||
"visitors_browser_language": "Külastaja brauseri keel",
|
||||
"name_or_email": "Nimi või e-post",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Kui kontakti ei leita, kasuta võimalusel liidi omanikku",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Halda oma organisatsiooni rolle ja õigusi",
|
||||
"learn_more_permissions": "Lisateave õiguste toimimise kohta",
|
||||
"role_name": "Rolli nimi",
|
||||
"role_name_required": "Rolli nimi on kohustuslik",
|
||||
"role_name_placeholder": "nt Müügimeeskond, Tugipersonal",
|
||||
"role_description": "Rolli kirjeldus",
|
||||
"role_description_placeholder": "Kirjeldage, milleks see roll on ja milliseid õigusi see vajab",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Roll loodud edukalt",
|
||||
"role_updated_successfully": "Roll uuendatud edukalt",
|
||||
"delete_role": "Kustuta roll",
|
||||
"confirm_delete_role": "Kas olete kindel, et soovite selle rolli kustutada? Seda toimingut ei saa tagasi võtta.",
|
||||
"confirm_delete_role": "Kas olete kindel, et soovite rolli kustutada: {{roleName}}? Seda toimingut ei saa tagasi võtta.",
|
||||
"role_deleted_successfully": "Roll kustutatud edukalt",
|
||||
"error_creating_role": "Viga rolli loomisel",
|
||||
"error_updating_role": "Viga rolli uuendamisel",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Muuda ja redigeeri töövoo seadeid",
|
||||
"pbac_desc_delete_workflows": "Eemalda töövood süsteemist",
|
||||
"pbac_resource_webhook": "Veebihaak",
|
||||
"pbac_resource_availability": "Saadavus",
|
||||
"pbac_resource_out_of_office": "Kontorist väljas",
|
||||
"pbac_desc_create_webhooks": "Loo veebihaake",
|
||||
"pbac_desc_view_webhooks": "Vaata veebihaake",
|
||||
"pbac_desc_update_webhooks": "Uuenda veebihaake",
|
||||
"pbac_desc_delete_webhooks": "Kustuta veebihaake",
|
||||
"pbac_desc_create_availability": "Loo saadavus",
|
||||
"pbac_desc_view_availability": "Vaata saadavust",
|
||||
"pbac_desc_update_availability": "Uuenda saadavust",
|
||||
"pbac_desc_delete_availability": "Kustuta saadavus",
|
||||
"pbac_desc_create_out_of_office": "Loo kontorist väljas olek",
|
||||
"pbac_desc_view_out_of_office": "Vaata kontorist väljas olekut",
|
||||
"pbac_desc_update_out_of_office": "Uuenda kontorist väljas olekut",
|
||||
"pbac_desc_delete_out_of_office": "Kustuta kontorist väljas olek",
|
||||
"pbac_desc_manage_workflows": "Täielik haldusõigus kõikidele töövoogudele",
|
||||
"pbac_desc_create_event_types": "Loo sündmuse tüüpe",
|
||||
"pbac_desc_view_event_types": "Vaata sündmuse tüüpe",
|
||||
@@ -3560,7 +3583,7 @@
|
||||
"rr_load_balancing_disabled_with_groups": "Koormuse tasakaalustamine ei ole saadaval Round Robin gruppidega",
|
||||
"round_robin_settings_description": "Kohanda selle meeskonna vaikimisi round robin'i seadeid",
|
||||
"round_robin_settings_updated_successfully": "Round robin'i seaded on edukalt uuendatud",
|
||||
"booking_creation_time": "Broneeringu loomise aeg",
|
||||
"booking_creation_time": "Broneerimise loomise aeg",
|
||||
"meeting_start_time": "Kohtumise algusaeg",
|
||||
"members_affected_by_disabling_delegation_credential": "Mõjutatud liikmed",
|
||||
"no_members_affected_by_disabling_delegation_credential": "Delegatsiooni mandaadi keelamine ei mõjuta ühtegi liiget",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Korraldaja e-post",
|
||||
"webhook_organizer_timezone": "Korraldaja ajavöönd (nt 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Korraldaja lokaad (nt 'en', 'fr')",
|
||||
"webhook_organizer_username": "Korraldaja kasutajanimi (nt 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Korraldaja kasutajanimi nende organisatsioonis (nt 'john.doe')",
|
||||
"webhook_attendee_name": "Esimese osaleja nimi",
|
||||
"webhook_attendee_email": "Esimese osaleja e-post",
|
||||
"webhook_attendee_timezone": "Esimese osaleja ajavöönd",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Kasuta häält",
|
||||
"current_voice": "Praegune hääl",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"no_available_users_found_error": "Ez da erabiltzaile eskuragarririk aurkitu. Beste denbora tarte bat probatu nahi duzu?",
|
||||
"timeslot_unavailable_book_a_new_time": "Hautatutako denbora-tartea ez dago jada eskuragarri. <0>Mesedez, hautatu beste ordu bat</0>",
|
||||
"timeslot_unavailable_short": "Hartuta",
|
||||
"just_connected_description": "Konektatu berri zara. Mesedez, erreserbatu goiko ordutegietatik edo saiatu berriro geroago.",
|
||||
"unavailable_timeslot_title": "Denbora-tarte ez erabilgarria",
|
||||
"booking_time_out_of_bounds_error": "Ezin da gertaera mota hau momentu honetan erreserbatu. Beste denbora-tarte bat probatuko zenuke?",
|
||||
"request_body_end_time_internal_error": "Barne errorea. Eskaeraren gorputzak ez du amaiera ordua",
|
||||
@@ -321,6 +322,7 @@
|
||||
"secret_meeting": "Bilera sekretua",
|
||||
"login_instead": "Saioa hasi ordez",
|
||||
"create_account": "Sortu kontua",
|
||||
"get_started": "Hasi",
|
||||
"confirm_password": "Baieztatu pasahitza",
|
||||
"reset_your_password": "Ezarri zure pasahitz berria zure email helbidera bidalitako argibideak jarraituz.",
|
||||
"org_banner_instructions": "Mesedez, igo {{width}} zabalera eta {{height}} altuera duen irudia.",
|
||||
@@ -1019,6 +1021,7 @@
|
||||
"attendees": "Partaideak",
|
||||
"add_attendees": "Gehitu partaideak",
|
||||
"show_advanced_settings": "Erakutsi ezarpen aurreratuak",
|
||||
"hide_advanced_settings": "Ezkutatu ezarpen aurreratuak",
|
||||
"event_name": "Gertaeraren izena",
|
||||
"event_name_in_calendar": "Gertaeraren izenarekin",
|
||||
"event_name_tooltip": "Egutegian agertuko den izena",
|
||||
@@ -1231,7 +1234,7 @@
|
||||
"contact_sales": "Jarri harremanetan salmenta taldearekin",
|
||||
"error_404": "404 errorea",
|
||||
"default": "Lehenetsitakoa",
|
||||
"set_to_default": "Ezarri lehenetsitako gisa",
|
||||
"set_to_default": "Ezarri lehenetsi gisa",
|
||||
"new_schedule_btn": "Programazio berria",
|
||||
"add_new_schedule": "Gehitu programazio berria",
|
||||
"add_new_calendar": "Gehitu egutegi berria",
|
||||
@@ -1329,7 +1332,7 @@
|
||||
"make_team_private_description": "Hau aktibatuta dagoenean, zure taldeko kideek ezin izango dituzte beste taldekideak ikusi.",
|
||||
"you_cannot_see_team_members": "Ezin dituzu talde pribatu bateko kide guztiak ikusi.",
|
||||
"you_cannot_see_teams_of_org": "Ezin dituzu erakunde pribatu bateko taldeak ikusi.",
|
||||
"allow_booker_to_select_duration": "Erreserbatzaileari iraupena aukeratzen utzi",
|
||||
"allow_multiple_durations": "Baimendu iraupen anitzak",
|
||||
"impersonate_user_tip": "Ezaugarri honen erabilera guztiak auditatzen dira.",
|
||||
"impersonating_user_warning": "\"{{user}}\" erabiltzailea ordezkatzen.",
|
||||
"impersonating_stop_instructions": "Klikatu hemen gelditzeko",
|
||||
@@ -2150,6 +2153,9 @@
|
||||
"delete_sso_configuration_confirmation_description": "Ziur zaude {{connectionType}} konfigurazioa ezabatu nahi duzula? {{connectionType}} saioa hasten duten zure taldekideek ezingo dute Cal.com erabili.",
|
||||
"organizer_timezone": "Antolatzailearen ordu-zona",
|
||||
"email_user_cta": "Ikusi gonbidapena",
|
||||
"accept_invite": "Onartu gonbidapena",
|
||||
"join_now": "Batu orain",
|
||||
"successfully_joined": "Arrakastaz batu zara",
|
||||
"email_no_user_invite_heading_subteam": "{{parentTeamName}} erakundeko talde batera batzeko gonbidapena jaso duzu",
|
||||
"email_no_user_invite_heading_org": "{{appName}} erakunde batera batzeko gonbidapena jaso duzu",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}}-k bere taldera gonbidatu zaitu {{appName}}-en. {{appName}} bileren antolaketa errazten duen tresna da, zure taldeak bilerak antolatzeko aukera ematen duena posta elektroniko truke etengabea ekidinez.",
|
||||
@@ -2202,6 +2208,7 @@
|
||||
"booking_confirmation_failed": "Erreserba berrespena huts egin du",
|
||||
"not_enough_seats": "Ez dago nahikoa eserleku",
|
||||
"form_builder_field_already_exists": "Izen hori duen eremu bat existitzen da dagoeneko",
|
||||
"guests_field_must_be_multiemail": "Gonbidatuen eremuak 'Helbide elektroniko anitzak' motakoa izan behar du",
|
||||
"show_on_booking_page": "Erakutsi erreserba orrian",
|
||||
"visit_cancelled_booking": "Bertan behera utzitako erreserba orria bisitatu dezakezu",
|
||||
"get_started_zapier_templates": "Hasi Zapier txantiloiekin",
|
||||
@@ -2379,6 +2386,7 @@
|
||||
"invite_link_copied": "Gonbidapen-esteka kopiatuta",
|
||||
"invite_link_deleted": "Gonbidapen-esteka ezabatuta",
|
||||
"api_key_deleted": "API gakoa ezabatuta",
|
||||
"delete_api_key_warning": "Honek API gakoa betiko ezabatuko du. Gako hau erabiltzen duten aplikazio guztiek zure konturako sarbidea galduko dute berehala. Ekintza hau ezin da desegin.",
|
||||
"invite_link_updated": "Gonbidapen-estekaren ezarpenak gordeta",
|
||||
"link_expires_after": "Estekak iraungitzeko denbora...",
|
||||
"one_day": "Egun 1",
|
||||
@@ -2922,6 +2930,7 @@
|
||||
"proceeding_agreement": "Jarraituz gero, gure honekin ados zaudela adierazten duzu",
|
||||
"terms": "Baldintzak",
|
||||
"always_show_x_days": "Beti {{x}} egun erabilgarri",
|
||||
"always_show_x_days_description": "Automatikoki mugitzen du {{x}} eguneko erreserba-leihoa egunero, zure egutegiak beti hurrengo {{x}} egunetako txanda erabilgarriak erakusten dituela ziurtatuz.",
|
||||
"team_subscription_cancelled_successfully": "Taldearen harpidetza behar bezala ezeztatu da",
|
||||
"team_subscription_cancellation_error": "Errorea gertatu da taldearen harpidetza ezeztatzean",
|
||||
"unable_to_subscribe_to_the_platform": "Errore bat gertatu da plataformaren planera harpidetzen saiatzean, saiatu berriro geroago",
|
||||
@@ -3143,7 +3152,8 @@
|
||||
"filter_operator_ends_with": "Honekin amaitzen da",
|
||||
"filter_operator_is_empty": "Hutsik dago",
|
||||
"filter_operator_not_empty": "Ez dago hutsik",
|
||||
"translate_description_button": "Bisitariaren nabigatzailearen hizkuntzara itzuli titulua/deskribapena AI erabiliz",
|
||||
"translate_description_button": "Auto itzuli izenburua eta deskribapena",
|
||||
"translate_description_button_description": "Automatikoki itzuli izenburuak eta deskribapenak bisitariaren nabigatzailearen hizkuntzara adimen artifizialaren bidez.",
|
||||
"rr_distribution_method": "Banaketa",
|
||||
"rr_distribution_method_description": "Banaketa optimizatzeko aukera ematen du, erabilgarritasun maximoa edo esleipen orekatuagoa lortzeko",
|
||||
"rr_distribution_method_availability_title": "Erabilgarritasuna maximizatu",
|
||||
@@ -3309,6 +3319,7 @@
|
||||
"seats_doesnt_support_recurring": "Eserlekuen ezaugarriak ez du ekitaldi errepikakorrik onartzen. Desgaitu eserlekuen ezaugarria errepikakor egiteko.",
|
||||
"recurring_event_seats_error": "Ekitaldi errepikakorrak ez du eserlekuen ezaugarria onartzen. Desgaitu eserlekuen ezaugarria edo egin ekitaldia ez-errepikakor.",
|
||||
"segment": "Segmentua",
|
||||
"saved_filters": "Gordetako iragazkiak",
|
||||
"rename": "Berrizendatu",
|
||||
"rename_segment": "Berrizendatu segmentua",
|
||||
"delete_segment": "Ezabatu segmentua",
|
||||
@@ -3338,6 +3349,7 @@
|
||||
"picklist": "Hautaketa-zerrenda",
|
||||
"most_cancelled_bookings": "Bertan behera utzitako erreserba gehien",
|
||||
"interface_language": "Interfazearen hizkuntza",
|
||||
"interface_language_description": "Ezarri zure hizkuntza hobetsia erreserba interfazerako",
|
||||
"visitors_browser_language": "Bisitariaren nabigatzailearen hizkuntza",
|
||||
"name_or_email": "Izena edo helbide elektronikoa",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Kontakturik aurkitzen ez bada, itzuli bezeroen arduradunera existitzen bada",
|
||||
@@ -3452,6 +3464,7 @@
|
||||
"roles_and_permissions_description": "Kudeatu zure erakundearen rolak eta baimenak",
|
||||
"learn_more_permissions": "Ikasi gehiago baimenek nola funtzionatzen duten",
|
||||
"role_name": "Rolaren izena",
|
||||
"role_name_required": "Rolaren izena beharrezkoa da",
|
||||
"role_name_placeholder": "adib. Salmenta taldea, Laguntza langileak",
|
||||
"role_description": "Rolaren deskribapena",
|
||||
"role_description_placeholder": "Deskribatu rol hau zertarako den eta zer baimen behar dituen",
|
||||
@@ -3490,7 +3503,7 @@
|
||||
"role_created_successfully": "Rola arrakastaz sortu da",
|
||||
"role_updated_successfully": "Rola arrakastaz eguneratu da",
|
||||
"delete_role": "Ezabatu rola",
|
||||
"confirm_delete_role": "Ziur zaude rol hau ezabatu nahi duzula? Ekintza hau ezin da desegin.",
|
||||
"confirm_delete_role": "Ziur zaude {{roleName}} rola ezabatu nahi duzula? Ekintza hau ezin da desegin.",
|
||||
"role_deleted_successfully": "Rola arrakastaz ezabatu da",
|
||||
"error_creating_role": "Errorea rola sortzerakoan",
|
||||
"error_updating_role": "Errorea rola eguneratzerakoan",
|
||||
@@ -3509,10 +3522,20 @@
|
||||
"pbac_desc_update_workflows": "Editatu eta aldatu lan-fluxuen ezarpenak",
|
||||
"pbac_desc_delete_workflows": "Kendu lan-fluxuak sistematik",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Erabilgarritasuna",
|
||||
"pbac_resource_out_of_office": "Bulegotik kanpo",
|
||||
"pbac_desc_create_webhooks": "Sortu webhook-ak",
|
||||
"pbac_desc_view_webhooks": "Ikusi webhook-ak",
|
||||
"pbac_desc_update_webhooks": "Webhookak eguneratu",
|
||||
"pbac_desc_delete_webhooks": "Webhookak ezabatu",
|
||||
"pbac_desc_create_availability": "Sortu erabilgarritasuna",
|
||||
"pbac_desc_view_availability": "Ikusi erabilgarritasuna",
|
||||
"pbac_desc_update_availability": "Eguneratu erabilgarritasuna",
|
||||
"pbac_desc_delete_availability": "Ezabatu erabilgarritasuna",
|
||||
"pbac_desc_create_out_of_office": "Sortu bulegotik kanpo",
|
||||
"pbac_desc_view_out_of_office": "Ikusi bulegotik kanpo",
|
||||
"pbac_desc_update_out_of_office": "Eguneratu bulegotik kanpo",
|
||||
"pbac_desc_delete_out_of_office": "Ezabatu bulegotik kanpo",
|
||||
"pbac_desc_manage_workflows": "Lan-fluxu guztien kudeaketa-sarbide osoa",
|
||||
"pbac_desc_create_event_types": "Gertaera motak sortu",
|
||||
"pbac_desc_view_event_types": "Gertaera motak ikusi",
|
||||
@@ -3560,8 +3583,8 @@
|
||||
"rr_load_balancing_disabled_with_groups": "Karga-orekatzea ez dago eskuragarri Round Robin taldeekin",
|
||||
"round_robin_settings_description": "Pertsonalizatu talde honentzako txandakako ezarpen lehenetsiak",
|
||||
"round_robin_settings_updated_successfully": "Txandakako ezarpenak ondo eguneratu dira",
|
||||
"booking_creation_time": "Erreserba sortze-ordua",
|
||||
"meeting_start_time": "Bilera hasiera-ordua",
|
||||
"booking_creation_time": "Erreserba sortzeko ordua",
|
||||
"meeting_start_time": "Bilera hasteko ordua",
|
||||
"members_affected_by_disabling_delegation_credential": "Eragindako kideak",
|
||||
"no_members_affected_by_disabling_delegation_credential": "Ez dago kiderik delegazio kredentzialak desgaitzeak eraginda",
|
||||
"download_expense_log": "Deskargatu gastuen erregistroa",
|
||||
@@ -3654,6 +3677,8 @@
|
||||
"webhook_organizer_email": "Antolatzailearen helbide elektronikoa",
|
||||
"webhook_organizer_timezone": "Antolatzailearen ordu-zona (adib., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Antolatzailearen lokala (adib., 'en', 'fr')",
|
||||
"webhook_organizer_username": "Antolatzailearen erabiltzaile-izena (adib., 'john.doe')",
|
||||
"webhook_organizer_username_in_org": "Antolatzailearen erabiltzaile-izena bere erakundean (adib., 'john.doe')",
|
||||
"webhook_attendee_name": "Lehen parte-hartzailearen izena",
|
||||
"webhook_attendee_email": "Lehen parte-hartzailearen helbide elektronikoa",
|
||||
"webhook_attendee_timezone": "Lehen parte-hartzailearen ordu-zona",
|
||||
@@ -3732,4 +3757,4 @@
|
||||
"use_voice": "Erabili ahotsa",
|
||||
"current_voice": "Uneko ahotsa",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Gehitu zure kate berriak honen gainean ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user