Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1377f1a214 | ||
|
|
e8dfa50119 | ||
|
|
706e8e4c1f | ||
|
|
a95729f4a1 | ||
|
|
4b6a389212 | ||
|
|
9d3be8de03 | ||
|
|
b283f1f8cd | ||
|
|
5ff52e1239 | ||
|
|
2b1cb9f878 | ||
|
|
74a46e8ba3 | ||
|
|
5b5f7c1c48 | ||
|
|
9375efffe3 | ||
|
|
da59c9de12 | ||
|
|
2a734faf45 | ||
|
|
934cefecce | ||
|
|
c88811aeee | ||
|
|
f46c3c65fb | ||
|
|
1d203dc566 | ||
|
|
26a1c749f7 | ||
|
|
ad347cb59f | ||
|
|
ac300ff670 | ||
|
|
c834b927b6 | ||
|
|
34ba27407b | ||
|
|
8431cd2ef8 | ||
|
|
cd09a56491 | ||
|
|
5c05a422a8 | ||
|
|
e805c0f00a | ||
|
|
2fa7f7931d | ||
|
|
20729b3a4e | ||
|
|
4d9777af8b | ||
|
|
1534588b34 | ||
|
|
7183ec7f1b | ||
|
|
d836497d56 | ||
|
|
38fa0fae1d | ||
|
|
2872b263fd | ||
|
|
92e53adc8e | ||
|
|
72cc4ab13e | ||
|
|
4e82ffe2ad | ||
|
|
90f6c7e239 | ||
|
|
ffa58d1cdb | ||
|
|
f834e93652 | ||
|
|
ae7fae7da4 | ||
|
|
b6c6e41603 | ||
|
|
e4dc2d0877 | ||
|
|
e43690f190 | ||
|
|
5bbccad41d | ||
|
|
bc53037f37 | ||
|
|
b4a1a8e7bf | ||
|
|
ac43d90d53 | ||
|
|
2b24b4472d | ||
|
|
715cbc1c38 |
+1
-1
@@ -334,8 +334,8 @@ AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_SECURITY_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
*/
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
|
||||
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.parse({
|
||||
...req.query,
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
|
||||
@@ -26,4 +26,5 @@ WEB_APP_URL=http://localhost:3000/
|
||||
CALCOM_LICENSE_KEY=
|
||||
API_KEY_PREFIX=cal_
|
||||
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||
IS_E2E=false
|
||||
IS_E2E=false
|
||||
DOCS_URL=
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.40",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.44",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
@@ -43,7 +44,7 @@
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/throttler": "6.2.1",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
@@ -57,10 +58,10 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"luxon": "^3.4.4",
|
||||
"nest-winston": "^1.9.4",
|
||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"qs-stringify": "^1.2.1",
|
||||
"querystring": "^0.2.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
+373
-14
@@ -1,26 +1,385 @@
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
describe("AppController", () => {
|
||||
describe("Rate limiting", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
|
||||
const userEmail = "app-rate-limits-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
let organization: Team;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer()).get("/health").expect("OK");
|
||||
});
|
||||
let apiKeyString: string;
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
let rateLimit: RateLimit;
|
||||
let apiKeyStringWithRateLimit: string;
|
||||
|
||||
let apiKeyStringWithMultipleLimits: string;
|
||||
let firstRateLimitWithMultipleLimits: RateLimit;
|
||||
let secondRateLimitWithMultipleLimits: RateLimit;
|
||||
|
||||
const mockDefaultLimit = 5;
|
||||
const mockDefaultTtl = 2500;
|
||||
const mockDefaultBlockDuration = 5000;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
}).compile();
|
||||
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
|
||||
jest
|
||||
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
|
||||
.mockReturnValue(mockDefaultBlockDuration);
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
|
||||
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
|
||||
user.id,
|
||||
null
|
||||
);
|
||||
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
|
||||
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
|
||||
|
||||
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
|
||||
await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
|
||||
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"short",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
1000,
|
||||
2,
|
||||
2000
|
||||
);
|
||||
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"long",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
2000,
|
||||
3,
|
||||
4000
|
||||
);
|
||||
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: userEmail,
|
||||
user: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 1023,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it(
|
||||
"api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with custom rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = rateLimit.limit;
|
||||
const blockDuration = rateLimit.blockDuration;
|
||||
const name = rateLimit.name;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
|
||||
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with multiple rate limits - should enforce both short and long rate limits",
|
||||
async () => {
|
||||
const shortLimit = firstRateLimitWithMultipleLimits.limit;
|
||||
const longLimit = secondRateLimitWithMultipleLimits.limit;
|
||||
const shortName = firstRateLimitWithMultipleLimits.name;
|
||||
const longName = secondRateLimitWithMultipleLimits.name;
|
||||
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
|
||||
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
|
||||
|
||||
let requestsMade = 0;
|
||||
// note(Lauris): exhaust short limit to have remaining 0 for it
|
||||
for (let i = 1; i <= shortLimit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
|
||||
for (let i = requestsMade; i < longLimit; i++) {
|
||||
const responseAfterShortLimit = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): both have remaining 0 so now exceed both
|
||||
const blockedResponseLong = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
|
||||
firstRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
|
||||
secondRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
// note(Lauris): wait for short limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, shortBlock));
|
||||
const responseAfterShortLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// note(Lauris): wait for long limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, longBlock));
|
||||
const responseAfterLongLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - 1).toString()
|
||||
);
|
||||
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
},
|
||||
30 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"non api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userEmail);
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import appConfig from "@/config/app";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||
@@ -12,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@@ -33,16 +34,19 @@ import { AppController } from "./app.controller";
|
||||
BullModule.forRoot({
|
||||
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||
}),
|
||||
// Rate limiting here is handled by the CustomThrottlerGuard
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
|
||||
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
|
||||
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
|
||||
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
|
||||
throttlers: [
|
||||
{
|
||||
name: "long",
|
||||
ttl: seconds(60), // Time to live for the long period in seconds
|
||||
limit: 120, // Maximum number of requests within the long ttl
|
||||
name: "dummy",
|
||||
ttl: seconds(60),
|
||||
limit: 120,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
@@ -55,6 +59,13 @@ import { AppController } from "./app.controller";
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: ThrottlerStorageRedisService,
|
||||
useFactory: (redisService: RedisService) => {
|
||||
return new ThrottlerStorageRedisService(redisService.redis);
|
||||
},
|
||||
inject: [RedisService],
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
@@ -79,6 +90,8 @@ export class AppModule implements NestModule {
|
||||
.forRoutes("*")
|
||||
.apply(AppLoggerMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RedirectsMiddleware)
|
||||
.forRoutes("/")
|
||||
.apply(RewriterMiddleware)
|
||||
.forRoutes("/");
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export class CalendarsController {
|
||||
|
||||
@Post("/ics-feed/save")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Save an ICS feed" })
|
||||
async createIcsFeed(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser("email") userEmail: string,
|
||||
@@ -78,6 +79,7 @@ export class CalendarsController {
|
||||
|
||||
@Get("/ics-feed/check")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Check an ICS feed" })
|
||||
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
|
||||
return await this.icsFeedService.check(userId);
|
||||
}
|
||||
|
||||
+8
@@ -260,6 +260,7 @@ describe("Event types Endpoints", () => {
|
||||
},
|
||||
requiresBookerEmailVerification: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -297,6 +298,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
|
||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -351,6 +353,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -667,6 +670,7 @@ describe("Event types Endpoints", () => {
|
||||
},
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -706,6 +710,7 @@ describe("Event types Endpoints", () => {
|
||||
body.requiresBookerEmailVerification
|
||||
);
|
||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -724,6 +729,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.customName = updatedEventType.customName;
|
||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
});
|
||||
@@ -774,6 +780,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -811,6 +818,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
@@ -69,21 +66,6 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(
|
||||
user: UserWithProfile,
|
||||
isUserOrganizationAdmin: boolean,
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
|
||||
@@ -142,27 +142,6 @@ export class EventTypesService_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
|
||||
user,
|
||||
isUserOrganizationAdmin,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
|
||||
+4
@@ -242,6 +242,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
|
||||
}
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
if (!recurrence || recurrence.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transformRecurrenceApiToInternal(recurrence);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -80,6 +80,7 @@ type Input = Pick<
|
||||
| "eventName"
|
||||
| "destinationCalendar"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -111,6 +112,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
hideCalendarNotes,
|
||||
seatsShowAttendees,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -182,6 +184,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
customName,
|
||||
destinationCalendar,
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-8
@@ -23,8 +23,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -57,11 +56,6 @@ export class SchedulesController_2024_04_15 {
|
||||
|
||||
@Get("/default")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_04_15,
|
||||
})
|
||||
async getDefaultSchedule(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
@@ -78,7 +72,6 @@ export class SchedulesController_2024_04_15 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
+17
-12
@@ -18,7 +18,6 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -57,17 +56,19 @@ export class SchedulesController_2024_06_11 {
|
||||
@ApiOperation({
|
||||
summary: "Create a schedule",
|
||||
description: `
|
||||
Create a schedule for the authenticated user.
|
||||
|
||||
The point of creating schedules is for event types to be available at specific times.
|
||||
|
||||
First goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
|
||||
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. Managed user can then change the default schedule via \`AvailabilitySettings\` atom.
|
||||
2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying
|
||||
\`"isDefault": true\` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.
|
||||
The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
|
||||
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the \`AvailabilitySettings\` atom.
|
||||
2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying
|
||||
\`"isDefault": true\` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.
|
||||
|
||||
Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.
|
||||
After creating a non default schedule you can update event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||
The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.
|
||||
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||
|
||||
When specifying start time and end time for each day use 24 hour format e.g. 08:00, 15:00 etc.
|
||||
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
|
||||
`,
|
||||
})
|
||||
async createSchedule(
|
||||
@@ -86,10 +87,12 @@ export class SchedulesController_2024_06_11 {
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule of the authenticated user",
|
||||
type: GetDefaultScheduleOutput_2024_06_11,
|
||||
})
|
||||
@ApiOperation({ summary: "Get default schedule (of the authenticated user)" })
|
||||
@ApiOperation({
|
||||
summary: "Get default schedule",
|
||||
description: "Get the default schedule of the authenticated user.",
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
|
||||
@@ -101,7 +104,6 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
@ApiOperation({ summary: "Get a schedule" })
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@@ -117,7 +119,10 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({ summary: "Get all schedules (of the authenticated user)" })
|
||||
@ApiOperation({
|
||||
summary: "Get all schedules",
|
||||
description: "Get all schedules of the authenticated user.",
|
||||
})
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export type Environment = {
|
||||
CALCOM_LICENSE_KEY: string;
|
||||
GET_LICENSE_KEY_URL: string;
|
||||
API_KEY_PREFIX: string;
|
||||
DOCS_URL: string;
|
||||
RATE_LIMIT_DEFAULT_TTL_MS: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT: number;
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -1,42 +1,191 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
import {
|
||||
ThrottlerGuard,
|
||||
ThrottlerException,
|
||||
ThrottlerRequest,
|
||||
ThrottlerModuleOptions,
|
||||
seconds,
|
||||
} from "@nestjs/throttler";
|
||||
import { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
|
||||
const rateLimitSchema = z.object({
|
||||
name: z.string(),
|
||||
limit: z.number(),
|
||||
ttl: z.number(),
|
||||
blockDuration: z.number(),
|
||||
});
|
||||
|
||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
|
||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
private logger = new Logger("CustomThrottlerGuard");
|
||||
|
||||
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", 60 * 1000));
|
||||
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
|
||||
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", 60 * 1000));
|
||||
|
||||
constructor(
|
||||
options: ThrottlerModuleOptions,
|
||||
storageService: ThrottlerStorage,
|
||||
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
|
||||
reflector: Reflector,
|
||||
private readonly config: ConfigService
|
||||
private readonly dbRead: PrismaReadService
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||
const { context } = requestProps;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.handleApiKeyRequest(tracker, response);
|
||||
} else {
|
||||
return this.handleNonApiKeyRequest(tracker, response);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||
|
||||
let allLimitsBlocked = true;
|
||||
for (const rateLimit of rateLimits) {
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (!isBlocked) {
|
||||
allLimitsBlocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allLimitsBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit();
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getDefaultRateLimit() {
|
||||
return {
|
||||
name: "default",
|
||||
limit: this.getDefaultLimit(),
|
||||
ttl: this.getDefaultTtl(),
|
||||
blockDuration: this.getDefaultBlockDuration(),
|
||||
};
|
||||
}
|
||||
|
||||
getDefaultLimit() {
|
||||
return this.defaultLimit;
|
||||
}
|
||||
|
||||
getDefaultTtl() {
|
||||
return this.defaultTttl;
|
||||
}
|
||||
|
||||
getDefaultBlockDuration() {
|
||||
return this.defaultBlockDuration;
|
||||
}
|
||||
|
||||
private async getRateLimitsForApiKeyTracker(tracker: string) {
|
||||
const cacheKey = `rate_limit:${tracker}`;
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
const apiKey = tracker.replace("api_key_", "");
|
||||
let rateLimits: RateLimitType[];
|
||||
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
|
||||
where: { hashedKey: apiKey },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
throw new UnauthorizedException("Invalid API Key");
|
||||
}
|
||||
|
||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||
where: { apiKeyId: apiKeyRecord.id },
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit()];
|
||||
}
|
||||
|
||||
await this.storageService.redis.setex(cacheKey, 3600, JSON.stringify(rateLimits));
|
||||
|
||||
return rateLimits;
|
||||
}
|
||||
|
||||
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
|
||||
const { name, limit, ttl, blockDuration } = rateLimit;
|
||||
|
||||
const key = `${tracker}:${limit}:${ttl}`;
|
||||
|
||||
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
|
||||
key,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
name
|
||||
);
|
||||
|
||||
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
|
||||
response.setHeader(
|
||||
`X-RateLimit-Remaining-${nameFirstUpper}`,
|
||||
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.log(`Rate limit for ${tracker} incremented. Remaining: ${limit - totalHits}`);
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (authorizationHeader) {
|
||||
return isApiKey(authorizationHeader, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? `api_key_${authorizationHeader}`
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||
: `access_token_${authorizationHeader}`;
|
||||
}
|
||||
|
||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (oauthClientId) {
|
||||
return oauthClientId;
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return request.ip;
|
||||
return `ip_${request.ip}`;
|
||||
}
|
||||
|
||||
this.logger.log(`no tracker found: ${request.url}`);
|
||||
|
||||
@@ -114,11 +114,14 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error: Error) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class RedirectsMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: () => void) {
|
||||
if (process.env.DOCS_URL && (req.url.startsWith("/v2/docs") || req.url.startsWith("/docs"))) {
|
||||
return res.redirect(process.env.DOCS_URL);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { App } from "@prisma/client";
|
||||
import { App, Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AppsRepository {
|
||||
@@ -10,4 +10,15 @@ export class AppsRepository {
|
||||
async getAppBySlug(slug: string): Promise<App | null> {
|
||||
return await this.dbRead.prisma.app.findUnique({ where: { slug } });
|
||||
}
|
||||
|
||||
async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
type: type,
|
||||
key: key,
|
||||
userId: userId,
|
||||
appId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14],
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
MembershipsRepository,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
})
|
||||
export class AtomsModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
these endpoints should not be recommended for use by third party and are excluded from docs
|
||||
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsController {
|
||||
constructor(private readonly eventTypesService: EventTypesAtomService) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
updateEventType,
|
||||
TUpdateEventTypeInputSchema,
|
||||
systemBeforeFieldEmail,
|
||||
getEventTypeById,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesAtomService {
|
||||
constructor(
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14
|
||||
) {}
|
||||
|
||||
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, { id: eventTypeId, userId: user.id });
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
|
||||
this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
const eventType = await updateEventType({
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
createMock<OAuthClientRepository>()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
|
||||
it("should return false", async () => {
|
||||
const mockContext = createMockExecutionContext({});
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when oauth id is provided", () => {
|
||||
it("should return true for valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for multiple valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for empty Permissions decorator", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a missing permission", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { hasPermissions } from "@calcom/platform-utils";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private tokensRepository: TokensRepository,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (nextAuthToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authString) {
|
||||
if (!authString && !oAuthClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
|
||||
const oAuthClientPermissions = authString
|
||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
||||
|
||||
if (!oAuthClientPermissions) {
|
||||
return false;
|
||||
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
||||
}
|
||||
|
||||
async getOAuthClientPermissions(accessToken: string) {
|
||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
|
||||
async getOAuthClientPermissionsById(id: string) {
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,20 +68,14 @@ export class BillingController {
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
const customerId = await this.billingService.createTeamBilling(teamId);
|
||||
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,20 +86,13 @@ export class BillingController {
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { action, url } = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
async createTeamBilling(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -60,6 +60,10 @@ export class BillingService implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
@@ -90,7 +94,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
return url;
|
||||
}
|
||||
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
@@ -111,7 +115,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
return url;
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
|
||||
import { AtomsModule } from "@/modules/atoms/atoms.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -18,6 +20,8 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
UsersModule,
|
||||
WebhooksModule,
|
||||
DestinationCalendarsModule,
|
||||
AtomsModule,
|
||||
StripeModule,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
|
||||
+2
@@ -300,6 +300,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
},
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -330,6 +331,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ type Input = Pick<
|
||||
| "requiresConfirmationWillBlockSlot"
|
||||
| "eventName"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
StripConnectOutputDto,
|
||||
StripConnectOutputResponseDto,
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Query,
|
||||
UseGuards,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Redirect,
|
||||
Req,
|
||||
BadRequestException,
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Stripe")
|
||||
export class StripeController {
|
||||
constructor(private readonly stripeService: StripeService) {}
|
||||
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("errorRedir") errorRedir?: string | null
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state = {
|
||||
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!redir ? redir : origin,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
||||
JSON.stringify(state),
|
||||
user.email,
|
||||
user.name
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/save")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
const accessToken = JSON.parse(state).accessToken;
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
return await this.stripeService.saveStripeAccount(state, code, accessToken);
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfStripeAccountConnected(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsOptional } from "class-validator";
|
||||
|
||||
export class StripeConnectQueryParamsInputDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly redir?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class StripConnectOutputDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly authUrl!: string;
|
||||
}
|
||||
|
||||
export class StripConnectOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => StripConnectOutputDto)
|
||||
data!: StripConnectOutputDto;
|
||||
}
|
||||
|
||||
export class StripCredentialsCheckOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
}
|
||||
|
||||
export class StripCredentialsSaveOutputResponseDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly url!: string;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
|
||||
controllers: [StripeController],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
@@ -1,15 +1,130 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
public stripe: Stripe;
|
||||
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
|
||||
|
||||
constructor(configService: ConfigService<AppConfig>) {
|
||||
constructor(
|
||||
configService: ConfigService<AppConfig>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {
|
||||
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
||||
const { client_id } = await this.getStripeAppKeys();
|
||||
|
||||
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
||||
client_id,
|
||||
scope: "read_write",
|
||||
response_type: "code",
|
||||
stripe_user: {
|
||||
email: userEmail,
|
||||
first_name: userName || undefined,
|
||||
/** We need this so E2E don't fail for international users */
|
||||
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
||||
},
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const params = z.record(z.any()).parse(stripeConnectParams);
|
||||
const query = stringify(params);
|
||||
const url = `https://connect.stripe.com/oauth/authorize?${query}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async getStripeAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug("stripe");
|
||||
|
||||
const { client_id, client_secret } = stripeKeysResponseSchema.parse(app?.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException("Stripe app not found");
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException("Stripe app not found");
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const response = await stripeInstance.oauth.token({
|
||||
grant_type: "authorization_code",
|
||||
code: code?.toString(),
|
||||
});
|
||||
|
||||
const data: StripeData = { ...response, default_currency: "" };
|
||||
if (response["stripe_user_id"]) {
|
||||
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
|
||||
|
||||
if (!stripeCredentials) {
|
||||
throw new NotFoundException("Credentials for stripe not found.");
|
||||
}
|
||||
|
||||
if (stripeCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid stripe credentials.");
|
||||
}
|
||||
|
||||
const stripeKey = JSON.stringify(stripeCredentials.key);
|
||||
const stripeKeyObject = JSON.parse(stripeKey);
|
||||
|
||||
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
|
||||
|
||||
// both of these should be true for an account to be fully active
|
||||
if (!stripeAccount.payouts_enabled || !stripeAccount.charges_enabled) {
|
||||
throw new BadRequestException("Stripe account is not an active account");
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const getReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
|
||||
let returnTo = "";
|
||||
try {
|
||||
returnTo = JSON.parse(`${queryState}`).returnTo;
|
||||
} catch (error) {
|
||||
console.info("No 'returnTo' in req.query.state");
|
||||
}
|
||||
return returnTo;
|
||||
};
|
||||
|
||||
export const getOnErrorReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
|
||||
let returnTo = "";
|
||||
try {
|
||||
returnTo = JSON.parse(`${queryState}`).onErrorReturnTo;
|
||||
} catch (error) {
|
||||
console.info("No 'onErrorReturnTo' in req.query.state");
|
||||
}
|
||||
return returnTo;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripeApiKey = process.env.STRIPE_API_KEY || "";
|
||||
export const stripeInstance = new Stripe(stripeApiKey, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const stripeOAuthTokenSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
livemode: z.boolean().optional(),
|
||||
token_type: z.literal("bearer").optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
stripe_user_id: z.string().optional(),
|
||||
stripe_publishable_key: z.string().optional(),
|
||||
});
|
||||
|
||||
export const stripeDataSchema = stripeOAuthTokenSchema.extend({
|
||||
default_currency: z.string(),
|
||||
});
|
||||
|
||||
export type StripeData = z.infer<typeof stripeDataSchema>;
|
||||
|
||||
export const stripeKeysResponseSchema = z.object({
|
||||
client_id: z.string().startsWith("ca_").min(1),
|
||||
client_secret: z.string().startsWith("sk_").min(1),
|
||||
public_key: z.string().startsWith("pk_").min(1),
|
||||
webhook_secret: z.string().startsWith("whsec_").min(1),
|
||||
});
|
||||
@@ -3,12 +3,12 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@@ -39,14 +39,10 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
throw new BadRequestException("webhookId parameter not specified in the request");
|
||||
}
|
||||
|
||||
if (!webhookId) {
|
||||
if (!oAuthClientId) {
|
||||
throw new BadRequestException("oAuthClientId parameter not specified in the request");
|
||||
}
|
||||
|
||||
if (!user || !webhookId || !oAuthClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
if (!oAuthClient) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
export class RateLimitRepositoryFixture {
|
||||
private dbWrite: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.dbWrite = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async createRateLimit(name: string, apiKeyId: string, ttl: number, limit: number, blockDuration: number) {
|
||||
return await this.dbWrite.rateLimit.create({
|
||||
data: {
|
||||
name,
|
||||
apiKeyId,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
API_KEY_PREFIX: "cal_test_",
|
||||
GET_LICENSE_KEY_URL: " https://console.cal.com/api/license",
|
||||
CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7",
|
||||
RATE_LIMIT_DEFAULT_TTL_MS: 60000,
|
||||
// note(Lauris): setting high limit so that e2e tests themselves are not rate limited
|
||||
RATE_LIMIT_DEFAULT_LIMIT: 10000,
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
|
||||
@@ -8,8 +8,8 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
|
||||
["/settings/security/:path*", process.env.APP_ROUTER_SETTINGS_SECURITY_ENABLED === "1"] as const,
|
||||
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
|
||||
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
|
||||
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
|
||||
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
|
||||
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
|
||||
|
||||
@@ -21,8 +21,5 @@ export const withAppDirSsr =
|
||||
...("trpcState" in props && {
|
||||
dehydratedState: props.trpcState,
|
||||
}),
|
||||
...("isInfiniteScrollEnabled" in props && {
|
||||
isInfiniteScrollEnabled: props.isInfiniteScrollEnabled,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export function WithLayout<T extends Record<string, any>>({
|
||||
requiresLicense={requiresLicense || !!(Page && "requiresLicense" in Page && Page.requiresLicense)}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
isThemeSupported={!!(Page && "isThemeSupported" in Page && Page.isThemeSupported)}
|
||||
isThemeSupported={Page && "isThemeSupported" in Page ? (Page.isThemeSupported as boolean) : undefined}
|
||||
isBookingPage={isBookingPage || !!(Page && "isBookingPage" in Page && Page.isBookingPage)}
|
||||
{...props}>
|
||||
{pageWithServerLayout}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getServerLayout: getLayout })<"L">;
|
||||
@@ -47,6 +47,7 @@ export default function CancelBooking(props: Props) {
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,6 +27,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
|
||||
mutation.mutate(calendar);
|
||||
}}
|
||||
hidePlaceholder
|
||||
calendarsQueryData={connectedCalendarsQuery.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,8 +127,8 @@ type CalcomThemeProps = Readonly<{
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isThemeSupported: boolean;
|
||||
children: React.ReactNode;
|
||||
isThemeSupported?: boolean;
|
||||
}>;
|
||||
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
@@ -273,7 +273,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
|
||||
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { ENABLE_INFINITE_EVENT_TYPES_FOR_ORG } from "@calcom/lib/constants";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -18,9 +17,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
}
|
||||
|
||||
const isInfiniteScrollEnabled = session.user?.org?.slug
|
||||
? ENABLE_INFINITE_EVENT_TYPES_FOR_ORG.includes(session.user.org.slug)
|
||||
: false;
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate(), isInfiniteScrollEnabled } };
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ interface LoginValues {
|
||||
}
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<img className="text-subtle mr-2 h-4 w-4 dark:invert" src="/google-icon.svg" alt="" />
|
||||
<img className="text-subtle mr-2 h-4 w-4" src="/google-icon-colored.svg" alt="Continue with Google Icon" />
|
||||
);
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
export default function Login({
|
||||
@@ -195,6 +195,50 @@ PageProps & WithNonceProps<{}>) {
|
||||
: null
|
||||
}>
|
||||
<FormProvider {...methods}>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
color="primary"
|
||||
className="w-full justify-center"
|
||||
disabled={formState.isSubmitting}
|
||||
data-testid="google"
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLastUsed("google");
|
||||
await signIn("google", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
<span>{t("signin_with_google")}</span>
|
||||
{lastUsed === "google" && <LastUsed />}
|
||||
</Button>
|
||||
)}
|
||||
{displaySSOLogin && (
|
||||
<SAMLLogin
|
||||
disabled={formState.isSubmitting}
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isGoogleLoginEnabled || displaySSOLogin) && (
|
||||
<div className="my-8">
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
<span className="text-subtle mx-2 flex-shrink text-sm font-normal leading-none">
|
||||
{t("or").toLocaleLowerCase()}
|
||||
</span>
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
|
||||
<div>
|
||||
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
|
||||
@@ -233,48 +277,14 @@ PageProps & WithNonceProps<{}>) {
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
color="secondary"
|
||||
disabled={formState.isSubmitting}
|
||||
className="w-full justify-center">
|
||||
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
|
||||
{lastUsed === "credentials" && (
|
||||
<span className="absolute right-3 text-xs text-gray-600">{t("last_used")}</span>
|
||||
)}
|
||||
{lastUsed === "credentials" && <LastUsed className="text-gray-600" />}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
|
||||
<div className="space-y-3">
|
||||
{isGoogleLoginEnabled && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="w-full justify-center"
|
||||
disabled={formState.isSubmitting}
|
||||
data-testid="google"
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLastUsed("google");
|
||||
await signIn("google", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
<span>{t("signin_with_google")}</span>
|
||||
{lastUsed === "google" && <LastUsed />}
|
||||
</Button>
|
||||
)}
|
||||
{displaySSOLogin && (
|
||||
<SAMLLogin
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</AuthContainer>
|
||||
<AddToHomescreen />
|
||||
|
||||
@@ -14,10 +14,7 @@ import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/emb
|
||||
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import SkeletonLoader, {
|
||||
InfiniteSkeletonLoader,
|
||||
} from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { parseEventTypeColor } from "@calcom/lib";
|
||||
@@ -25,7 +22,6 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
@@ -38,7 +34,6 @@ import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -62,7 +57,6 @@ import {
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
UserAvatarGroup,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -76,8 +70,6 @@ type InfiniteEventTypeGroup = GetUserEventGroupsResponse["eventTypeGroups"][numb
|
||||
type InfiniteEventType = GetEventTypesFromGroupsResponse["eventTypes"][number];
|
||||
|
||||
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
|
||||
|
||||
type EventTypeGroup = EventTypeGroups[number];
|
||||
type EventType = EventTypeGroup["eventTypes"][number];
|
||||
@@ -89,19 +81,8 @@ type DeNormalizedEventType = Omit<EventType, "userIds"> & {
|
||||
})[];
|
||||
};
|
||||
|
||||
type DeNormalizedEventTypeGroup = Omit<EventTypeGroup, "eventTypes"> & {
|
||||
eventTypes: DeNormalizedEventType[];
|
||||
};
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
interface EventTypeListHeadingProps {
|
||||
profile: EventTypeGroupProfile;
|
||||
membershipCount: number;
|
||||
teamId?: number | null;
|
||||
bookerUrl: string;
|
||||
}
|
||||
|
||||
interface InfiniteEventTypeListProps {
|
||||
group: InfiniteEventTypeGroup;
|
||||
readOnly: boolean;
|
||||
@@ -111,56 +92,15 @@ interface InfiniteEventTypeListProps {
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
interface EventTypeListProps {
|
||||
group: DeNormalizedEventTypeGroup;
|
||||
groupIndex: number;
|
||||
readOnly: boolean;
|
||||
bookerUrl: string | null;
|
||||
types: DeNormalizedEventType[];
|
||||
lockedByOrg?: boolean;
|
||||
}
|
||||
|
||||
interface InfiniteMobileTeamsTabProps {
|
||||
interface InfiniteTeamsTabProps {
|
||||
activeEventTypeGroup: InfiniteEventTypeGroup;
|
||||
}
|
||||
|
||||
interface MobileTeamsTabProps {
|
||||
eventTypeGroups: DeNormalizedEventTypeGroup[];
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
teamId: z.nullable(z.coerce.number()).optional().default(null),
|
||||
});
|
||||
|
||||
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
const { eventTypeGroups } = props;
|
||||
const tabs = eventTypeGroups.map((item) => ({
|
||||
name: item.profile.name ?? "",
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
|
||||
avatar: item.profile.image,
|
||||
}));
|
||||
const { data } = useTypedQuery(querySchema);
|
||||
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
{events.length > 0 ? (
|
||||
<EventTypeList
|
||||
types={events[0].eventTypes}
|
||||
group={events[0]}
|
||||
groupIndex={0}
|
||||
bookerUrl={events[0].bookerUrl}
|
||||
readOnly={events[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView slug={eventTypeGroups[0].profile.slug ?? ""} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InfiniteMobileTeamsTab: FC<InfiniteMobileTeamsTabProps> = (props) => {
|
||||
const InfiniteTeamsTab: FC<InfiniteTeamsTabProps> = (props) => {
|
||||
const { activeEventTypeGroup } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -292,510 +232,6 @@ const Item = ({
|
||||
|
||||
const MemoizedItem = memo(Item);
|
||||
|
||||
export const EventTypeList = ({
|
||||
group,
|
||||
groupIndex,
|
||||
readOnly,
|
||||
types,
|
||||
bookerUrl,
|
||||
lockedByOrg,
|
||||
}: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
|
||||
null
|
||||
);
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.eventTypeOrder.useMutation({
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
await utils.viewer.eventTypes.invalidate();
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.eventTypes.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setHiddenMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onMutate: async ({ id }) => {
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
|
||||
if (previousValue) {
|
||||
const newList = [...types.map(normalizeEventType)];
|
||||
const itemIndex = newList.findIndex((item) => item.id === id);
|
||||
if (itemIndex !== -1 && newList[itemIndex]) {
|
||||
newList[itemIndex].hidden = !newList[itemIndex].hidden;
|
||||
}
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, {
|
||||
...previousValue,
|
||||
eventTypeGroups: [
|
||||
...previousValue.eventTypeGroups.slice(0, groupIndex),
|
||||
{ ...group, eventTypes: newList },
|
||||
...previousValue.eventTypeGroups.slice(groupIndex + 1),
|
||||
],
|
||||
});
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onError: async (err, _, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, context.previousValue);
|
||||
}
|
||||
console.error(err.message);
|
||||
},
|
||||
onSettled: () => {
|
||||
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
|
||||
utils.viewer.eventTypes.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
async function moveEventType(index: number, increment: 1 | -1) {
|
||||
const newList = [...types.map(normalizeEventType)];
|
||||
|
||||
const type = types[index];
|
||||
const tmp = types[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = normalizeEventType(tmp);
|
||||
newList[index + increment] = normalizeEventType(type);
|
||||
}
|
||||
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
|
||||
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
|
||||
if (previousValue) {
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, {
|
||||
...previousValue,
|
||||
eventTypeGroups: [
|
||||
...previousValue.eventTypeGroups.slice(0, groupIndex),
|
||||
{ ...group, eventTypes: newList },
|
||||
...previousValue.eventTypeGroups.slice(groupIndex + 1),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList.map((type) => type.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteEventTypeHandler(id: number) {
|
||||
const payload = { id };
|
||||
deleteMutation.mutate(payload);
|
||||
}
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openDuplicateModal = (eventType: DeNormalizedEventType, group: DeNormalizedEventTypeGroup) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
if (value === null) newSearchParams.delete(key);
|
||||
}
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
setParamsIfDefined("teamId", eventType.teamId);
|
||||
setParamsIfDefined("parentId", eventType.parentId);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
setDeleteDialogOpen(false);
|
||||
},
|
||||
onMutate: async ({ id }) => {
|
||||
await utils.viewer.eventTypes.getByViewer.cancel();
|
||||
const previousValue = utils.viewer.eventTypes.getByViewer.getData();
|
||||
if (previousValue) {
|
||||
const newList = types.filter((item) => item.id !== id).map(normalizeEventType);
|
||||
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, {
|
||||
...previousValue,
|
||||
eventTypeGroups: [
|
||||
...previousValue.eventTypeGroups.slice(0, groupIndex),
|
||||
{ ...group, eventTypes: newList },
|
||||
...previousValue.eventTypeGroups.slice(groupIndex + 1),
|
||||
],
|
||||
});
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (err, _, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.eventTypes.getByViewer.setData(undefined, context.previousValue);
|
||||
}
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
setDeleteDialogOpen(false);
|
||||
} else if (err instanceof TRPCClientError) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
|
||||
utils.viewer.eventTypes.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const [isNativeShare, setNativeShare] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.share) {
|
||||
setNativeShare(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!types.length) {
|
||||
return group.teamId ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : !group.profile.eventTypesLockedByOrg ? (
|
||||
<CreateFirstEventTypeView slug={group.profile.slug ?? ""} />
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
const firstItem = types[0];
|
||||
const lastItem = types[types.length - 1];
|
||||
const isManagedEventPrefix = () => {
|
||||
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||
};
|
||||
return (
|
||||
<div className="bg-default border-subtle mb-16 flex overflow-hidden rounded-md border">
|
||||
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
|
||||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${bookerUrl}/${embedLink}`;
|
||||
const isPrivateURLEnabled = type.hashedLink?.link;
|
||||
const placeholderHashedLink = `${WEBSITE_URL}/d/${type.hashedLink?.link}/${type.slug}`;
|
||||
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
|
||||
const isChildrenManagedEventType =
|
||||
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||
return (
|
||||
<li key={type.id}>
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between transition">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden py-4 pl-2 pr-4 sm:pl-3 sm:pr-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{!!type.teamId && !isManagedEventType && (
|
||||
<UserAvatarGroup
|
||||
className="relative right-3"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
users={type?.users ?? []}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && type?.children && type.children?.length > 0 && (
|
||||
<UserAvatarGroup
|
||||
className="relative right-3"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
users={type?.children.flatMap((ch) => ch.users) ?? []}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
|
||||
<Tooltip
|
||||
content={type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}>
|
||||
<div className="self-center rounded-md p-2">
|
||||
<Switch
|
||||
name="Hidden"
|
||||
disabled={lockedByOrg}
|
||||
checked={!type.hidden}
|
||||
onCheckedChange={() => {
|
||||
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ButtonGroup combined>
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
<Tooltip content={t("preview")}>
|
||||
<Button
|
||||
data-testid="preview-link-button"
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
href={calLink}
|
||||
StartIcon="external-link"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t("copy_link")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="link"
|
||||
onClick={() => {
|
||||
showToast(t("link_copied"), "success");
|
||||
navigator.clipboard.writeText(calLink);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{isPrivateURLEnabled && (
|
||||
<Tooltip content={t("copy_private_link_to_event")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="venetian-mask"
|
||||
onClick={() => {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon="ellipsis"
|
||||
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-edit-${type.id}`}
|
||||
StartIcon="pencil"
|
||||
onClick={() => router.push(`/event-types/${type.id}`)}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
StartIcon="copy"
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EventTypeEmbedButton
|
||||
namespace={type.slug}
|
||||
as={DropdownItem}
|
||||
type="button"
|
||||
StartIcon="code"
|
||||
className="w-full rounded-none"
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
eventId={type.id}>
|
||||
{t("embed")}
|
||||
</EventTypeEmbedButton>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-9 mx-5 flex sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild data-testid={`event-type-options-${type.id}`}>
|
||||
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{!isManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
href={calLink}
|
||||
target="_blank"
|
||||
StartIcon="external-link"
|
||||
className="w-full rounded-none">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
StartIcon="clipboard"
|
||||
className="w-full rounded-none text-left">
|
||||
{t("copy_link")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
data-testid={`event-type-duplicate-${type.id}`}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
title: t("share"),
|
||||
text: t("share_event", { appName: APP_NAME }),
|
||||
url: calLink,
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}
|
||||
StartIcon="upload"
|
||||
className="w-full rounded-none">
|
||||
{t("share")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => router.push(`/event-types/${type.id}`)}
|
||||
StartIcon="pencil"
|
||||
className="w-full rounded-none">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
StartIcon="copy"
|
||||
data-testid={`event-type-duplicate-${type.id}`}>
|
||||
{t("duplicate")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!isManagedEventType && (
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 inline cursor-pointer self-center pr-2 ">
|
||||
{type.hidden ? t("show_eventtype_on_profile") : t("hide_from_profile")}
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
name="Hidden"
|
||||
checked={!type.hidden}
|
||||
onCheckedChange={() => {
|
||||
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t(`delete${isManagedEventPrefix()}_event_type`)}
|
||||
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||
loadingText={t(`confirm_delete_event_type`)}
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteEventTypeHandler(deleteDialogTypeId);
|
||||
}}>
|
||||
<p className="mt-5">
|
||||
<Trans
|
||||
i18nKey={`delete${isManagedEventPrefix()}_event_type_description`}
|
||||
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
|
||||
<ul>
|
||||
<li>Members assigned to this event type will also have their event types deleted.</li>
|
||||
<li>
|
||||
Anyone who they've shared their link with will no longer be able to book using it.
|
||||
</li>
|
||||
</ul>
|
||||
</Trans>
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InfiniteEventTypeList = ({
|
||||
group,
|
||||
readOnly,
|
||||
@@ -1339,66 +775,6 @@ export const InfiniteEventTypeList = ({
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeListHeading = ({
|
||||
profile,
|
||||
membershipCount,
|
||||
teamId,
|
||||
bookerUrl,
|
||||
}: EventTypeListHeadingProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<Avatar
|
||||
alt={profile.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={profile.image}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
<div>
|
||||
<Link
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
className="text-emphasis font-bold">
|
||||
{profile.name || ""}
|
||||
</Link>
|
||||
{membershipCount && teamId && (
|
||||
<span className="text-subtle relative -top-px me-2 ms-2 text-xs">
|
||||
<Link href={`/settings/teams/${teamId}/members`}>
|
||||
<Badge variant="gray">
|
||||
<Icon name="users" className="-mt-px mr-1 inline h-3 w-3" />
|
||||
{membershipCount}
|
||||
</Badge>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{profile.slug && (
|
||||
<Link href={`${bookerUrl}/${profile.slug}`} className="text-subtle block text-xs">
|
||||
{`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!profile.slug && !!teamId && (
|
||||
<button onClick={() => publishTeamMutation.mutate({ teamId })}>
|
||||
<Badge variant="gray" className="-ml-2 mb-1">
|
||||
{t("upgrade")}
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1420,7 +796,6 @@ const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||
const CTA = ({
|
||||
profileOptions,
|
||||
isOrganization,
|
||||
isInfiniteScrollEnabled,
|
||||
}: {
|
||||
profileOptions: {
|
||||
teamId: number | null | undefined;
|
||||
@@ -1430,7 +805,6 @@ const CTA = ({
|
||||
slug: string | null;
|
||||
}[];
|
||||
isOrganization: boolean;
|
||||
isInfiniteScrollEnabled: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1441,24 +815,11 @@ const CTA = ({
|
||||
data-testid="new-event-type"
|
||||
subtitle={t("create_event_on").toUpperCase()}
|
||||
options={profileOptions}
|
||||
createDialog={() => (
|
||||
<CreateEventTypeDialog
|
||||
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
|
||||
profileOptions={profileOptions}
|
||||
/>
|
||||
)}
|
||||
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Actions = (props: { showDivider: boolean }) => {
|
||||
return (
|
||||
<div className="hidden items-center md:flex">
|
||||
<TeamsFilter useProfileFilter popoverTriggerClassNames="mb-0" showVerticalDivider={props.showDivider} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup | InfiniteEventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -1478,99 +839,6 @@ const EmptyEventTypeList = ({ group }: { group: EventTypeGroup | InfiniteEventTy
|
||||
);
|
||||
};
|
||||
|
||||
const Main = ({
|
||||
status,
|
||||
errorMessage,
|
||||
data: rawData,
|
||||
filters,
|
||||
}: {
|
||||
status: string;
|
||||
data: GetByViewerResponse;
|
||||
errorMessage?: string;
|
||||
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
if (status === "error") {
|
||||
return <Alert severity="error" title="Something went wrong" message={errorMessage} />;
|
||||
}
|
||||
|
||||
if (!rawData || status === "pending") {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
const isFilteredByOnlyOneItem =
|
||||
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
|
||||
rawData.eventTypeGroups.length === 1;
|
||||
|
||||
const data = denormalizePayload(rawData);
|
||||
return (
|
||||
<>
|
||||
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group, index: number) => {
|
||||
const eventsLockedByOrg = group.profile.eventTypesLockedByOrg;
|
||||
const userHasManagedOrHiddenEventTypes = group.eventTypes.find(
|
||||
(event) => event.metadata?.managedEventConfig || event.hidden
|
||||
);
|
||||
if (eventsLockedByOrg && !userHasManagedOrHiddenEventTypes) return null;
|
||||
return (
|
||||
<div
|
||||
className="mt-4 flex flex-col"
|
||||
data-testid={`slug-${group.profile.slug}`}
|
||||
key={group.profile.slug}>
|
||||
{/* If the group is readonly and empty don't leave a floating header when the user cant see the create box due
|
||||
to it being readonly for that user */}
|
||||
{group.eventTypes.length === 0 && group.metadata.readOnly ? null : (
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
teamId={group.teamId}
|
||||
bookerUrl={group.bookerUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{group.eventTypes.length ? (
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
bookerUrl={group.bookerUrl}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
lockedByOrg={eventsLockedByOrg}
|
||||
/>
|
||||
) : group.teamId && !group.metadata.readOnly ? (
|
||||
<EmptyEventTypeList group={group} />
|
||||
) : !group.metadata.readOnly ? (
|
||||
<CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
data.eventTypeGroups.length === 1 && (
|
||||
<EventTypeList
|
||||
types={data.eventTypeGroups[0].eventTypes}
|
||||
group={data.eventTypeGroups[0]}
|
||||
groupIndex={0}
|
||||
bookerUrl={data.eventTypeGroups[0].bookerUrl}
|
||||
readOnly={data.eventTypeGroups[0].metadata.readOnly}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={data.profiles[0].slug ?? ""} />}
|
||||
<EventTypeEmbedDialog />
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InfiniteScrollMain = ({
|
||||
status,
|
||||
errorMessage,
|
||||
@@ -1607,20 +875,20 @@ const InfiniteScrollMain = ({
|
||||
{eventTypeGroups.length >= 1 && (
|
||||
<>
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
<InfiniteMobileTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
|
||||
<InfiniteTeamsTab activeEventTypeGroup={activeEventTypeGroup[0]} />
|
||||
</>
|
||||
)}
|
||||
{eventTypeGroups.length === 0 && <CreateFirstEventTypeView slug={profiles[0].slug ?? ""} />}
|
||||
<EventTypeEmbedDialog />
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog isInfiniteScrollEnabled />}
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
|
||||
const EventTypesPage: React.FC & {
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
getLayout?: AppProps["Component"]["getLayout"];
|
||||
} = ({ isInfiniteScrollEnabled = false }) => {
|
||||
} = () => {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { open } = useIntercom();
|
||||
@@ -1632,13 +900,6 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
const router = useRouter();
|
||||
|
||||
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 1 * 60 * 60 * 1000,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
enabled: !isInfiniteScrollEnabled,
|
||||
});
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
const {
|
||||
data: getUserEventGroupsData,
|
||||
@@ -1648,7 +909,6 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 1 * 60 * 60 * 1000,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
enabled: isInfiniteScrollEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1672,31 +932,18 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
|
||||
}, [orgBranding, user]);
|
||||
|
||||
const profileOptions =
|
||||
(data?.profiles
|
||||
? data?.profiles
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
membershipRole: profile.membershipRole,
|
||||
slug: profile.slug,
|
||||
};
|
||||
})
|
||||
: getUserEventGroupsData?.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
?.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
membershipRole: profile.membershipRole,
|
||||
slug: profile.slug,
|
||||
};
|
||||
})) ?? [];
|
||||
getUserEventGroupsData?.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
?.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
membershipRole: profile.membershipRole,
|
||||
slug: profile.slug,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<Shell
|
||||
@@ -1707,63 +954,20 @@ const EventTypesPage: React.FC<{ isInfiniteScrollEnabled?: boolean }> & {
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
beforeCTAactions={
|
||||
isInfiniteScrollEnabled ? undefined : <Actions showDivider={profileOptions.length > 0} />
|
||||
}
|
||||
CTA={
|
||||
<CTA
|
||||
isInfiniteScrollEnabled={isInfiniteScrollEnabled}
|
||||
profileOptions={profileOptions}
|
||||
isOrganization={!!user?.organizationId}
|
||||
/>
|
||||
}>
|
||||
CTA={<CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />}>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
{isInfiniteScrollEnabled ? (
|
||||
<InfiniteScrollMain
|
||||
profiles={getUserEventGroupsData?.profiles}
|
||||
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
|
||||
status={getUserEventGroupsStatus}
|
||||
errorMessage={getUserEventGroupsStatusError?.message}
|
||||
/>
|
||||
) : (
|
||||
<Main data={data} status={status} errorMessage={error?.message} filters={filters} />
|
||||
)}
|
||||
|
||||
<InfiniteScrollMain
|
||||
profiles={getUserEventGroupsData?.profiles}
|
||||
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
|
||||
status={getUserEventGroupsStatus}
|
||||
errorMessage={getUserEventGroupsStatusError?.message}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypesPage;
|
||||
|
||||
function normalizeEventType(eventType: DeNormalizedEventType): EventType {
|
||||
return {
|
||||
...eventType,
|
||||
userIds: eventType.users.map((user) => user.id),
|
||||
};
|
||||
}
|
||||
|
||||
function denormalizePayload(data: NonNullable<GetByViewerResponse>) {
|
||||
return {
|
||||
...data,
|
||||
eventTypeGroups: data.eventTypeGroups.map((eventTypeGroup) => {
|
||||
return {
|
||||
...eventTypeGroup,
|
||||
eventTypes: eventTypeGroup.eventTypes.map((eventType) => {
|
||||
const { userIds, ...rest } = eventType;
|
||||
return {
|
||||
...rest,
|
||||
users: userIds.map((userId) => {
|
||||
const user = data.allUsersAcrossAllEventTypes.get(userId);
|
||||
if (!user) {
|
||||
throw new Error(`User with id ${userId} not found in allUsersAcrossAllEventTypes`);
|
||||
}
|
||||
return user;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ const AppearanceView = ({
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
isLoading={mutation.isPending}
|
||||
user={user}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export const CreateTeamEventType = () => {
|
||||
SubmitButton={SubmitButton}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
isInfiniteScrollEnabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+237
-184
@@ -46,9 +46,9 @@ import {
|
||||
TextField,
|
||||
Form,
|
||||
Alert,
|
||||
showToast,
|
||||
CheckboxField,
|
||||
Icon,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { getServerSideProps } from "@lib/signup/getServerSideProps";
|
||||
@@ -179,9 +179,12 @@ export default function Signup({
|
||||
redirectUrl,
|
||||
emailVerificationEnabled,
|
||||
}: SignupProps) {
|
||||
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
||||
const [isSamlSignup, setIsSamlSignup] = useState(false);
|
||||
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||
const [usernameTaken, setUsernameTaken] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [displayEmailForm, setDisplayEmailForm] = useState(token);
|
||||
const searchParams = useCompatSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -203,13 +206,14 @@ export default function Signup({
|
||||
}
|
||||
}, [redirectUrl]);
|
||||
|
||||
const [COOKIE_CONSENT, setCOOKIE_CONSENT] = useState(false);
|
||||
const [userConsentToCookie, setUserConsentToCookie] = useState(false); // No need to be checked for user to proceed
|
||||
|
||||
function handleConsentChange(consent: boolean) {
|
||||
setCOOKIE_CONSENT(!consent);
|
||||
setUserConsentToCookie(!consent);
|
||||
}
|
||||
|
||||
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
||||
const displayBackButton = token ? false : displayEmailForm;
|
||||
|
||||
const handleErrorsAndStripe = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
@@ -229,7 +233,6 @@ export default function Signup({
|
||||
}
|
||||
};
|
||||
|
||||
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
||||
const isPlatformUser = redirectUrl?.includes("platform") && redirectUrl?.includes("new");
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (_data) => {
|
||||
@@ -294,7 +297,7 @@ export default function Signup({
|
||||
|
||||
return (
|
||||
<>
|
||||
{IS_CALCOM && (!IS_EUROPE || COOKIE_CONSENT) ? (
|
||||
{IS_CALCOM && (!IS_EUROPE || userConsentToCookie) ? (
|
||||
<>
|
||||
{process.env.NEXT_PUBLIC_GTM_ID && (
|
||||
<>
|
||||
@@ -332,7 +335,22 @@ export default function Signup({
|
||||
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
{/* Left side */}
|
||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
|
||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-24 2xl:px-28">
|
||||
{displayBackButton && (
|
||||
<div className="flex w-fit lg:-mt-12">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="hover:bg-subtle todesktop:mt-10 mb-6 flex h-6 max-h-6 w-full items-center rounded-md px-3 py-2"
|
||||
StartIcon="arrow-left"
|
||||
data-testid="signup-back-button"
|
||||
onClick={() => {
|
||||
setDisplayEmailForm(false);
|
||||
setIsSamlSignup(false);
|
||||
}}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-cal text-[28px] leading-none ">
|
||||
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
|
||||
@@ -347,197 +365,232 @@ export default function Signup({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="mt-12">
|
||||
<Form
|
||||
className="flex flex-col gap-4"
|
||||
form={formMethods}
|
||||
handleSubmit={async (values) => {
|
||||
let updatedValues = values;
|
||||
if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
updatedValues = {
|
||||
...values,
|
||||
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
|
||||
};
|
||||
}
|
||||
await signUp(updatedValues);
|
||||
}}>
|
||||
{/* Username */}
|
||||
{!isOrgInviteByLink ? (
|
||||
<UsernameField
|
||||
orgSlug={orgSlug}
|
||||
label={t("username")}
|
||||
username={watch("username") || ""}
|
||||
premium={premiumUsername}
|
||||
usernameTaken={usernameTaken}
|
||||
disabled={!!orgSlug}
|
||||
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||
data-testid="signup-usernamefield"
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true }).replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
{displayEmailForm && (
|
||||
<div className="mt-12">
|
||||
<Form
|
||||
className="flex flex-col gap-4"
|
||||
form={formMethods}
|
||||
handleSubmit={async (values) => {
|
||||
let updatedValues = values;
|
||||
if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
|
||||
updatedValues = {
|
||||
...values,
|
||||
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
|
||||
};
|
||||
}
|
||||
await signUp(updatedValues);
|
||||
}}>
|
||||
{/* Username */}
|
||||
{!isOrgInviteByLink ? (
|
||||
<UsernameField
|
||||
orgSlug={orgSlug}
|
||||
label={t("username")}
|
||||
username={watch("username") || ""}
|
||||
premium={premiumUsername}
|
||||
usernameTaken={usernameTaken}
|
||||
disabled={!!orgSlug}
|
||||
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||
data-testid="signup-usernamefield"
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
|
||||
URL_PROTOCOL_REGEX,
|
||||
""
|
||||
)}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* Email */}
|
||||
<TextField
|
||||
{...register("email")}
|
||||
label={t("email")}
|
||||
type="email"
|
||||
disabled={prepopulateFormValues?.email}
|
||||
data-testid="signup-emailfield"
|
||||
/>
|
||||
) : null}
|
||||
{/* Email */}
|
||||
<TextField
|
||||
{...register("email")}
|
||||
label={t("email")}
|
||||
type="email"
|
||||
disabled={prepopulateFormValues?.email}
|
||||
data-testid="signup-emailfield"
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<PasswordField
|
||||
data-testid="signup-passwordfield"
|
||||
label={t("password")}
|
||||
{...register("password")}
|
||||
hintErrors={["caplow", "min", "num"]}
|
||||
/>
|
||||
{/* Cloudflare Turnstile Captcha */}
|
||||
{CLOUDFLARE_SITE_ID ? (
|
||||
<TurnstileCaptcha
|
||||
appearance="interaction-only"
|
||||
onVerify={(token) => {
|
||||
formMethods.setValue("cfToken", token);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Password */}
|
||||
{!isSamlSignup && (
|
||||
<PasswordField
|
||||
data-testid="signup-passwordfield"
|
||||
label={t("password")}
|
||||
{...register("password")}
|
||||
hintErrors={["caplow", "min", "num"]}
|
||||
/>
|
||||
)}
|
||||
{/* Cloudflare Turnstile Captcha */}
|
||||
{CLOUDFLARE_SITE_ID ? (
|
||||
<TurnstileCaptcha
|
||||
appearance="interaction-only"
|
||||
onVerify={(token) => {
|
||||
formMethods.setValue("cfToken", token);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CheckboxField
|
||||
onChange={() => handleConsentChange(COOKIE_CONSENT)}
|
||||
description={t("cookie_consent_checkbox")}
|
||||
/>
|
||||
{errors.apiError && (
|
||||
<Alert
|
||||
className="mb-3"
|
||||
severity="error"
|
||||
message={errors.apiError?.message}
|
||||
data-testid="signup-error-message"
|
||||
<CheckboxField
|
||||
data-testid="signup-cookie-content-checkbox"
|
||||
onChange={() => handleConsentChange(userConsentToCookie)}
|
||||
description={t("cookie_consent_checkbox")}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="my-2 w-full justify-center"
|
||||
loading={loadingSubmitState}
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
!formMethods.getValues("email") ||
|
||||
!formMethods.getValues("password") ||
|
||||
(CLOUDFLARE_SITE_ID &&
|
||||
!process.env.NEXT_PUBLIC_IS_E2E &&
|
||||
!formMethods.getValues("cfToken")) ||
|
||||
isSubmitting ||
|
||||
usernameTaken
|
||||
}>
|
||||
{premiumUsername && !usernameTaken
|
||||
? `Create Account for ${getPremiumPlanPriceValue()}`
|
||||
: t("create_account")}
|
||||
</Button>
|
||||
</Form>
|
||||
{!isGoogleLoginEnabled && !isSAMLLoginEnabled ? null : (
|
||||
<div className="mt-6">
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
<span className="text-subtle mx-2 flex-shrink text-sm font-normal leading-none">
|
||||
{t("or_continue_with")}
|
||||
</span>
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||
{isGoogleLoginEnabled ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={!!formMethods.formState.errors.username || premiumUsername}
|
||||
loading={isGoogleLoading}
|
||||
CustomStartIcon={
|
||||
<img
|
||||
className={classNames(
|
||||
"text-subtle mr-2 h-4 w-4 dark:invert",
|
||||
premiumUsername && "opacity-50"
|
||||
)}
|
||||
src="/google-icon.svg"
|
||||
alt=""
|
||||
/>
|
||||
}
|
||||
className={classNames(
|
||||
"w-full justify-center rounded-md text-center",
|
||||
formMethods.formState.errors.username ? "opacity-50" : ""
|
||||
)}
|
||||
onClick={async () => {
|
||||
setIsGoogleLoading(true);
|
||||
const username = formMethods.getValues("username");
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
|
||||
const searchQueryParams = new URLSearchParams();
|
||||
if (username) {
|
||||
// If username is present we save it in query params to check for premium
|
||||
searchQueryParams.set("username", username);
|
||||
{errors.apiError && (
|
||||
<Alert
|
||||
className="mb-3"
|
||||
severity="error"
|
||||
message={errors.apiError?.message}
|
||||
data-testid="signup-error-message"
|
||||
/>
|
||||
)}
|
||||
{isSamlSignup ? (
|
||||
<Button
|
||||
data-testid="saml-submit-button"
|
||||
color="primary"
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
!formMethods.getValues("email") ||
|
||||
!formMethods.getValues("username") ||
|
||||
premiumUsername ||
|
||||
isSubmitting
|
||||
}
|
||||
onClick={() => {
|
||||
const username = formMethods.getValues("username");
|
||||
if (!username) {
|
||||
// should not be reached but needed to bypass type errors
|
||||
showToast("error", t("username_required"));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("username", username);
|
||||
const sp = new URLSearchParams();
|
||||
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
||||
sp.set("username", username);
|
||||
sp.set("email", formMethods.getValues("email"));
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
|
||||
);
|
||||
}}
|
||||
className={classNames(
|
||||
"my-2 w-full justify-center rounded-md text-center",
|
||||
formMethods.formState.errors.username && formMethods.formState.errors.email
|
||||
? "opacity-50"
|
||||
: ""
|
||||
)}>
|
||||
<Icon name="shield-check" className="mr-2 h-5 w-5" />
|
||||
{t("create_account_with_saml")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="signup-submit-button"
|
||||
className="my-2 w-full justify-center"
|
||||
loading={loadingSubmitState}
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
!formMethods.getValues("email") ||
|
||||
!formMethods.getValues("password") ||
|
||||
(CLOUDFLARE_SITE_ID &&
|
||||
!process.env.NEXT_PUBLIC_IS_E2E &&
|
||||
!formMethods.getValues("cfToken")) ||
|
||||
isSubmitting ||
|
||||
usernameTaken
|
||||
}>
|
||||
{premiumUsername && !usernameTaken
|
||||
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
|
||||
: t("create_account")}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
{!displayEmailForm && (
|
||||
<div className="mt-12">
|
||||
{/* Upper Row */}
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||
{isGoogleLoginEnabled ? (
|
||||
<Button
|
||||
color="primary"
|
||||
loading={isGoogleLoading}
|
||||
CustomStartIcon={
|
||||
<img
|
||||
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
|
||||
src="/google-icon-colored.svg"
|
||||
alt="Continue with Google Icon"
|
||||
/>
|
||||
}
|
||||
if (token) {
|
||||
searchQueryParams.set("email", prepopulateFormValues?.email);
|
||||
}
|
||||
const url = searchQueryParams.toString()
|
||||
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
|
||||
: GOOGLE_AUTH_URL;
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
data-testid="continue-with-google-button"
|
||||
onClick={async () => {
|
||||
setIsSamlSignup(false);
|
||||
setIsGoogleLoading(true);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
|
||||
const searchQueryParams = new URLSearchParams();
|
||||
if (prepopulateFormValues?.username) {
|
||||
// If username is present we save it in query params to check for premium
|
||||
searchQueryParams.set("username", prepopulateFormValues.username);
|
||||
localStorage.setItem("username", prepopulateFormValues.username);
|
||||
}
|
||||
if (token) {
|
||||
searchQueryParams.set("email", prepopulateFormValues?.email);
|
||||
}
|
||||
const url = searchQueryParams.toString()
|
||||
? `${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`
|
||||
: GOOGLE_AUTH_URL;
|
||||
|
||||
router.push(url);
|
||||
}}>
|
||||
Google
|
||||
</Button>
|
||||
) : null}
|
||||
{isSAMLLoginEnabled ? (
|
||||
router.push(url);
|
||||
}}>
|
||||
{t("continue_with_google")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isGoogleLoginEnabled && (
|
||||
<div className="mt-6">
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
<span className="text-subtle mx-2 flex-shrink text-sm font-normal leading-none">
|
||||
{t("or").toLocaleLowerCase()}
|
||||
</span>
|
||||
<div className="border-subtle flex-grow border-t" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lower Row */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
!!formMethods.formState.errors.email ||
|
||||
premiumUsername ||
|
||||
isSubmitting ||
|
||||
isGoogleLoading
|
||||
}
|
||||
className={classNames(
|
||||
"w-full justify-center rounded-md text-center",
|
||||
formMethods.formState.errors.username && formMethods.formState.errors.email
|
||||
? "opacity-50"
|
||||
: ""
|
||||
)}
|
||||
disabled={isGoogleLoading}
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
onClick={() => {
|
||||
if (!formMethods.getValues("username")) {
|
||||
formMethods.trigger("username");
|
||||
}
|
||||
if (!formMethods.getValues("email")) {
|
||||
formMethods.trigger("email");
|
||||
|
||||
return;
|
||||
}
|
||||
const username = formMethods.getValues("username");
|
||||
if (!username) {
|
||||
showToast("error", t("username_required"));
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("username", username);
|
||||
const sp = new URLSearchParams();
|
||||
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
||||
sp.set("username", username);
|
||||
sp.set("email", formMethods.getValues("email"));
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
|
||||
);
|
||||
}}>
|
||||
<Icon name="shield-check" className="mr-2 h-5 w-5" />
|
||||
{t("saml_sso")}
|
||||
setDisplayEmailForm(true);
|
||||
setIsSamlSignup(false);
|
||||
}}
|
||||
data-testid="continue-with-email-button">
|
||||
{t("continue_with_email")}
|
||||
</Button>
|
||||
) : null}
|
||||
{isSAMLLoginEnabled && (
|
||||
<Button
|
||||
data-testid="continue-with-saml-button"
|
||||
color="minimal"
|
||||
disabled={isGoogleLoading}
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
onClick={() => {
|
||||
setDisplayEmailForm(true);
|
||||
setIsSamlSignup(true);
|
||||
}}>
|
||||
{`${t("or").toLocaleLowerCase()} ${t("saml_sso")}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already have an account & T&C */}
|
||||
<div className="mt-10 flex h-full flex-col justify-end pb-6 text-xs">
|
||||
<div className="flex flex-col text-sm">
|
||||
@@ -547,7 +600,7 @@ export default function Signup({
|
||||
{t("sign_in")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-subtle ">
|
||||
<div className="text-subtle">
|
||||
<Trans
|
||||
i18nKey="signing_up_terms"
|
||||
components={[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.5.5",
|
||||
"version": "4.5.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -136,7 +136,7 @@
|
||||
"stripe": "^9.16.0",
|
||||
"superjson": "1.9.1",
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"turndown": "^7.1.1",
|
||||
"turndown": "^7.1.3",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import ProfileImpersonationViewWrapper from "~/settings/security/impersonation-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("impersonation")}
|
||||
description={t("impersonation_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<ProfileImpersonationViewWrapper />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import PasswordViewWrapper from "~/settings/security/password-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
<PasswordViewWrapper />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,25 +0,0 @@
|
||||
import UserSSOView from "@calcom/features/ee/sso/page/user-sso-view";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("sso_configuration")}
|
||||
description={t("sso_configuration_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<UserSSOView />
|
||||
</>
|
||||
);
|
||||
};
|
||||
Page.PageWrapper = PageWrapper;
|
||||
Page.getLayout = getLayout;
|
||||
|
||||
export default Page;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import TwoFactorAuthView from "~/settings/security/two-factor-auth-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
|
||||
<TwoFactorAuthView />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { testBothFutureAndLegacyRoutes } from "playwright/lib/future-legacy-routes";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Change Theme Test", () => {
|
||||
testBothFutureAndLegacyRoutes.describe("Change Theme Test", () => {
|
||||
test("change theme to dark", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -292,6 +292,12 @@ export const createUsersFixture = (
|
||||
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
|
||||
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
|
||||
{ title: "Seated", slug: "seated", seatsPerTimeSlot: 2, length: 30 },
|
||||
{
|
||||
title: "Multiple duration",
|
||||
slug: "multiple-duration",
|
||||
length: 30,
|
||||
metadata: { multipleDuration: [30, 60, 90] },
|
||||
},
|
||||
];
|
||||
|
||||
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
|
||||
|
||||
@@ -249,6 +249,7 @@ test.describe("Manage Booking Questions", () => {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -256,7 +257,10 @@ test.describe("Manage Booking Questions", () => {
|
||||
const webhookReceiver = await addWebhook(undefined, teamId);
|
||||
|
||||
await test.step("Go to First Team Event", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||
await page.getByTestId(`horizontal-tab-${team?.name}`).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").locator("li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
|
||||
await firstEventTypeElement.click();
|
||||
|
||||
@@ -38,7 +38,7 @@ async function setupManagedEvent({
|
||||
const memberUser = users.get().find((u) => u.name === teamMateName)!;
|
||||
const { team } = await adminUser.getFirstTeamMembership();
|
||||
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
|
||||
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle };
|
||||
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle, teamId: team.id };
|
||||
}
|
||||
|
||||
/** Short hand to get elements by translation key */
|
||||
@@ -129,7 +129,9 @@ test.describe("Managed Event Types", () => {
|
||||
test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => {
|
||||
const { adminUser, teamEventTitle } = await setupManagedEvent({ users });
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const teamMembership = await adminUser.getFirstTeamMembership();
|
||||
|
||||
await page.goto(`/event-types?teamId=${teamMembership.team.id}`);
|
||||
|
||||
await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
|
||||
await page.waitForURL("event-types/**");
|
||||
@@ -169,7 +171,7 @@ test.describe("Managed Event Types", () => {
|
||||
users,
|
||||
browser,
|
||||
}) => {
|
||||
const { adminUser, memberUser, teamEventTitle } = await setupManagedEvent({
|
||||
const { adminUser, memberUser, teamEventTitle, teamId } = await setupManagedEvent({
|
||||
users,
|
||||
unlockedFields: {
|
||||
title: true,
|
||||
@@ -185,7 +187,7 @@ test.describe("Managed Event Types", () => {
|
||||
|
||||
// We edit the managed event as original owner
|
||||
const [adminContext, adminPage] = await adminUser.apiLoginOnNewBrowser(browser);
|
||||
await adminPage.goto("/event-types");
|
||||
await adminPage.goto(`/event-types?teamId=${teamId}`);
|
||||
await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
|
||||
await adminPage.waitForURL("event-types/**");
|
||||
await adminPage.locator('input[name="length"]').fill(`45`);
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe("OIDC", () => {
|
||||
// Login a user using the OIDC provider.
|
||||
// The credentials are handled by the provider, so we don't need to create a user in the db.
|
||||
await page.goto("/auth/login");
|
||||
await page.click('[data-testid="saml"]');
|
||||
await page.click('[data-testid="samlAndOidc"]');
|
||||
// Redirected outide of the app, the user would be redirected to the OIDC provider.
|
||||
await page.waitForURL(/https:\/\/[^/]+\/oauth2\/v1\/authorize\?.*/);
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(OIDC_USER_EMAIL);
|
||||
|
||||
@@ -47,19 +47,28 @@ test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => {
|
||||
|
||||
await user1NotMemberOfOrg1.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const userEventLinksSelector = `[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`;
|
||||
await page.waitForSelector(userEventLinksSelector);
|
||||
|
||||
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
const userEventLinksLocators = await page
|
||||
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
// Get all the event links
|
||||
const userEventLinksLocators = await page.locator(userEventLinksSelector).all();
|
||||
expect(userEventLinksLocators.length).toBeGreaterThan(0);
|
||||
for (const userEventLinkLocator of userEventLinksLocators) {
|
||||
const href = await userEventLinkLocator.getAttribute("href");
|
||||
expect(href).toContain(WEBAPP_URL);
|
||||
}
|
||||
|
||||
await page.goto(`/event-types?teamId=${team1MemberOfOrg1.id}`);
|
||||
|
||||
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
const teamEventLinksLocators = await page
|
||||
.locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`)
|
||||
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
expect(teamEventLinksLocators.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -297,6 +297,9 @@ test.describe("Organization", () => {
|
||||
await test.step("Signing up with the previous username of the migrated user - shouldn't be allowed", async () => {
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await page.locator('input[name="username"]').fill(existingUser.username!);
|
||||
|
||||
@@ -1,17 +1,81 @@
|
||||
import { IS_SAML_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { login } from "./fixtures/users";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("SAML tests", () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!isSAMLLoginEnabled, "Skipping due to SAML login being disabled");
|
||||
|
||||
test("test SAML configuration UI with pro@example.com", async ({ page }) => {
|
||||
// TODO: Figure out a way to use the users from fixtures here, right now we cannot set
|
||||
// the SAML_ADMINS env variables dynamically
|
||||
await login({ username: "pro", email: "pro@example.com", password: "pro" }, page);
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML is enabled");
|
||||
// Try to go Security page
|
||||
await page.goto("/settings/security/sso");
|
||||
// It should redirect you to the event-types page
|
||||
// await page.waitForSelector("[data-testid=saml_config]");
|
||||
});
|
||||
|
||||
test.describe("SAML Signup Flow Test", async () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible(); // this prevents flaky test
|
||||
await page.getByTestId("continue-with-saml-button").click();
|
||||
await page.waitForSelector('[data-testid="saml-submit-button"]');
|
||||
});
|
||||
|
||||
test("Submit button should be disabled without username", async ({ page }) => {
|
||||
await page.locator('input[name="email"]').fill("tester123@example.com");
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeVisible();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Submit button should be disabled without email", async ({ page }) => {
|
||||
await page.locator('input[name="username"]').fill("tester123");
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeVisible();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Password input should not exist", async ({ page }) => {
|
||||
await expect(page.locator('input[name="password"]')).toBeHidden();
|
||||
});
|
||||
|
||||
test("Checkbox for cookie consent does not need to be checked", async ({ page }) => {
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("tester123");
|
||||
await page.locator('input[name="email"]').fill("tester123@example.com");
|
||||
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
|
||||
|
||||
await checkbox.check();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// the cookie consent checkbox does not need to be checked for user to proceed
|
||||
await checkbox.uncheck();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("Submit button should be disabled with a premium username", async ({ page }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com");
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("pro");
|
||||
await page.locator('input[name="email"]').fill("pro@example.com");
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
|
||||
@@ -10,7 +10,41 @@ import { expectInvitationEmailToBeReceived } from "./team/expects";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Signup Flow Test", async () => {
|
||||
const preventFlakyTest = async (page: Page) => {
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
};
|
||||
test.describe("Signup Main Page Test", async () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await preventFlakyTest(page);
|
||||
});
|
||||
|
||||
test("Continue with email button must exist / work", async ({ page }) => {
|
||||
const button = page.getByTestId("continue-with-email-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("signup-back-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Continue with google button must exist / work", async ({ page }) => {
|
||||
const button = page.getByTestId("continue-with-google-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await page.waitForURL("/auth/sso/google");
|
||||
});
|
||||
|
||||
test("Continue with SAML button must exist / work", async ({ page }) => {
|
||||
const button = page.getByTestId("continue-with-saml-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("signup-back-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Email Signup Flow Test", async () => {
|
||||
test.beforeEach(async ({ features }) => {
|
||||
features.reset(); // This resets to the inital state not an empt yarray
|
||||
});
|
||||
@@ -25,7 +59,10 @@ test.describe("Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
const alertMessage = "Username or email is already taken";
|
||||
|
||||
@@ -35,7 +72,8 @@ test.describe("Signup Flow Test", async () => {
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
const alert = await page.waitForSelector('[data-testid="alert"]');
|
||||
const alertMessageInner = await alert.innerText();
|
||||
@@ -52,8 +90,10 @@ test.describe("Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
const alertMessage = "Username or email is already taken";
|
||||
|
||||
@@ -63,8 +103,9 @@ test.describe("Signup Flow Test", async () => {
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
const alert = await page.waitForSelector('[data-testid="alert"]');
|
||||
const alertMessageInner = await alert.innerText();
|
||||
|
||||
@@ -84,14 +125,19 @@ test.describe("Signup Flow Test", async () => {
|
||||
|
||||
// Signup with premium username name
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("rock");
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
@@ -103,7 +149,7 @@ test.describe("Signup Flow Test", async () => {
|
||||
expect(url).toContain(expectedUrl);
|
||||
// TODO: complete the stripe checkout flow
|
||||
});
|
||||
test("Signup with valid (non premium) username", async ({ page, users, features }) => {
|
||||
test("Signup with valid (non premium) username", async ({ page, users }) => {
|
||||
const userToCreate = users.buildForSignup({
|
||||
username: "rick-jones",
|
||||
password: "Password99!",
|
||||
@@ -112,14 +158,20 @@ test.describe("Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill(userToCreate.username);
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL("/auth/verify-email**");
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
@@ -131,7 +183,11 @@ test.describe("Signup Flow Test", async () => {
|
||||
test("Signup fields prefilled with query params", async ({ page, users }) => {
|
||||
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
|
||||
await page.goto(signupUrlWithParams);
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
|
||||
// Fill form
|
||||
const usernameInput = page.locator('input[name="username"]');
|
||||
@@ -181,7 +237,8 @@ test.describe("Signup Flow Test", async () => {
|
||||
|
||||
const signupUrlWithToken = `/signup?token=${token}`;
|
||||
await page.goto(signupUrlWithToken);
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
|
||||
const usernameField = page.locator('input[name="username"]');
|
||||
const emailField = page.locator('input[name="email"]');
|
||||
@@ -212,15 +269,18 @@ test.describe("Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill(userToCreate.username);
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).toBeEnabled();
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
|
||||
@@ -283,7 +343,10 @@ test.describe("Signup Flow Test", async () => {
|
||||
|
||||
const url = new URL(newPage.url());
|
||||
expect(url.pathname).toBe("/signup");
|
||||
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
// Check required fields
|
||||
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
@@ -292,4 +355,27 @@ test.describe("Signup Flow Test", async () => {
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test("Checkbox for cookie consent does not need to be checked", async ({ page, users }) => {
|
||||
await page.goto("/signup");
|
||||
await preventFlakyTest(page);
|
||||
|
||||
// Navigate to email form
|
||||
await page.getByTestId("continue-with-email-button").click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("pro");
|
||||
await page.locator('input[name="email"]').fill("pro@example.com");
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
|
||||
|
||||
await checkbox.check();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// the cookie consent checkbox does not need to be checked for user to proceed
|
||||
await checkbox.uncheck();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
|
||||
id: teamEventId,
|
||||
} = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto("/event-types");
|
||||
await page.goto(`/event-types?teamId=${team.id}`);
|
||||
|
||||
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
|
||||
await page.getByTestId("embed").click();
|
||||
|
||||
@@ -106,6 +106,7 @@ test.describe("BOOKING_CREATED", async () => {
|
||||
location: "[redacted/dynamic]",
|
||||
destinationCalendar: null,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
requiresConfirmation: "[redacted/dynamic]",
|
||||
eventTypeId: "[redacted/dynamic]",
|
||||
seatsShowAttendees: true,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="100" height="100" viewBox="0 0 48 48">
|
||||
<path fill="#FFC107"
|
||||
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
<path fill="#FF3D00"
|
||||
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||
<path fill="#4CAF50"
|
||||
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||
<path fill="#1976D2"
|
||||
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "تسجيل الدخول عبر Google",
|
||||
"signin_with_saml": "تسجيل الدخول عبر SAML",
|
||||
"signin_with_saml_oidc": "تسجيل الدخول عبر SAML/OIDC",
|
||||
"continue_with_email": "الاستمرار باستخدام البريد الإلكتروني",
|
||||
"continue_with_google": "الاستمرار باستخدام جوجل",
|
||||
"last_used": "آخر استخدام",
|
||||
"you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
|
||||
"import": "استيراد",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} مثبت",
|
||||
"verify_wallet": "تأكيد المحفظة",
|
||||
"create_events_on": "إنشاء أحداث في",
|
||||
"enterprise_license": "هذه هي ميزة للمؤسسات",
|
||||
"enterprise_license": "هذه ميزة تجارية",
|
||||
"enterprise_license_locally": "يمكنك تجرِبة هذه المِيزة على جهازك وليس على البيئة الإنتاجية.",
|
||||
"enterprise_license_sales": "للترقية إلى الإصدار المؤسسي، يرجى الاتصال بفريق المبيعات. إذا كان مفتاح الترخيص موجوداً بالفعل، يرجى الاتصال بـ support@cal.com للحصول على المساعدة.",
|
||||
"enterprise_license_sales": "لشراء ترخيص تجاري، يرجى التواصل مع فريق المبيعات لدينا. إذا كان لديك مفتاح ترخيص بالفعل، يرجى الاتصال بـ support@cal.com للحصول على المساعدة.",
|
||||
"missing_license": "الترخيص مفقود",
|
||||
"next_steps": "الخطوات التالية",
|
||||
"acquire_commercial_license": "الحصول على ترخيص تجاري",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "اختيار الوقت",
|
||||
"select_date": "اختيار التاريخ",
|
||||
"connecting_you_to_someone": "نقوم بربطك بشخص ما.",
|
||||
"please_do_not_close_this_tab": "يرجى عدم إغلاق هذه النافذة",
|
||||
"please_do_not_close_this_tab": "من فضلك لا تغلق أو تحدّث هذه الصفحة",
|
||||
"see_all_available_times": "رؤية كل الأوقات المتاحة",
|
||||
"org_team_names_example_1": "مثال، فريق التسويق",
|
||||
"org_team_names_example_2": "مثال، فريق المبيعات",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "منع الأعضاء من إنشاء أنواع الأحداث الخاصة بهم.",
|
||||
"add_to_event_type": "إضافة إلى نوع الحدث",
|
||||
"create_account_password": "إنشاء كلمة مرور للحساب",
|
||||
"create_account_with_saml": "إنشاء حساب باستخدام SAML",
|
||||
"error_creating_account_password": "فشل في إنشاء كلمة مرور للحساب",
|
||||
"cannot_create_account_password_cal_provider": "لا يمكن إنشاء كلمة مرور للحسابات الخاصة بـ cal",
|
||||
"cannot_create_account_password_already_existing": "لا يمكن إنشاء كلمة مرور للحسابات التي تم إنشاؤها بالفعل",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "اتصال بتقويم Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "تم الاتصال بتقويم Outlook",
|
||||
"outlook_connect_atom_loading_label": "جارٍ التحقق من تقويم Outlook",
|
||||
"stripe_connect_atom_label": "الاتصال بـ Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "تم الاتصال بـ Stripe",
|
||||
"stripe_connect_atom_loading_label": "جارٍ التحقق من بيانات اعتماد Stripe",
|
||||
"booking_question_response_variables": "متغيرات استجابة أسئلة الحجز",
|
||||
"managed_by_teamAdmins": "يدار بواسطة {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} خيارات",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "حدد عدد المرات التي يمكن فيها حجز الأعضاء عبر جميع أنواع فعاليات الفريق (الجماعية وround-robin)",
|
||||
"booking_limits_updated_successfully": "تم تحديث حدود الحجز بنجاح",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز",
|
||||
"hide_calendar_event_details": "إخفاء تفاصيل الأحداث في التقويمات المشتركة",
|
||||
"description_hide_calendar_event_details": "عند مشاركة التقويم، تكون الأحداث مرئية للقراء ولكن تفاصيلها تكون مخفية عن أولئك الذين ليس لديهم صلاحية الكتابة.",
|
||||
"last_number_of_days": "آخر {{count}} يوم",
|
||||
"month_to_date": "منذ بداية الشهر",
|
||||
"year_to_date": "منذ بداية السنة",
|
||||
"custom_range": "نطاق مخصص",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Google ilə daxil olun",
|
||||
"signin_with_saml": "SAML ilə daxil olun",
|
||||
"signin_with_saml_oidc": "SAML/OIDC ilə daxil olun",
|
||||
"continue_with_email": "Email ilə davam edin",
|
||||
"continue_with_google": "Google ilə davam edin",
|
||||
"last_used": "Son istifadə",
|
||||
"you_will_need_to_generate": "Köhnə planlama alətinizdən bir giriş tokeni yaratmalısınız.",
|
||||
"import": "İdxal et",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} quraşdırılıb",
|
||||
"verify_wallet": "Cüzdanı Təsdiqlə",
|
||||
"create_events_on": "Tədbirləri yarat",
|
||||
"enterprise_license": "Bu, müəssisə xüsusiyyətidir",
|
||||
"enterprise_license": "Bu kommersiya xüsusiyyətidir",
|
||||
"enterprise_license_locally": "Bu xüsusiyyəti yerli olaraq sınaqdan keçirə bilərsiniz, lakin istehsalda deyil.",
|
||||
"enterprise_license_sales": "Müəssisə versiyasına yüksəltmək üçün satış komandamızla əlaqə saxlayın. Əgər lisenziya açarı artıq mövcuddursa, kömək üçün support@cal.com ilə əlaqə saxlayın.",
|
||||
"enterprise_license_sales": "Kommersiya lisenziyası almaq üçün satış komandamızla əlaqə saxlayın. Əgər lisenziya açarı artıq mövcuddursa, kömək üçün support@cal.com ilə əlaqə saxlayın.",
|
||||
"missing_license": "Lisenziya yoxdur",
|
||||
"next_steps": "Növbəti Addımlar",
|
||||
"acquire_commercial_license": "Kommersiya lisenziyası əldə edin",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Vaxtı Seçin",
|
||||
"select_date": "Tarixi Seçin",
|
||||
"connecting_you_to_someone": "Sizi kiməsə qoşuruq.",
|
||||
"please_do_not_close_this_tab": "Zəhmət olmasa, bu nişanı bağlamayın",
|
||||
"please_do_not_close_this_tab": "Zəhmət olmasa, bu səhifəni bağlamayın və ya yeniləməyin",
|
||||
"see_all_available_times": "Bütün mövcud vaxtları görün",
|
||||
"org_team_names_example_1": "məs. Marketinq Komandası",
|
||||
"org_team_names_example_2": "məs. Satış Komandası",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Üzvlərin öz tədbir növlərini yaratmasının qarşısını al.",
|
||||
"add_to_event_type": "Tədbir növünə əlavə et",
|
||||
"create_account_password": "Hesab parolu yarat",
|
||||
"create_account_with_saml": "SAML ilə hesab yaradın",
|
||||
"error_creating_account_password": "Hesab parolu yaratmaq alınmadı",
|
||||
"cannot_create_account_password_cal_provider": "cal hesabları üçün hesab parolu yaradıla bilmir",
|
||||
"cannot_create_account_password_already_existing": "Artıq yaradılmışlar üçün hesab parolu yaradıla bilmir",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Təqvimini Bağla",
|
||||
"outlook_connect_atom_already_connected_label": "Bağlı Outlook Təqvimi",
|
||||
"outlook_connect_atom_loading_label": "Outlook Təqvimi Yoxlanılır",
|
||||
"stripe_connect_atom_label": "Stripe-ə qoşulun",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe qoşulub",
|
||||
"stripe_connect_atom_loading_label": "Stripe məlumatları yoxlanılır",
|
||||
"booking_question_response_variables": "Rezervasiya sual cavab dəyişənləri",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} tərəfindən idarə olunur",
|
||||
"number_of_options": "{{count}} seçim",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Komanda üzvlərinin bütün komanda tədbir növləri (kollektiv və round-robin) üzrə neçə dəfə rezervasiya edilə biləcəyini məhdudlaşdırın",
|
||||
"booking_limits_updated_successfully": "Rezervasiya məhdudiyyətləri uğurla yeniləndi",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur",
|
||||
"hide_calendar_event_details": "Təqvim hadisə detallarını gizlət",
|
||||
"description_hide_calendar_event_details": "Təqvim paylaşıldıqda, hadisələr oxucular üçün görünür, lakin yazma icazəsi olmayanlar üçün detallar gizlədilir.",
|
||||
"last_number_of_days": "son {{count}} gün",
|
||||
"month_to_date": "bu ayın tarixinə qədər",
|
||||
"year_to_date": "bu ilin tarixinə qədər",
|
||||
"custom_range": "xüsusi aralıq",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Přihlásit se pomocí Googlu",
|
||||
"signin_with_saml": "Přihlásit se pomocí SAML",
|
||||
"signin_with_saml_oidc": "Přihlášení pomocí SAML/OIDC",
|
||||
"continue_with_email": "Pokračovat s e-mailem",
|
||||
"continue_with_google": "Pokračovat s Googlem",
|
||||
"last_used": "Naposledy použito",
|
||||
"you_will_need_to_generate": "Budete muset vygenerovat přístupový token ve vašem dřívějším plánovacím nástroji.",
|
||||
"import": "Importovat",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "Nainstalováno: {{count}}",
|
||||
"verify_wallet": "Ověřit peněženku",
|
||||
"create_events_on": "Vytvořit události v:",
|
||||
"enterprise_license": "Jedná se o firemní funkci",
|
||||
"enterprise_license": "Toto je komerční funkce",
|
||||
"enterprise_license_locally": "Tuto funkci můžete testovat lokálně, ale ne v produkčním prostředí.",
|
||||
"enterprise_license_sales": "Pro upgrade na enterprise edici kontaktujte náš prodejní tým. Pokud již máte licenční klíč, kontaktujte prosím support@cal.com pro pomoc.",
|
||||
"enterprise_license_sales": "Pro zakoupení komerční licence se obraťte na náš prodejní tým. Pokud již máte licenční klíč, kontaktujte prosím podporu na adrese support@cal.com pro pomoc.",
|
||||
"missing_license": "Chybějící licence",
|
||||
"next_steps": "Další kroky",
|
||||
"acquire_commercial_license": "Získat komerční licenci",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Vyberte čas",
|
||||
"select_date": "Vyberte datum",
|
||||
"connecting_you_to_someone": "Spojujeme vás s někým.",
|
||||
"please_do_not_close_this_tab": "Prosím, nezavírejte tuto kartu.",
|
||||
"please_do_not_close_this_tab": "Prosím, nezavírejte ani neobnovujte tuto stránku",
|
||||
"see_all_available_times": "Zobrazit všechny dostupné časy",
|
||||
"org_team_names_example_1": "Např. marketingový tým",
|
||||
"org_team_names_example_2": "Např. obchodní tým",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Zabránit členům ve vytváření vlastních typů událostí.",
|
||||
"add_to_event_type": "Přidat k typu události",
|
||||
"create_account_password": "Vytvořit heslo k účtu",
|
||||
"create_account_with_saml": "Vytvořit účet pomocí SAML",
|
||||
"error_creating_account_password": "Nepodařilo se vytvořit heslo k účtu",
|
||||
"cannot_create_account_password_cal_provider": "Nelze vytvořit heslo k účtu pro cal účty",
|
||||
"cannot_create_account_password_already_existing": "Nelze vytvořit heslo k účtu pro již vytvořené účty",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Připojit Outlook Kalendář",
|
||||
"outlook_connect_atom_already_connected_label": "Připojený Outlook Kalendář",
|
||||
"outlook_connect_atom_loading_label": "Kontrola kalendáře Outlook",
|
||||
"stripe_connect_atom_label": "Připojit ke Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe připojeno",
|
||||
"stripe_connect_atom_loading_label": "Kontrola přihlašovacích údajů Stripe",
|
||||
"booking_question_response_variables": "Proměnné odpovědí na otázky při rezervaci",
|
||||
"managed_by_teamAdmins": "Spravováno {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} možností",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Omezte, kolikrát mohou být členové rezervováni napříč všemi typy týmových událostí (kolektivní a round-robin)",
|
||||
"booking_limits_updated_successfully": "Limity rezervací byly úspěšně aktualizovány",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci",
|
||||
"hide_calendar_event_details": "Skrýt podrobnosti událostí v kalendáři na sdílených kalendářích",
|
||||
"description_hide_calendar_event_details": "Když je kalendář sdílen, události jsou viditelné pro čtenáře, ale jejich podrobnosti jsou skryty pro ty, kteří nemají přístup k úpravám.",
|
||||
"last_number_of_days": "posledních {{count}} dní",
|
||||
"month_to_date": "od začátku měsíce",
|
||||
"year_to_date": "od začátku roku",
|
||||
"custom_range": "vlastní rozsah",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Log ind med Google",
|
||||
"signin_with_saml": "Log ind med SAML",
|
||||
"signin_with_saml_oidc": "Log ind med SAML/OIDC",
|
||||
"continue_with_email": "Fortsæt med e-mail",
|
||||
"continue_with_google": "Fortsæt med Google",
|
||||
"last_used": "Sidst brugt",
|
||||
"you_will_need_to_generate": "Du skal generere et adgangs token fra dit gamle planlægningsværktøj.",
|
||||
"import": "Importér",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installeret",
|
||||
"verify_wallet": "Verificér Wallet",
|
||||
"create_events_on": "Opret begivenheder på",
|
||||
"enterprise_license": "Dette er en virksomhedsfunktion",
|
||||
"enterprise_license": "Dette er en kommerciel funktion",
|
||||
"enterprise_license_locally": "Du kan teste denne funktion lokalt, men ikke i produktion.",
|
||||
"enterprise_license_sales": "For at opgradere til enterprise-udgaven, kontakt vores salgsteam. Hvis en licensnøgle allerede er på plads, kontakt support@cal.com for hjælp.",
|
||||
"enterprise_license_sales": "For at købe en kommerciel licens, kontakt venligst vores salgsteam. Hvis du allerede har en licensnøgle, kontakt venligst support@cal.com for hjælp.",
|
||||
"missing_license": "Manglende Licens",
|
||||
"next_steps": "Næste Trin",
|
||||
"acquire_commercial_license": "Erhverv en kommerciel licens",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Vælg tid",
|
||||
"select_date": "Vælg dato",
|
||||
"connecting_you_to_someone": "Vi forbinder dig med en person.",
|
||||
"please_do_not_close_this_tab": "Luk venligst ikke denne fane",
|
||||
"please_do_not_close_this_tab": "Luk eller opdater venligst ikke denne side",
|
||||
"see_all_available_times": "Se alle tilgængelige tidspunkter",
|
||||
"org_team_names_example_1": "f.eks. Marketingteam",
|
||||
"org_team_names_example_2": "f.eks. Salgsteam",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Forhindre medlemmer i at oprette deres egne begivenhedstyper.",
|
||||
"add_to_event_type": "Tilføj til begivenhedstype",
|
||||
"create_account_password": "Opret kontoadgangskode",
|
||||
"create_account_with_saml": "Opret konto med SAML",
|
||||
"error_creating_account_password": "Kunne ikke oprette kontoadgangskode",
|
||||
"cannot_create_account_password_cal_provider": "Kan ikke oprette kontoadgangskode for cal-konti",
|
||||
"cannot_create_account_password_already_existing": "Kan ikke oprette kontoadgangskode for allerede oprettede konti",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Forbind Outlook Kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Forbundet Outlook Kalender",
|
||||
"outlook_connect_atom_loading_label": "Kontrollerer Outlook Kalender",
|
||||
"stripe_connect_atom_label": "Forbind til Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe forbundet",
|
||||
"stripe_connect_atom_loading_label": "Kontrollerer Stripe legitimationsoplysninger",
|
||||
"booking_question_response_variables": "Booking spørgsmål svar variabler",
|
||||
"managed_by_teamAdmins": "Administreret af {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} muligheder",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Begræns, hvor mange gange medlemmer kan bookes på tværs af alle teambegivenhedstyper (kollektiv og round-robin)",
|
||||
"booking_limits_updated_successfully": "Bookinggrænser opdateret med succes",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen",
|
||||
"hide_calendar_event_details": "Skjul kalenderbegivenhedsdetaljer på delte kalendere",
|
||||
"description_hide_calendar_event_details": "Når en kalender deles, er begivenheder synlige for læsere, men deres detaljer er skjult for dem uden skriveadgang.",
|
||||
"last_number_of_days": "sidste {{count}} dage",
|
||||
"month_to_date": "måned til dato",
|
||||
"year_to_date": "år til dato",
|
||||
"custom_range": "tilpasset interval",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Mit Google anmelden",
|
||||
"signin_with_saml": "Anmelden mit SAML",
|
||||
"signin_with_saml_oidc": "Anmelden mit SAML/OIDC",
|
||||
"continue_with_email": "Mit E-Mail fortfahren",
|
||||
"continue_with_google": "Mit Google fortfahren",
|
||||
"last_used": "Zuletzt verwendet",
|
||||
"you_will_need_to_generate": "Sie müssen ein Zugriffstoken in ihrem alten Kalender-Tool erzeugen.",
|
||||
"import": "Importieren",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installiert",
|
||||
"verify_wallet": "Wallet verifizieren",
|
||||
"create_events_on": "Erstelle Termine in:",
|
||||
"enterprise_license": "Das ist eine Enterprise-Funktion",
|
||||
"enterprise_license": "Dies ist eine kommerzielle Funktion",
|
||||
"enterprise_license_locally": "Sie können diese Funktion lokal testen, aber nicht in der Produktionsumgebung.",
|
||||
"enterprise_license_sales": "Um zur Enterprise Edition upzugraden, wenden Sie sich bitte an unser Vertriebsteam. Wenn bereits ein Lizenzschlüssel vorhanden ist, wenden Sie sich für weitere Hilfe bitte an support@cal.com.",
|
||||
"enterprise_license_sales": "Um eine kommerzielle Lizenz zu erwerben, wenden Sie sich bitte an unser Vertriebsteam. Falls bereits ein Lizenzschlüssel vorhanden ist, kontaktieren Sie bitte support@cal.com für Unterstützung.",
|
||||
"missing_license": "Lizenz fehlt",
|
||||
"next_steps": "Nächste Schritte",
|
||||
"acquire_commercial_license": "Eine kommerzielle Lizenz erwerben",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Zeit auswählen",
|
||||
"select_date": "Datum auswählen",
|
||||
"connecting_you_to_someone": "Wir verbinden Sie mit jemandem.",
|
||||
"please_do_not_close_this_tab": "Diesen Tab bitte nicht schließen",
|
||||
"please_do_not_close_this_tab": "Bitte schließen oder aktualisieren Sie diese Seite nicht",
|
||||
"see_all_available_times": "Alle verfügbaren Zeiten ansehen",
|
||||
"org_team_names_example_1": "z.B. Marketing-Team",
|
||||
"org_team_names_example_2": "z.B. Vertriebsteam",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Mitglieder daran hindern, ihre eigenen Termintypen zu erstellen.",
|
||||
"add_to_event_type": "Zu Termintyp hinzufügen",
|
||||
"create_account_password": "Kontopasswort erstellen",
|
||||
"create_account_with_saml": "Konto mit SAML erstellen",
|
||||
"error_creating_account_password": "Passwort konnte nicht erstellt werden",
|
||||
"cannot_create_account_password_cal_provider": "Kann kein Kontopasswort für Cal-Konten erstellen",
|
||||
"cannot_create_account_password_already_existing": "Es kann kein Passwort für bereits erstellte Konten erstellt werden",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Kalender verbinden",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Kalender verbunden",
|
||||
"outlook_connect_atom_loading_label": "Outlook Kalender wird überprüft",
|
||||
"stripe_connect_atom_label": "Mit Stripe verbinden",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe verbunden",
|
||||
"stripe_connect_atom_loading_label": "Überprüfe Stripe-Anmeldedaten",
|
||||
"booking_question_response_variables": "Variablen für Buchungsfragen-Antworten",
|
||||
"managed_by_teamAdmins": "Verwaltet von {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} Optionen",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Begrenzen Sie, wie oft Mitglieder über alle Team-Ereignistypen hinweg (kollektiv und round-robin) gebucht werden können",
|
||||
"booking_limits_updated_successfully": "Buchungslimits erfolgreich aktualisiert",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen",
|
||||
"hide_calendar_event_details": "Kalenderereignisdetails in geteilten Kalendern verbergen",
|
||||
"description_hide_calendar_event_details": "Wenn ein Kalender geteilt wird, sind Ereignisse für Leser sichtbar, aber ihre Details bleiben für Personen ohne Schreibzugriff verborgen.",
|
||||
"last_number_of_days": "letzte {{count}} Tage",
|
||||
"month_to_date": "aktueller Monat",
|
||||
"year_to_date": "aktuelles Jahr",
|
||||
"custom_range": "benutzerdefinierter Zeitraum",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Sign in with Google",
|
||||
"signin_with_saml": "Sign in with SAML",
|
||||
"signin_with_saml_oidc": "Sign in with SAML/OIDC",
|
||||
"continue_with_email": "Continue with email",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"last_used": "Last used",
|
||||
"you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.",
|
||||
"import": "Import",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installed",
|
||||
"verify_wallet": "Verify Wallet",
|
||||
"create_events_on": "Create events on",
|
||||
"enterprise_license": "This is an enterprise feature",
|
||||
"enterprise_license": "This is a commercial feature",
|
||||
"enterprise_license_locally": "You can test this feature locally but not on production.",
|
||||
"enterprise_license_sales": "To upgrade to the enterprise edition, please reach out to our sales team. If a license key is already in place, please contact support@cal.com for help.",
|
||||
"enterprise_license_sales": "To purchase a commercial license, please reach out to our sales team. If a license key is already in place, please contact support@cal.com for help.",
|
||||
"missing_license": "Missing License",
|
||||
"next_steps": "Next Steps",
|
||||
"acquire_commercial_license": "Acquire a commercial license",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Select Time",
|
||||
"select_date": "Select Date",
|
||||
"connecting_you_to_someone": "We are connecting you to someone.",
|
||||
"please_do_not_close_this_tab": "Please do not close this tab",
|
||||
"please_do_not_close_this_tab": "Please do not close or refresh this page",
|
||||
"see_all_available_times": "See all available times",
|
||||
"org_team_names_example_1": "e.g. Marketing Team",
|
||||
"org_team_names_example_2": "e.g. Sales Team",
|
||||
@@ -2484,6 +2486,7 @@
|
||||
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
|
||||
"add_to_event_type": "Add to event type",
|
||||
"create_account_password": "Create account password",
|
||||
"create_account_with_saml": "Create Account with SAML",
|
||||
"error_creating_account_password": "Failed to create account password",
|
||||
"cannot_create_account_password_cal_provider": "Cannot create account password for cal accounts",
|
||||
"cannot_create_account_password_already_existing": "Cannot create account password for already created ones",
|
||||
@@ -2602,6 +2605,9 @@
|
||||
"outlook_connect_atom_label": "Connect Outlook Calendar",
|
||||
"outlook_connect_atom_already_connected_label": "Connected Outlook Calendar",
|
||||
"outlook_connect_atom_loading_label": "Checking Outlook Calendar",
|
||||
"stripe_connect_atom_label": "Connect to Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Connected Stripe",
|
||||
"stripe_connect_atom_loading_label": "Checking Stripe credentials",
|
||||
"booking_question_response_variables": "Booking question response variables",
|
||||
"managed_by_teamAdmins": "Managed by {{teamAdmins}}",
|
||||
"number_of_options":"{{count}} options",
|
||||
@@ -2613,5 +2619,11 @@
|
||||
"limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types (collective and round-robin)",
|
||||
"booking_limits_updated_successfully": "Booking limits updated successfully",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
|
||||
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
|
||||
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
|
||||
"last_number_of_days": "last {{count}} days",
|
||||
"month_to_date": "month to date",
|
||||
"year_to_date": "year to date",
|
||||
"custom_range": "custom range",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Iniciar sesión con Google",
|
||||
"signin_with_saml": "Iniciar sesión con SAML",
|
||||
"signin_with_saml_oidc": "Iniciar sesión con SAML/OIDC",
|
||||
"continue_with_email": "Continuar con correo electrónico",
|
||||
"continue_with_google": "Continuar con Google",
|
||||
"last_used": "Último uso",
|
||||
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
|
||||
"import": "Importar",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} instalado(s)",
|
||||
"verify_wallet": "Verificar billetera",
|
||||
"create_events_on": "Crear eventos en",
|
||||
"enterprise_license": "Esta es una característica empresarial",
|
||||
"enterprise_license": "Esta es una función comercial",
|
||||
"enterprise_license_locally": "Puedes probar esta característica localmente pero no en producción.",
|
||||
"enterprise_license_sales": "Para actualizar a la edición empresarial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, contacta a support@cal.com para obtener ayuda.",
|
||||
"enterprise_license_sales": "Para adquirir una licencia comercial, comuníquese con nuestro equipo de ventas. Si ya tiene una clave de licencia, por favor contacte a support@cal.com para obtener ayuda.",
|
||||
"missing_license": "Licencia faltante",
|
||||
"next_steps": "Próximos pasos",
|
||||
"acquire_commercial_license": "Adquirir una licencia comercial",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Seleccionar hora",
|
||||
"select_date": "Seleccionar fecha",
|
||||
"connecting_you_to_someone": "Te estamos conectando con alguien.",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
|
||||
"see_all_available_times": "Ver todos los horarios disponibles",
|
||||
"org_team_names_example_1": "p. ej. Equipo de Marketing",
|
||||
"org_team_names_example_2": "p. ej. Equipo de Ventas",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Evitar que los miembros creen sus propios tipos de eventos.",
|
||||
"add_to_event_type": "Agregar al tipo de evento",
|
||||
"create_account_password": "Crear contraseña de cuenta",
|
||||
"create_account_with_saml": "Crear cuenta con SAML",
|
||||
"error_creating_account_password": "Error al crear la contraseña de la cuenta",
|
||||
"cannot_create_account_password_cal_provider": "No se puede crear la contraseña de la cuenta para cuentas cal",
|
||||
"cannot_create_account_password_already_existing": "No se puede crear la contraseña de la cuenta para las ya creadas",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendario de Outlook",
|
||||
"stripe_connect_atom_label": "Conectar con Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
|
||||
"booking_question_response_variables": "Variables de respuesta de preguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opciones",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limita cuántas veces se pueden reservar los miembros en todos los tipos de eventos del equipo (colectivos y round-robin)",
|
||||
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
|
||||
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
|
||||
"last_number_of_days": "últimos {{count}} días",
|
||||
"month_to_date": "mes hasta la fecha",
|
||||
"year_to_date": "año hasta la fecha",
|
||||
"custom_range": "rango personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Iniciar sesión con Google",
|
||||
"signin_with_saml": "Iniciar sesión con SAML",
|
||||
"signin_with_saml_oidc": "Inicie sesión con SAML/OIDC",
|
||||
"continue_with_email": "Continuar con correo electrónico",
|
||||
"continue_with_google": "Continuar con Google",
|
||||
"last_used": "Último uso",
|
||||
"you_will_need_to_generate": "Necesitarás generar un token de acceso desde tu antigua herramienta de programación.",
|
||||
"import": "Importar",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} instaladas",
|
||||
"verify_wallet": "Verificar billetera",
|
||||
"create_events_on": "Crear eventos en",
|
||||
"enterprise_license": "Esta es una función empresarial",
|
||||
"enterprise_license": "Esta es una función comercial",
|
||||
"enterprise_license_locally": "Puedes probar esta función localmente pero no en producción.",
|
||||
"enterprise_license_sales": "Para actualizar a la edición empresarial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, por favor contacta a support@cal.com para obtener ayuda.",
|
||||
"enterprise_license_sales": "Para adquirir una licencia comercial, por favor contacta a nuestro equipo de ventas. Si ya tienes una clave de licencia, contacta a support@cal.com para obtener ayuda.",
|
||||
"missing_license": "Falta la licencia",
|
||||
"next_steps": "Pasos siguientes",
|
||||
"acquire_commercial_license": "Adquirir una licencia comercial",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Seleccione la hora",
|
||||
"select_date": "Seleccione la fecha",
|
||||
"connecting_you_to_someone": "Te estamos conectando con alguien.",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
|
||||
"see_all_available_times": "Ver todas las horas disponibles",
|
||||
"org_team_names_example_1": "ej. Equipo de marketing",
|
||||
"org_team_names_example_2": "ej. Equipo de ventas",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Evitar que los miembros creen sus propios tipos de eventos.",
|
||||
"add_to_event_type": "Añadir al tipo de evento",
|
||||
"create_account_password": "Crear contraseña de cuenta",
|
||||
"create_account_with_saml": "Crear cuenta con SAML",
|
||||
"error_creating_account_password": "Error al crear la contraseña de la cuenta",
|
||||
"cannot_create_account_password_cal_provider": "No se puede crear la contraseña de la cuenta para cuentas de calendario",
|
||||
"cannot_create_account_password_already_existing": "No se puede crear la contraseña de la cuenta para las ya existentes",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Comprobando el calendario de Outlook",
|
||||
"stripe_connect_atom_label": "Conectar con Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
|
||||
"booking_question_response_variables": "Variables de respuesta a preguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opciones",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limita cuántas veces se pueden reservar los miembros en todos los tipos de eventos del equipo (colectivos y round-robin)",
|
||||
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
|
||||
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
|
||||
"last_number_of_days": "últimos {{count}} días",
|
||||
"month_to_date": "mes hasta la fecha",
|
||||
"year_to_date": "año hasta la fecha",
|
||||
"custom_range": "rango personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Logi sisse Google'iga",
|
||||
"signin_with_saml": "Logi sisse SAML-iga",
|
||||
"signin_with_saml_oidc": "Logi sisse SAML-i/OIDC-ga",
|
||||
"continue_with_email": "Jätka e-postiga",
|
||||
"continue_with_google": "Jätka Google'iga",
|
||||
"last_used": "Viimati kasutatud",
|
||||
"you_will_need_to_generate": "Peate oma vanast ajastamistööriistast looma juurdepääsuluba.",
|
||||
"import": "Import",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installitud",
|
||||
"verify_wallet": "Kinnita rahakott",
|
||||
"create_events_on": "Loo sündmusi",
|
||||
"enterprise_license": "See on ettevõtte funktsioon",
|
||||
"enterprise_license": "See on ärifunktsioon",
|
||||
"enterprise_license_locally": "Saate seda funktsiooni kohapeal testida, kuid mitte tootmises.",
|
||||
"enterprise_license_sales": "Ettevõtte versioonile üleminekuks võtke ühendust meie müügimeeskonnaga. Kui litsentsivõti on juba olemas, võtke abi saamiseks ühendust aadressil support@cal.com.",
|
||||
"enterprise_license_sales": "Ärilise litsentsi ostmiseks võtke palun ühendust meie müügimeeskonnaga. Kui litsentsivõti on juba olemas, pöörduge abi saamiseks aadressil support@cal.com.",
|
||||
"missing_license": "Puuduv litsents",
|
||||
"next_steps": "Järgmised sammud",
|
||||
"acquire_commercial_license": "Oma ärilitsents",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Vali aeg",
|
||||
"select_date": "Vali kuupäev",
|
||||
"connecting_you_to_someone": "Me ühendame teid kellegagi.",
|
||||
"please_do_not_close_this_tab": "Palun ärge sulgege seda vahekaarti",
|
||||
"please_do_not_close_this_tab": "Palun ärge sulgege ega värskendage seda lehte",
|
||||
"see_all_available_times": "Vaata kõiki saadaolevaid aegu",
|
||||
"org_team_names_example_1": "nt turundusmeeskond",
|
||||
"org_team_names_example_2": "nt müügimeeskond",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Takistage liikmetel oma sündmuste tüüpide loomist.",
|
||||
"add_to_event_type": "Lisa sündmuse tüübile",
|
||||
"create_account_password": "Loo konto parool",
|
||||
"create_account_with_saml": "Loo konto SAML-iga",
|
||||
"error_creating_account_password": "Konto parooli loomine ebaõnnestus",
|
||||
"cannot_create_account_password_cal_provider": "Ei saa luua konto parooli cal kontodele",
|
||||
"cannot_create_account_password_already_existing": "Ei saa luua konto parooli juba loodud paroolidele",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Ühenda Outlooki kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Outlooki kalender on ühendatud",
|
||||
"outlook_connect_atom_loading_label": "Outlooki kalendri kontrollimine",
|
||||
"stripe_connect_atom_label": "Ühenda Stripe'iga",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe on ühendatud",
|
||||
"stripe_connect_atom_loading_label": "Stripe'i andmete kontrollimine",
|
||||
"booking_question_response_variables": "Broneerimisküsimuste vastuse muutujad",
|
||||
"managed_by_teamAdmins": "Haldab {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} valikut",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Piirake, mitu korda saab liikmeid broneerida kõigi meeskonna sündmusetüüpide lõikes (ühised ja round-robin)",
|
||||
"booking_limits_updated_successfully": "Broneerimispiirangud on edukalt uuendatud",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks",
|
||||
"hide_calendar_event_details": "Peida kalendrisündmuste üksikasjad jagatud kalendrites",
|
||||
"description_hide_calendar_event_details": "Kui kalender on jagatud, on sündmused lugejatele nähtavad, kuid nende üksikasjad on peidetud nende eest, kellel puudub kirjutamisõigus.",
|
||||
"last_number_of_days": "viimased {{count}} päeva",
|
||||
"month_to_date": "kuu algusest tänaseni",
|
||||
"year_to_date": "aasta algusest tänaseni",
|
||||
"custom_range": "kohandatud vahemik",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Se connecter avec Google",
|
||||
"signin_with_saml": "Se connecter avec SAML",
|
||||
"signin_with_saml_oidc": "Se connecter avec SAML/OIDC",
|
||||
"continue_with_email": "Continuer avec l'e-mail",
|
||||
"continue_with_google": "Continuer avec Google",
|
||||
"last_used": "Dernière utilisation",
|
||||
"you_will_need_to_generate": "Vous devrez générer un token d'accès à partir de votre ancien outil de planification.",
|
||||
"import": "Importer",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installation(s)",
|
||||
"verify_wallet": "Vérifier le portefeuille",
|
||||
"create_events_on": "Créer des événements dans :",
|
||||
"enterprise_license": "Il s'agit d'une fonctionnalité d'entreprise",
|
||||
"enterprise_license": "Ceci est une fonctionnalité commerciale",
|
||||
"enterprise_license_locally": "Vous pouvez tester cette fonctionnalité en local, mais pas en production.",
|
||||
"enterprise_license_sales": "Pour passer sur l'édition entreprise, veuillez contacter notre équipe commerciale. Si une clé de licence est déjà en place, veuillez contacter support@cal.com pour obtenir de l'aide.",
|
||||
"enterprise_license_sales": "Pour acheter une licence commerciale, veuillez contacter notre équipe commerciale. Si une clé de licence est déjà en place, veuillez contacter support@cal.com pour obtenir de l'aide.",
|
||||
"missing_license": "Licence manquante",
|
||||
"next_steps": "Prochaines étapes",
|
||||
"acquire_commercial_license": "Obtenir une licence commerciale",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Sélectionner un créneau",
|
||||
"select_date": "Sélectionner une date",
|
||||
"connecting_you_to_someone": "Nous vous mettons en relation avec quelqu'un.",
|
||||
"please_do_not_close_this_tab": "Veuillez ne pas fermer cet onglet",
|
||||
"please_do_not_close_this_tab": "Veuillez ne pas fermer ou actualiser cette page",
|
||||
"see_all_available_times": "Voir tous les créneaux disponibles",
|
||||
"org_team_names_example_1": "p. ex. Équipe marketing",
|
||||
"org_team_names_example_2": "p. ex. Équipe de vente",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Empêchez les membres de créer leurs propres types d'évènements.",
|
||||
"add_to_event_type": "Ajouter un type d'évènement",
|
||||
"create_account_password": "Créer le mot de passe du compte",
|
||||
"create_account_with_saml": "Créer un compte avec SAML",
|
||||
"error_creating_account_password": "Échec de la création du mot de passe du compte",
|
||||
"cannot_create_account_password_cal_provider": "Impossible de créer un mot de passe pour les comptes cal",
|
||||
"cannot_create_account_password_already_existing": "Impossible de créer un mot de passe pour un compte déjà créé",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Connecter le calendrier Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendrier Outlook connecté",
|
||||
"outlook_connect_atom_loading_label": "Vérification du calendrier Outlook",
|
||||
"stripe_connect_atom_label": "Se connecter à Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe connecté",
|
||||
"stripe_connect_atom_loading_label": "Vérification des identifiants Stripe",
|
||||
"booking_question_response_variables": "Variables de réponse aux questions de réservation",
|
||||
"managed_by_teamAdmins": "Géré par {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} options",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limitez le nombre de fois où les membres peuvent être réservés pour tous les types d'événements d'équipe (collectifs et round robin)",
|
||||
"booking_limits_updated_successfully": "Limites de réservation mises à jour avec succès",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation",
|
||||
"hide_calendar_event_details": "Masquer les détails des événements du calendrier sur les calendriers partagés",
|
||||
"description_hide_calendar_event_details": "Lorsqu'un calendrier est partagé, les événements sont visibles pour les lecteurs, mais leurs détails sont cachés pour ceux qui n'ont pas d'accès en écriture.",
|
||||
"last_number_of_days": "{{count}} derniers jours",
|
||||
"month_to_date": "mois en cours",
|
||||
"year_to_date": "année en cours",
|
||||
"custom_range": "période personnalisée",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "כניסה עם Google",
|
||||
"signin_with_saml": "כניסה עם SAML",
|
||||
"signin_with_saml_oidc": "כניסה עם SAML/OIDC",
|
||||
"continue_with_email": "המשך עם אימייל",
|
||||
"continue_with_google": "המשך עם Google",
|
||||
"last_used": "שימוש אחרון",
|
||||
"you_will_need_to_generate": "תצטרך ליצור טוקן גישה מהכלי הישן שבו השתמשת לקביעת מועדים.",
|
||||
"import": "ייבוא",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} התקנות",
|
||||
"verify_wallet": "אימות הארנק",
|
||||
"create_events_on": "ליצור אירועים ב-",
|
||||
"enterprise_license": "תכונה זו זמינה רק במינוי Enterprise",
|
||||
"enterprise_license": "זוהי תכונה מסחרית",
|
||||
"enterprise_license_locally": "אפשר לבדוק את היכולת הזאת מקומית אבל לא בסביבה המבצעית.",
|
||||
"enterprise_license_sales": "כדי לשדרג למהדורה הארגונית, נא לגשת לצוות המכירות שלנו. אם כבר יש לך מפתח רישיון, נא ליצור קשר עם support@cal.com לקבלת עזרה.",
|
||||
"enterprise_license_sales": "כדי לרכוש רישיון מסחרי, אנא פנו לצוות המכירות שלנו. אם מפתח רישיון כבר קיים, אנא צרו קשר עם support@cal.com לקבלת עזרה.",
|
||||
"missing_license": "חסר רישיון",
|
||||
"next_steps": "השלבים הבאים",
|
||||
"acquire_commercial_license": "רכישת רישיון לשימוש מסחרי",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "בחירת שעה",
|
||||
"select_date": "בחירת תאריך",
|
||||
"connecting_you_to_someone": "אנו מחברים אותך למישהו או מישהי.",
|
||||
"please_do_not_close_this_tab": "נא לא לסגור את הלשונית הזאת",
|
||||
"please_do_not_close_this_tab": "אנא אל תסגור או תרענן דף זה",
|
||||
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
|
||||
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
|
||||
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "מנע מחברים ליצור סוגי אירועים משלהם.",
|
||||
"add_to_event_type": "הוספה לסוגי האירועים",
|
||||
"create_account_password": "יצירת סיסמת חשבון",
|
||||
"create_account_with_saml": "צור חשבון עם SAML",
|
||||
"error_creating_account_password": "יצירת סיסמת חשבון נכשלה",
|
||||
"cannot_create_account_password_cal_provider": "לא ניתן ליצור סיסמת חשבון לחשבונות cal",
|
||||
"cannot_create_account_password_already_existing": "לא ניתן ליצור סיסמת חשבון לחשבונות שכבר נוצרה להם סיסמה",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "חבר את לוח השנה של Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "לוח השנה של Outlook מחובר",
|
||||
"outlook_connect_atom_loading_label": "בודק את לוח השנה של Outlook",
|
||||
"stripe_connect_atom_label": "התחבר ל-Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "מחובר ל-Stripe",
|
||||
"stripe_connect_atom_loading_label": "בודק אישורי Stripe",
|
||||
"booking_question_response_variables": "משתני תגובה לשאלות הזמנה",
|
||||
"managed_by_teamAdmins": "מנוהל על ידי {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} אפשרויות",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "הגבל כמה פעמים ניתן להזמין חברי צוות בכל סוגי האירועים של הצוות (קולקטיבי ו-round robin)",
|
||||
"booking_limits_updated_successfully": "מגבלות ההזמנה עודכנו בהצלחה",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה",
|
||||
"hide_calendar_event_details": "הסתר פרטי אירועי לוח שנה בלוחות שנה משותפים",
|
||||
"description_hide_calendar_event_details": "כאשר לוח שנה משותף, האירועים נראים לקוראים אך פרטיהם מוסתרים מאלו שאין להם גישת כתיבה.",
|
||||
"last_number_of_days": "ב{{count}} הימים האחרונים",
|
||||
"month_to_date": "מתחילת החודש",
|
||||
"year_to_date": "מתחילת השנה",
|
||||
"custom_range": "טווח מותאם אישית",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Jelentkezzen be a Google-lal",
|
||||
"signin_with_saml": "Jelentkezzen be SAML-lel",
|
||||
"signin_with_saml_oidc": "Jelentkezzen be SAML/OIDC segítségével",
|
||||
"continue_with_email": "Folytatás e-maillel",
|
||||
"continue_with_google": "Folytatás Google-lal",
|
||||
"last_used": "Utoljára használt",
|
||||
"you_will_need_to_generate": "A régi ütemező eszközből létre kell hoznia egy hozzáférési tokent.",
|
||||
"import": "Importálás",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} telepítve",
|
||||
"verify_wallet": "A Tárca ellenőrzése",
|
||||
"create_events_on": "Események létrehozása",
|
||||
"enterprise_license": "Ez egy vállalati szolgáltatás",
|
||||
"enterprise_license": "Ez egy kereskedelmi funkció",
|
||||
"enterprise_license_locally": "Ezt a funkciót helyben tesztelheti, éles környezetben azonban nem.",
|
||||
"enterprise_license_sales": "A vállalati kiadásra való frissítéshez forduljon értékesítési csapatunkhoz. Ha a licenckulcs már rendelkezésre áll, segítségért forduljon a support@cal.com címhez.",
|
||||
"enterprise_license_sales": "Kereskedelmi licenc vásárlásához kérjük, vegye fel a kapcsolatot értékesítési csapatunkkal. Ha már rendelkezik licenckulccsal, kérjük, lépjen kapcsolatba a support@cal.com címen segítségért.",
|
||||
"missing_license": "Hiányzó licenc",
|
||||
"next_steps": "Következő lépések",
|
||||
"acquire_commercial_license": "Szerezzen kereskedelmi licenc-et",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Válasszon időt",
|
||||
"select_date": "Válasszon dátumot",
|
||||
"connecting_you_to_someone": "Összekapcsolunk valakivel.",
|
||||
"please_do_not_close_this_tab": "Kérjük, ne zárja be ezt a lapot",
|
||||
"please_do_not_close_this_tab": "Kérjük, ne zárja be vagy frissítse ezt az oldalt",
|
||||
"see_all_available_times": "Az összes elérhető időpont megtekintése",
|
||||
"org_team_names_example_1": "például. Marketing Csapat",
|
||||
"org_team_names_example_2": "például. Értékesítési csapat",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Megakadályozza, hogy a tagok saját eseménytípusokat hozzanak létre.",
|
||||
"add_to_event_type": "Hozzáadás az eseménytípushoz",
|
||||
"create_account_password": "Fiók jelszavának létrehozása",
|
||||
"create_account_with_saml": "Fiók létrehozása SAML-lel",
|
||||
"error_creating_account_password": "Nem sikerült létrehozni a fiók jelszavát",
|
||||
"cannot_create_account_password_cal_provider": "Nem lehet fiókjelszót létrehozni a cal fiókokhoz",
|
||||
"cannot_create_account_password_already_existing": "A már létrehozott fiókokhoz nem lehet jelszót létrehozni",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Naptár csatlakoztatása",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Naptár csatlakoztatva",
|
||||
"outlook_connect_atom_loading_label": "Outlook Naptár ellenőrzése",
|
||||
"stripe_connect_atom_label": "Csatlakozás a Stripe-hoz",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe csatlakoztatva",
|
||||
"stripe_connect_atom_loading_label": "Stripe hitelesítő adatok ellenőrzése",
|
||||
"booking_question_response_variables": "Foglalási kérdés válaszváltozók",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} által kezelve",
|
||||
"number_of_options": "{{count}} lehetőség",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Korlátozza, hogy a tagok hányszor foglalhatók az összes csapat eseménytípusra (kollektív és round-robin)",
|
||||
"booking_limits_updated_successfully": "A foglalási korlátok sikeresen frissítve",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához",
|
||||
"hide_calendar_event_details": "Naptáresemény részleteinek elrejtése megosztott naptárakon",
|
||||
"description_hide_calendar_event_details": "Amikor egy naptár meg van osztva, az események láthatóak az olvasók számára, de a részletek rejtve maradnak azok elől, akiknek nincs írási hozzáférésük.",
|
||||
"last_number_of_days": "az elmúlt {{count}} nap",
|
||||
"month_to_date": "hónap elejétől",
|
||||
"year_to_date": "év elejétől",
|
||||
"custom_range": "egyéni tartomány",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Accedi con Google",
|
||||
"signin_with_saml": "Accedi con SAML",
|
||||
"signin_with_saml_oidc": "Accedi con SAML/OIDC",
|
||||
"continue_with_email": "Continua con l'email",
|
||||
"continue_with_google": "Continua con Google",
|
||||
"last_used": "Ultimo utilizzo",
|
||||
"you_will_need_to_generate": "Dovrai generare un token di accesso con il tuo vecchio strumento di pianificazione.",
|
||||
"import": "Importa",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installate",
|
||||
"verify_wallet": "Verifica Wallet",
|
||||
"create_events_on": "Crea eventi su:",
|
||||
"enterprise_license": "Questa è una funzione Enterprise",
|
||||
"enterprise_license": "Questa è una funzionalità commerciale",
|
||||
"enterprise_license_locally": "Puoi testare questa funzione localmente ma non in ambiente di produzione.",
|
||||
"enterprise_license_sales": "Per passare all'edizione aziendale, contatta i nostri agenti di vendita. Se esiste già una chiave di licenza, contatta support@cal.com per chiedere assistenza.",
|
||||
"enterprise_license_sales": "Per acquistare una licenza commerciale, contatta il nostro team di vendita. Se hai già una chiave di licenza, contatta support@cal.com per assistenza.",
|
||||
"missing_license": "Licenza mancante",
|
||||
"next_steps": "Prossimi Passi",
|
||||
"acquire_commercial_license": "Acquista una licenza commerciale",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Seleziona l'ora",
|
||||
"select_date": "Seleziona la data",
|
||||
"connecting_you_to_someone": "Ti stiamo collegando con qualcuno.",
|
||||
"please_do_not_close_this_tab": "Non chiudere questa scheda",
|
||||
"please_do_not_close_this_tab": "Per favore, non chiudere o aggiornare questa pagina",
|
||||
"see_all_available_times": "Vedi tutti gli orari disponibili",
|
||||
"org_team_names_example_1": "ad es., team di marketing",
|
||||
"org_team_names_example_2": "ad es., team vendite",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Impedisci ai membri di creare propri tipi di evento.",
|
||||
"add_to_event_type": "Aggiungi al tipo di evento",
|
||||
"create_account_password": "Crea password per l'account",
|
||||
"create_account_with_saml": "Crea un account con SAML",
|
||||
"error_creating_account_password": "Non è stato possibile creare la password per l'account",
|
||||
"cannot_create_account_password_cal_provider": "Impossibile creare password per gli account cal",
|
||||
"cannot_create_account_password_already_existing": "Impossibile creare password per account già creati",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Connetti Calendario Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario Outlook connesso",
|
||||
"outlook_connect_atom_loading_label": "Verifica Calendario Outlook",
|
||||
"stripe_connect_atom_label": "Collegati a Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe collegato",
|
||||
"stripe_connect_atom_loading_label": "Verifica delle credenziali Stripe in corso",
|
||||
"booking_question_response_variables": "Variabili di risposta alle domande di prenotazione",
|
||||
"managed_by_teamAdmins": "Gestito da {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opzioni",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limita quante volte i membri possono essere prenotati in tutti i tipi di eventi del team (collettivi e round-robin)",
|
||||
"booking_limits_updated_successfully": "Limiti di prenotazione aggiornati con successo",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione",
|
||||
"hide_calendar_event_details": "Nascondi i dettagli degli eventi del calendario nei calendari condivisi",
|
||||
"description_hide_calendar_event_details": "Quando un calendario è condiviso, gli eventi sono visibili ai lettori ma i loro dettagli sono nascosti a chi non ha accesso in scrittura.",
|
||||
"last_number_of_days": "ultimi {{count}} giorni",
|
||||
"month_to_date": "mese corrente",
|
||||
"year_to_date": "anno corrente",
|
||||
"custom_range": "intervallo personalizzato",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Google アカウントを使用してサインイン",
|
||||
"signin_with_saml": "SAML を使用してサインイン",
|
||||
"signin_with_saml_oidc": "SAML/OIDCでサインイン",
|
||||
"continue_with_email": "メールで続行",
|
||||
"continue_with_google": "Googleで続行",
|
||||
"last_used": "最終使用",
|
||||
"you_will_need_to_generate": "従来のスケジュールツールからアクセストークンを生成する必要があります。",
|
||||
"import": "インポートする",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "時間帯を選ぶ",
|
||||
"select_date": "日付を選ぶ",
|
||||
"connecting_you_to_someone": "誰かに接続しています。",
|
||||
"please_do_not_close_this_tab": "このタブを閉じないでください",
|
||||
"please_do_not_close_this_tab": "このページを閉じたり、リフレッシュしたりしないでください",
|
||||
"see_all_available_times": "出席できる時間帯をすべて表示",
|
||||
"org_team_names_example_1": "例:マーケティングチーム",
|
||||
"org_team_names_example_2": "例:営業チーム",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "メンバーが独自のイベントタイプを作成するのを防ぎます。",
|
||||
"add_to_event_type": "イベントタイプに追加",
|
||||
"create_account_password": "アカウントパスワードを作成",
|
||||
"create_account_with_saml": "SAMLでアカウントを作成",
|
||||
"error_creating_account_password": "アカウントパスワードの作成に失敗しました",
|
||||
"cannot_create_account_password_cal_provider": "calアカウントのパスワードを作成できません",
|
||||
"cannot_create_account_password_already_existing": "既に作成されたアカウントのパスワードは作成できません",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlookカレンダーを接続",
|
||||
"outlook_connect_atom_already_connected_label": "Outlookカレンダーが接続されました",
|
||||
"outlook_connect_atom_loading_label": "Outlookカレンダーを確認中",
|
||||
"stripe_connect_atom_label": "Stripeに接続",
|
||||
"stripe_connect_atom_already_connected_label": "Stripeに接続済み",
|
||||
"stripe_connect_atom_loading_label": "Stripeの認証情報を確認中",
|
||||
"booking_question_response_variables": "予約質問の回答変数",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}}によって管理されています",
|
||||
"number_of_options": "{{count}}のオプション",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "チームメンバーが全てのチームイベントタイプ(集団およびround-robin)で予約される回数を制限",
|
||||
"booking_limits_updated_successfully": "予約制限が正常に更新されました",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません",
|
||||
"hide_calendar_event_details": "共有カレンダーのイベント詳細を非表示にする",
|
||||
"description_hide_calendar_event_details": "カレンダーが共有されると、イベントは閲覧者に表示されますが、書き込み権限のない人には詳細が非表示になります。",
|
||||
"last_number_of_days": "過去{{count}}日間",
|
||||
"month_to_date": "月初から今日まで",
|
||||
"year_to_date": "年初から今日まで",
|
||||
"custom_range": "カスタム範囲",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "ចូលប្រើដោយ Google",
|
||||
"signin_with_saml": "ចូលប្រើដោយ SAML",
|
||||
"signin_with_saml_oidc": "ចូលប្រើដោយ SAML/OIDC",
|
||||
"continue_with_email": "បន្តដោយប្រើអ៊ីមែល",
|
||||
"continue_with_google": "បន្តដោយប្រើ Google",
|
||||
"last_used": "ប្រើចុងក្រោយ",
|
||||
"you_will_need_to_generate": "អ្នកត្រូវតែបង្កើតសញ្ញាសម្ងាត់ចូលពីឧបករណ៍កំណត់ពេលវេលាចាស់របស់អ្នក។",
|
||||
"import": "នាំចូល",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} បានដំឡើង",
|
||||
"verify_wallet": "ផ្ទៀងផ្ទាត់កាបូប",
|
||||
"create_events_on": "បង្កើតព្រឹត្តិការណ៍នៅលើ",
|
||||
"enterprise_license": "នេះគឺជម្រើសសម្រាប់សហគ្រាស",
|
||||
"enterprise_license": "នេះគឺជាមុខងារជំនួញ",
|
||||
"enterprise_license_locally": "Bạn có thể thử tính năng này tại địa phương nhưng không phải trên môi trường sản xuất.",
|
||||
"enterprise_license_sales": "Để nâng cấp lên phiên bản doanh nghiệp, vui lòng liên hệ với đội ngũ bán hàng của chúng tôi. Nếu khóa giấy phép đã có sẵn, vui lòng liên hệ support@cal.com để được hỗ trợ.",
|
||||
"enterprise_license_sales": "ដើម្បីទិញអាជ្ញាប័ណ្ណជំនួញ សូមទាក់ទងក្រុមលក់របស់យើង។ ប្រសិនបើមានកូនសោអាជ្ញាប័ណ្ណរួចហើយ សូមទាក់ទង support@cal.com សម្រាប់ជំនួយ។",
|
||||
"missing_license": "បាត់បង់អាជ្ញាប័ណ្ណ",
|
||||
"next_steps": "ជំហានបន្ទាប់",
|
||||
"acquire_commercial_license": "ទទួលបានអាជ្ញាប័ណ្ណពាណិជ្ជកម្ម",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "ជ្រើសរើសពេលវេលា",
|
||||
"select_date": "ជ្រើសរើសកាលបរិច្ឆេទ",
|
||||
"connecting_you_to_someone": "យើងកំពុងភ្ជាប់អ្នកទៅនរណាម្នាក់។",
|
||||
"please_do_not_close_this_tab": "សូមកុំបិទផ្ទាំងនេះ",
|
||||
"please_do_not_close_this_tab": "សូមកុំបិទ ឬធ្វើការបន្ទាន់សម័យទំព័រនេះ",
|
||||
"see_all_available_times": "មើលពេលវេលាទាំងអស់ដែលអាចប្រើបាន",
|
||||
"org_team_names_example_1": "ឧ. ក្រុមទីផ្សារ",
|
||||
"org_team_names_example_2": "ឧ. ក្រុមលក់",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "បង្រ្កាបសមាជិកមិនឱ្យបង្កើតប្រភេទព្រឹត្តិការណ៍ផ្ទាល់ខ្លួន។",
|
||||
"add_to_event_type": "បន្ថែមទៅប្រភេទព្រឹត្តិការណ៍",
|
||||
"create_account_password": "បង្កើតពាក្យសម្ងាត់គណនី",
|
||||
"create_account_with_saml": "បង្កើតគណនីដោយប្រើ SAML",
|
||||
"error_creating_account_password": "បរាជ័យក្នុងការបង្កើតពាក្យសម្ងាត់គណនី",
|
||||
"cannot_create_account_password_cal_provider": "មិនអាចបង្កើតពាក្យសម្ងាត់គណនីសម្រាប់គណនី cal",
|
||||
"cannot_create_account_password_already_existing": "មិនអាចបង្កើតពាក្យសម្ងាត់គណនីសម្រាប់គណនីដែលបានបង្កើតរួចហើយ",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "ភ្ជាប់ប្រតិទិន Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "បានភ្ជាប់ប្រតិទិន Outlook",
|
||||
"outlook_connect_atom_loading_label": "កំពុងពិនិត្យប្រតិទិន Outlook",
|
||||
"stripe_connect_atom_label": "ភ្ជាប់ទៅកាន់ Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "បានភ្ជាប់ទៅកាន់ Stripe",
|
||||
"stripe_connect_atom_loading_label": "កំពុងពិនិត្យសមាជិកភាព Stripe",
|
||||
"booking_question_response_variables": "អថេរឆ្លើយសំណួរការកក់",
|
||||
"managed_by_teamAdmins": "គ្រប់គ្រងដោយ {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} ជម្រើស",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "ដែនកំណត់ចំនួនដងដែលសមាជិកអាចត្រូវបានកក់ក្នុងប្រភេទព្រឹត្តិការណ៍ក្រុមទាំងអស់ (សហគមន៍ និង round-robin)",
|
||||
"booking_limits_updated_successfully": "ដែនកំណត់ការកក់ត្រូវបានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "អ្នកមិនមានសិទ្ធិធ្វើការផ្លាស់ប្តូរនេះលើការកក់ទេ",
|
||||
"hide_calendar_event_details": "លាក់ព័ត៌មានលម្អិតនៃព្រឹត្តិការណ៍ប្រចាំប្រតិទិននៅលើប្រតិទិនដែលបានចែករំលែក",
|
||||
"description_hide_calendar_event_details": "នៅពេលដែលប្រតិទិនត្រូវបានចែករំលែក ព្រឹត្តិការណ៍នឹងមើលឃើញបានសម្រាប់អ្នកអាន ប៉ុន្តែព័ត៌មានលម្អិតនឹងត្រូវបានលាក់ពីអ្នកដែលមិនមានសិទ្ធិចូលសរសេរ។",
|
||||
"last_number_of_days": "ចុងក្រោយ {{count}} ថ្ងៃ",
|
||||
"month_to_date": "ខែនេះ",
|
||||
"year_to_date": "ឆ្នាំនេះ",
|
||||
"custom_range": "ជួរដែលកំណត់ដោយខ្លួនឯង",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Google로 로그인",
|
||||
"signin_with_saml": "SAML로 로그인",
|
||||
"signin_with_saml_oidc": "SAML/OIDC로 로그인",
|
||||
"continue_with_email": "이메일로 계속하기",
|
||||
"continue_with_google": "Google로 계속하기",
|
||||
"last_used": "마지막 사용",
|
||||
"you_will_need_to_generate": "이전 일정 관리 도구에서 액세스 토큰을 생성해야 합니다.",
|
||||
"import": "가져오기",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}}개 설치됨",
|
||||
"verify_wallet": "지갑 인증",
|
||||
"create_events_on": "이벤트 생성일:",
|
||||
"enterprise_license": "엔터프라이즈 기능입니다",
|
||||
"enterprise_license": "이것은 상업용 기능입니다",
|
||||
"enterprise_license_locally": "이 기능은 로컬에서 테스트할 수 있지만 실제 운영 환경에서는 할 수 없습니다.",
|
||||
"enterprise_license_sales": "기업용 버전으로 업그레이드하려면 영업팀에 문의하세요. 라이센스 키가 이미 있는 경우 support@cal.com에 문의하여 도움을 받으세요.",
|
||||
"enterprise_license_sales": "상업용 라이선스를 구매하려면 영업팀에 문의하십시오. 이미 라이선스 키가 있는 경우, 지원이 필요하면 support@cal.com으로 연락해 주십시오.",
|
||||
"missing_license": "라이선스 없음",
|
||||
"next_steps": "다음 단계",
|
||||
"acquire_commercial_license": "상용 라이선스 취득하기",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "시간 선택",
|
||||
"select_date": "날짜 선택",
|
||||
"connecting_you_to_someone": "다른 사람과 연결 중입니다.",
|
||||
"please_do_not_close_this_tab": "이 탭을 닫지 마세요",
|
||||
"please_do_not_close_this_tab": "이 페이지를 닫거나 새로 고침하지 마세요",
|
||||
"see_all_available_times": "모든 사용 가능한 시간 보기",
|
||||
"org_team_names_example_1": "예: 마케팅 팀",
|
||||
"org_team_names_example_2": "예: 세일즈 팀",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "회원이 자신의 이벤트 유형을 생성하지 못하도록 방지합니다.",
|
||||
"add_to_event_type": "이벤트 유형에 추가",
|
||||
"create_account_password": "계정 비밀번호 생성",
|
||||
"create_account_with_saml": "SAML로 계정 만들기",
|
||||
"error_creating_account_password": "계정 비밀번호 생성 실패",
|
||||
"cannot_create_account_password_cal_provider": "cal 계정에 대해 계정 비밀번호를 생성할 수 없습니다",
|
||||
"cannot_create_account_password_already_existing": "이미 생성된 계정에 대해 계정 비밀번호를 생성할 수 없습니다",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook 캘린더 연결",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook 캘린더 연결됨",
|
||||
"outlook_connect_atom_loading_label": "Outlook 캘린더 확인 중",
|
||||
"stripe_connect_atom_label": "Stripe에 연결",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe에 연결됨",
|
||||
"stripe_connect_atom_loading_label": "Stripe 자격 증명 확인 중",
|
||||
"booking_question_response_variables": "예약 질문 응답 변수",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}}에 의해 관리됨",
|
||||
"number_of_options": "{{count}}개의 옵션",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "모든 팀 이벤트 유형(집단 및 round-robin)에서 팀원들이 예약될 수 있는 횟수를 제한합니다",
|
||||
"booking_limits_updated_successfully": "예약 제한이 성공적으로 업데이트되었습니다",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "이 예약을 변경할 권한이 없습니다",
|
||||
"hide_calendar_event_details": "공유 캘린더에서 이벤트 세부 정보 숨기기",
|
||||
"description_hide_calendar_event_details": "캘린더가 공유되면 이벤트는 읽기 권한이 있는 사람에게 보이지만, 쓰기 권한이 없는 사람에게는 세부 정보가 숨겨집니다.",
|
||||
"last_number_of_days": "지난 {{count}}일",
|
||||
"month_to_date": "이번 달 현재까지",
|
||||
"year_to_date": "올해 현재까지",
|
||||
"custom_range": "사용자 지정 범위",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Aanmelden met Google",
|
||||
"signin_with_saml": "Aanmelden met SAML",
|
||||
"signin_with_saml_oidc": "Aanmelden met SAML/OIDC",
|
||||
"continue_with_email": "Doorgaan met e-mail",
|
||||
"continue_with_google": "Doorgaan met Google",
|
||||
"last_used": "Laatst gebruikt",
|
||||
"you_will_need_to_generate": "U moet een toegangstoken genereren van uw oude planningstool.",
|
||||
"import": "Importeren",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} keer geïnstalleerd",
|
||||
"verify_wallet": "Wallet verifiëren",
|
||||
"create_events_on": "Maak gebeurtenissen aan in de",
|
||||
"enterprise_license": "Dit is een bedrijfsfunctie",
|
||||
"enterprise_license": "Dit is een commerciële functie",
|
||||
"enterprise_license_locally": "Je kunt deze functie lokaal testen, maar niet in productie.",
|
||||
"enterprise_license_sales": "Om te upgraden naar de enterprise-editie, neem contact op met ons verkoopteam. Als er al een licentiesleutel aanwezig is, neem dan contact op met support@cal.com voor hulp.",
|
||||
"enterprise_license_sales": "Neem contact op met ons verkoopteam om een commerciële licentie aan te schaffen. Als er al een licentiesleutel aanwezig is, neem dan contact op met support@cal.com voor hulp.",
|
||||
"missing_license": "Ontbrekende licentie",
|
||||
"next_steps": "Volgende stappen",
|
||||
"acquire_commercial_license": "Verkrijg een commerciële licentie",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Tijd selecteren",
|
||||
"select_date": "Datum selecteren",
|
||||
"connecting_you_to_someone": "We verbinden je met iemand.",
|
||||
"please_do_not_close_this_tab": "Sluit dit tabblad niet.",
|
||||
"please_do_not_close_this_tab": "Sluit of vernieuw deze pagina niet",
|
||||
"see_all_available_times": "Bekijk alle beschikbare tijden",
|
||||
"org_team_names_example_1": "bijvoorbeeld Marketingteam",
|
||||
"org_team_names_example_2": "bijvoorbeeld Verkoopteam",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Voorkom dat leden hun eigen evenementtypen aanmaken.",
|
||||
"add_to_event_type": "Toevoegen aan evenementtype",
|
||||
"create_account_password": "Maak een accountwachtwoord aan",
|
||||
"create_account_with_saml": "Account aanmaken met SAML",
|
||||
"error_creating_account_password": "Het aanmaken van een accountwachtwoord is mislukt",
|
||||
"cannot_create_account_password_cal_provider": "Kan geen accountwachtwoord aanmaken voor cal-accounts",
|
||||
"cannot_create_account_password_already_existing": "Kan geen accountwachtwoord aanmaken voor reeds bestaande accounts",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Agenda verbinden",
|
||||
"outlook_connect_atom_already_connected_label": "Verbonden Outlook-agenda",
|
||||
"outlook_connect_atom_loading_label": "Outlook-agenda controleren",
|
||||
"stripe_connect_atom_label": "Verbinden met Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe verbonden",
|
||||
"stripe_connect_atom_loading_label": "Stripe-gegevens controleren",
|
||||
"booking_question_response_variables": "Variabelen voor boekingsvraagreacties",
|
||||
"managed_by_teamAdmins": "Beheerd door {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opties",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Beperk hoe vaak teamleden geboekt kunnen worden voor alle soorten teamevenementen (collectief en round-robin)",
|
||||
"booking_limits_updated_successfully": "Boekingslimieten succesvol bijgewerkt",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "U bent niet gemachtigd om deze wijziging in de boeking aan te brengen",
|
||||
"hide_calendar_event_details": "Verberg details van kalendergebeurtenissen op gedeelde kalenders",
|
||||
"description_hide_calendar_event_details": "Wanneer een kalender wordt gedeeld, zijn gebeurtenissen zichtbaar voor lezers, maar hun details zijn verborgen voor degenen zonder schrijfrechten.",
|
||||
"last_number_of_days": "laatste {{count}} dagen",
|
||||
"month_to_date": "maand tot nu toe",
|
||||
"year_to_date": "jaar tot nu toe",
|
||||
"custom_range": "aangepast bereik",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Voeg uw nieuwe strings hierboven toe ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Logg på med Google",
|
||||
"signin_with_saml": "Logg på med SAML",
|
||||
"signin_with_saml_oidc": "Logg inn med SAML/OIDC",
|
||||
"continue_with_email": "Fortsett med e-post",
|
||||
"continue_with_google": "Fortsett med Google",
|
||||
"last_used": "Sist brukt",
|
||||
"you_will_need_to_generate": "Du må generere et tilgangstoken fra ditt gamle planleggingsverktøy.",
|
||||
"import": "Importer",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} er installert",
|
||||
"verify_wallet": "Verifiser Lommebok",
|
||||
"create_events_on": "Opprett hendelser på",
|
||||
"enterprise_license": "Dette er en bedriftsfunksjon",
|
||||
"enterprise_license": "Dette er en kommersiell funksjon",
|
||||
"enterprise_license_locally": "Du kan teste denne funksjonen lokalt, men ikke i produksjon.",
|
||||
"enterprise_license_sales": "For å oppgradere til enterprise-utgaven, ta kontakt med vårt salgsteam. Hvis en lisensnøkkel allerede er på plass, ta kontakt med support@cal.com for hjelp.",
|
||||
"enterprise_license_sales": "For å kjøpe en kommersiell lisens, vennligst kontakt vårt salgsteam. Hvis en lisensnøkkel allerede er på plass, vennligst kontakt support@cal.com for hjelp.",
|
||||
"missing_license": "Mangler Lisens",
|
||||
"next_steps": "Neste Skritt",
|
||||
"acquire_commercial_license": "Skaff en kommersiell lisens",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Velg tid",
|
||||
"select_date": "Velg dato",
|
||||
"connecting_you_to_someone": "Vi kobler deg til noen.",
|
||||
"please_do_not_close_this_tab": "Vennligst ikke lukk denne fanen",
|
||||
"please_do_not_close_this_tab": "Vennligst ikke lukk eller oppdater denne siden",
|
||||
"see_all_available_times": "Se alle tilgjengelige tider",
|
||||
"org_team_names_example_1": "f.eks. Markedsføringsteam",
|
||||
"org_team_names_example_2": "f.eks. Salgsteam",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Forhindre medlemmer fra å opprette sine egne hendelsestyper.",
|
||||
"add_to_event_type": "Legg til hendelsestype",
|
||||
"create_account_password": "Opprett kontopassord",
|
||||
"create_account_with_saml": "Opprett konto med SAML",
|
||||
"error_creating_account_password": "Kunne ikke opprette kontopassord",
|
||||
"cannot_create_account_password_cal_provider": "Kan ikke opprette kontopassord for cal-kontoer",
|
||||
"cannot_create_account_password_already_existing": "Kan ikke opprette kontopassord for allerede opprettede kontoer",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Koble til Outlook-kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Tilkoblet Outlook-kalender",
|
||||
"outlook_connect_atom_loading_label": "Sjekker Outlook-kalender",
|
||||
"stripe_connect_atom_label": "Koble til Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe tilkoblet",
|
||||
"stripe_connect_atom_loading_label": "Sjekker Stripe-legitimasjon",
|
||||
"booking_question_response_variables": "Variabler for svar på booking-spørsmål",
|
||||
"managed_by_teamAdmins": "Administrert av {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} alternativer",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Begrens hvor mange ganger medlemmer kan bookes på tvers av alle teamhendelsestyper (kollektivt og round-robin)",
|
||||
"booking_limits_updated_successfully": "Bestillingsgrenser oppdatert vellykket",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tillatelse til å gjøre denne endringen i bestillingen",
|
||||
"hide_calendar_event_details": "Skjul kalenderhendelsesdetaljer på delte kalendere",
|
||||
"description_hide_calendar_event_details": "Når en kalender deles, er hendelser synlige for lesere, men detaljene er skjult for de uten skriveadgang.",
|
||||
"last_number_of_days": "siste {{count}} dager",
|
||||
"month_to_date": "måned til dags dato",
|
||||
"year_to_date": "år til dags dato",
|
||||
"custom_range": "tilpasset periode",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Legg til dine nye strenger over her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Zaloguj się przez Google",
|
||||
"signin_with_saml": "Zaloguj się za pomocą SAML",
|
||||
"signin_with_saml_oidc": "Zaloguj się za pomocą SAML/OIDC",
|
||||
"continue_with_email": "Kontynuuj za pomocą e-maila",
|
||||
"continue_with_google": "Kontynuuj za pomocą Google",
|
||||
"last_used": "Ostatnio używane",
|
||||
"you_will_need_to_generate": "Będziesz musiał wygenerować token dostępu ze strony integracji.",
|
||||
"import": "Importuj",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "Zainstalowano {{count}}",
|
||||
"verify_wallet": "Zweryfikuj portfel",
|
||||
"create_events_on": "Utwórz wydarzenia w",
|
||||
"enterprise_license": "To funkcja dla przedsiębiorstw",
|
||||
"enterprise_license": "To jest funkcja komercyjna",
|
||||
"enterprise_license_locally": "Możesz przetestować tę funkcję lokalnie, ale nie na produkcji.",
|
||||
"enterprise_license_sales": "Aby uaktualnić do wersji enterprise, skontaktuj się z naszym zespołem sprzedaży. Jeśli klucz licencyjny jest już wprowadzony, skontaktuj się z support@cal.com po pomoc.",
|
||||
"enterprise_license_sales": "Aby zakupić licencję komercyjną, skontaktuj się z naszym zespołem sprzedaży. Jeśli klucz licencyjny jest już w użyciu, skontaktuj się z support@cal.com, aby uzyskać pomoc.",
|
||||
"missing_license": "Brakująca licencja",
|
||||
"next_steps": "Następne kroki",
|
||||
"acquire_commercial_license": "Uzyskaj licencję komercyjną",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Wybierz godzinę",
|
||||
"select_date": "Wybierz datę",
|
||||
"connecting_you_to_someone": "Łączymy Cię z kimś.",
|
||||
"please_do_not_close_this_tab": "Proszę nie zamykać tej karty",
|
||||
"please_do_not_close_this_tab": "Proszę nie zamykać ani nie odświeżać tej strony",
|
||||
"see_all_available_times": "Zobacz wszystkie dostępne godziny",
|
||||
"org_team_names_example_1": "np. zespół marketingowy",
|
||||
"org_team_names_example_2": "np. zespół ds. sprzedaży",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Uniemożliw członkom tworzenie własnych typów wydarzeń.",
|
||||
"add_to_event_type": "Dodaj do typu wydarzenia",
|
||||
"create_account_password": "Utwórz hasło do konta",
|
||||
"create_account_with_saml": "Utwórz konto za pomocą SAML",
|
||||
"error_creating_account_password": "Nie udało się utworzyć hasła do konta",
|
||||
"cannot_create_account_password_cal_provider": "Nie można utworzyć hasła do konta dla kont kalendarza",
|
||||
"cannot_create_account_password_already_existing": "Nie można utworzyć hasła do konta dla już istniejących",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Połącz z Kalendarzem Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Połączono z Kalendarzem Outlook",
|
||||
"outlook_connect_atom_loading_label": "Sprawdzanie kalendarza Outlook",
|
||||
"stripe_connect_atom_label": "Połącz ze Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Połączono ze Stripe",
|
||||
"stripe_connect_atom_loading_label": "Sprawdzanie poświadczeń Stripe",
|
||||
"booking_question_response_variables": "Zmienne odpowiedzi na pytania dotyczące rezerwacji",
|
||||
"managed_by_teamAdmins": "Zarządzane przez {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opcji",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Ogranicz, ile razy członkowie mogą być rezerwowani we wszystkich typach wydarzeń zespołowych (zbiorowe i round-robin)",
|
||||
"booking_limits_updated_successfully": "Limity rezerwacji zostały pomyślnie zaktualizowane",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nie masz uprawnień do wprowadzenia tej zmiany w rezerwacji",
|
||||
"hide_calendar_event_details": "Ukryj szczegóły wydarzeń w kalendarzu na udostępnionych kalendarzach",
|
||||
"description_hide_calendar_event_details": "Gdy kalendarz jest udostępniony, wydarzenia są widoczne dla czytelników, ale ich szczegóły są ukryte dla osób bez dostępu do edycji.",
|
||||
"last_number_of_days": "ostatnie {{count}} dni",
|
||||
"month_to_date": "od początku miesiąca",
|
||||
"year_to_date": "od początku roku",
|
||||
"custom_range": "zakres niestandardowy",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodaj nowe ciągi powyżej ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Entrar com Google",
|
||||
"signin_with_saml": "Entrar com SAML",
|
||||
"signin_with_saml_oidc": "Entrar com SAML/OIDC",
|
||||
"continue_with_email": "Continuar com e-mail",
|
||||
"continue_with_google": "Continuar com o Google",
|
||||
"last_used": "Último uso",
|
||||
"you_will_need_to_generate": "Você precisa gerar um token de acesso na ferramenta de agendamento antiga.",
|
||||
"import": "Importar",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} instalado(s)",
|
||||
"verify_wallet": "Verificar carteira",
|
||||
"create_events_on": "Criar eventos em",
|
||||
"enterprise_license": "Este não é um recurso corporativo",
|
||||
"enterprise_license": "Este é um recurso comercial",
|
||||
"enterprise_license_locally": "Você pode testar este recurso localmente mas não em produção.",
|
||||
"enterprise_license_sales": "Para atualizar para a versão enterprise, por favor entre em contato com nossa equipe de vendas. Se um chave de licença já existir, por favor entre em contato com support@cal.com para obter ajuda.",
|
||||
"enterprise_license_sales": "Para adquirir uma licença comercial, entre em contato com nossa equipe de vendas. Se você já possui uma chave de licença, por favor, entre em contato com support@cal.com para obter ajuda.",
|
||||
"missing_license": "Falta a licença",
|
||||
"next_steps": "Próximos passos",
|
||||
"acquire_commercial_license": "Adquira uma licença comercial",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Selecione o horário",
|
||||
"select_date": "Selecione a data",
|
||||
"connecting_you_to_someone": "Estamos conectando você a alguém.",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche esta aba",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche ou atualize esta página",
|
||||
"see_all_available_times": "Veja todos os horários disponíveis",
|
||||
"org_team_names_example_1": "ex.: Time de Marketing",
|
||||
"org_team_names_example_2": "ex: Time de Vendas",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Impedir membros de criar seus próprios tipos de evento.",
|
||||
"add_to_event_type": "Adicionar ao tipo de evento",
|
||||
"create_account_password": "Crie a senha da conta",
|
||||
"create_account_with_saml": "Criar conta com SAML",
|
||||
"error_creating_account_password": "Erro ao criar senha da conta",
|
||||
"cannot_create_account_password_cal_provider": "Não é permitido criar senha de conta para contas cal",
|
||||
"cannot_create_account_password_already_existing": "Não é permitido criar senha de conta para senhas já criadas",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendário Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendário Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendário Outlook",
|
||||
"stripe_connect_atom_label": "Conectar ao Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciais do Stripe",
|
||||
"booking_question_response_variables": "Variáveis de resposta de perguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gerenciado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opções",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limite quantas vezes os membros podem ser agendados em todos os tipos de eventos da equipe (coletivo e round-robin)",
|
||||
"booking_limits_updated_successfully": "Limites de agendamento atualizados com sucesso",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Você não tem autorização para fazer essa alteração na reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários compartilhados",
|
||||
"description_hide_calendar_event_details": "Quando um calendário é compartilhado, os eventos são visíveis para os leitores, mas seus detalhes são ocultados para aqueles que não têm acesso de escrita.",
|
||||
"last_number_of_days": "últimos {{count}} dias",
|
||||
"month_to_date": "mês até a data",
|
||||
"year_to_date": "ano até a data",
|
||||
"custom_range": "intervalo personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Iniciar sessão com o Google",
|
||||
"signin_with_saml": "Iniciar sessão com SAML",
|
||||
"signin_with_saml_oidc": "Iniciar sessão com SAML/OIDC",
|
||||
"continue_with_email": "Continuar com email",
|
||||
"continue_with_google": "Continuar com o Google",
|
||||
"last_used": "Última utilização",
|
||||
"you_will_need_to_generate": "Precisará de gerar um token de acesso na página das integrações.",
|
||||
"import": "Importar",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} instaladas",
|
||||
"verify_wallet": "Verificar carteira",
|
||||
"create_events_on": "Criar eventos em:",
|
||||
"enterprise_license": "Esta é uma funcionalidade empresarial",
|
||||
"enterprise_license": "Esta é uma funcionalidade comercial",
|
||||
"enterprise_license_locally": "Pode testar esta funcionalidade localmente, mas não em produção.",
|
||||
"enterprise_license_sales": "Para atualizar para a edição enterprise, entre em contacto com a nossa equipa de vendas. Se já tiver uma chave de licença, contacte support@cal.com para obter ajuda.",
|
||||
"enterprise_license_sales": "Para adquirir uma licença comercial, por favor contacte a nossa equipa de vendas. Se já tiver uma chave de licença, por favor contacte support@cal.com para obter ajuda.",
|
||||
"missing_license": "Licença em falta",
|
||||
"next_steps": "Passos seguintes",
|
||||
"acquire_commercial_license": "Adquira uma licença comercial",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Selecionar horário",
|
||||
"select_date": "Selecionar data",
|
||||
"connecting_you_to_someone": "Estamos a conectá-lo a alguém.",
|
||||
"please_do_not_close_this_tab": "Não feche este separador",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche ou atualize esta página",
|
||||
"see_all_available_times": "Ver todos os horários disponíveis",
|
||||
"org_team_names_example_1": "Por exemplo, Equipa de Marketing",
|
||||
"org_team_names_example_2": "Por exemplo, Equipa de Vendas",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Impedir que os membros criem os seus próprios tipos de eventos.",
|
||||
"add_to_event_type": "Adicionar ao tipo de evento",
|
||||
"create_account_password": "Criar palavra-passe de conta",
|
||||
"create_account_with_saml": "Criar conta com SAML",
|
||||
"error_creating_account_password": "Falha ao criar a palavra-passe de conta",
|
||||
"cannot_create_account_password_cal_provider": "Não é possível criar palavras-passe de conta para contas cal",
|
||||
"cannot_create_account_password_already_existing": "Não é possível criar palavras-passe de conta para contas já criadas",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendário Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendário Outlook Conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendário Outlook",
|
||||
"stripe_connect_atom_label": "Conectar ao Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciais do Stripe",
|
||||
"booking_question_response_variables": "Variáveis de resposta às perguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gerido por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opções",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limite quantas vezes os membros podem ser reservados em todos os tipos de eventos da equipa (coletivo e round-robin)",
|
||||
"booking_limits_updated_successfully": "Limites de reservas atualizados com sucesso",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Não tem autorização para fazer esta alteração na reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários partilhados",
|
||||
"description_hide_calendar_event_details": "Quando um calendário é partilhado, os eventos são visíveis para os leitores, mas os seus detalhes são ocultados para aqueles que não têm acesso de escrita.",
|
||||
"last_number_of_days": "últimos {{count}} dias",
|
||||
"month_to_date": "mês até à data",
|
||||
"year_to_date": "ano até à data",
|
||||
"custom_range": "intervalo personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Conectați-vă cu Google",
|
||||
"signin_with_saml": "Conectați-vă cu SAML",
|
||||
"signin_with_saml_oidc": "Conectați-vă cu SAML/OIDC",
|
||||
"continue_with_email": "Continuă cu email",
|
||||
"continue_with_google": "Continuă cu Google",
|
||||
"last_used": "Ultima utilizare",
|
||||
"you_will_need_to_generate": "Va trebui să generați un token de acces din vechiul instrument de programare.",
|
||||
"import": "Importare",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} instalată/instalate",
|
||||
"verify_wallet": "Verificați portofelul",
|
||||
"create_events_on": "Creați evenimente în",
|
||||
"enterprise_license": "Aceasta este o caracteristică de întreprindere",
|
||||
"enterprise_license": "Aceasta este o funcție comercială",
|
||||
"enterprise_license_locally": "Puteți testa această funcționalitate local, dar nu în producție.",
|
||||
"enterprise_license_sales": "Pentru a face upgrade la ediția enterprise, contactați echipa noastră de vânzări. Dacă o cheie de licență este deja în uz, contactați support@cal.com pentru asistență.",
|
||||
"enterprise_license_sales": "Pentru a achiziționa o licență comercială, vă rugăm să contactați echipa noastră de vânzări. Dacă aveți deja o cheie de licență, vă rugăm să contactați support@cal.com pentru ajutor.",
|
||||
"missing_license": "Licență lipsă",
|
||||
"next_steps": "Pașii următori",
|
||||
"acquire_commercial_license": "Obțineți o licență comercială",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Selectați ora",
|
||||
"select_date": "Selectați data",
|
||||
"connecting_you_to_someone": "Te conectăm cu cineva.",
|
||||
"please_do_not_close_this_tab": "Te rugăm să nu închizi acest tab",
|
||||
"please_do_not_close_this_tab": "Vă rugăm să nu închideți sau să reîmprospătați această pagină",
|
||||
"see_all_available_times": "Vedeți toate orele disponibile",
|
||||
"org_team_names_example_1": "de ex. echipa de marketing",
|
||||
"org_team_names_example_2": "de ex. echipa de vânzări",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Împiedică membrii să creeze propriile tipuri de evenimente.",
|
||||
"add_to_event_type": "Adaugă la tipul de eveniment",
|
||||
"create_account_password": "Creează parola contului",
|
||||
"create_account_with_saml": "Creează cont cu SAML",
|
||||
"error_creating_account_password": "Eroare la crearea parolei contului",
|
||||
"cannot_create_account_password_cal_provider": "Nu se poate crea parola contului pentru conturile cal",
|
||||
"cannot_create_account_password_already_existing": "Nu se poate crea parola contului pentru cele deja existente",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Conectează Calendar Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendar Outlook Conectat",
|
||||
"outlook_connect_atom_loading_label": "Verificare Calendar Outlook",
|
||||
"stripe_connect_atom_label": "Conectează-te la Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectat",
|
||||
"stripe_connect_atom_loading_label": "Verificarea acreditărilor Stripe",
|
||||
"booking_question_response_variables": "Variabile de răspuns la întrebările de rezervare",
|
||||
"managed_by_teamAdmins": "Gestionat de {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opțiuni",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Limitează de câte ori pot fi rezervați membrii pentru toate tipurile de evenimente ale echipei (colective și round-robin)",
|
||||
"booking_limits_updated_successfully": "Limitele de rezervare au fost actualizate cu succes",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nu aveți autorizația necesară pentru a face această modificare în rezervare",
|
||||
"hide_calendar_event_details": "Ascunde detaliile evenimentelor din calendar pe calendarele partajate",
|
||||
"description_hide_calendar_event_details": "Când un calendar este partajat, evenimentele sunt vizibile pentru cititori, dar detaliile lor sunt ascunse celor fără acces de scriere.",
|
||||
"last_number_of_days": "ultimele {{count}} zile",
|
||||
"month_to_date": "luna până în prezent",
|
||||
"year_to_date": "anul până în prezent",
|
||||
"custom_range": "interval personalizat",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adăugați stringurile noi deasupra acestui rând ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Ulogujte sa pomoću Google-a",
|
||||
"signin_with_saml": "Ulogujte se pomoću SAML",
|
||||
"signin_with_saml_oidc": "Prijavite se pomoću SAML/OIDC",
|
||||
"continue_with_email": "Наставите са имејлом",
|
||||
"continue_with_google": "Наставите са Google-ом",
|
||||
"last_used": "Poslednji put korišćeno",
|
||||
"you_will_need_to_generate": "Morate generisati token pristupnosti iz svog prošlog softvera za zakazivanje.",
|
||||
"import": "Importuj",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "Instalirano {{count}} puta",
|
||||
"verify_wallet": "Verifikuj Wallet",
|
||||
"create_events_on": "Kreiraj događaje na",
|
||||
"enterprise_license": "Ovo je enterprise funkcija",
|
||||
"enterprise_license": "Ovo je komercijalna funkcija",
|
||||
"enterprise_license_locally": "Možete testirati ovu funkciju lokalno, ali ne u proizvodnji.",
|
||||
"enterprise_license_sales": "Da biste se nadograđivali na poslovnu verziju, obratite se našem prodajnom timu. Ako je licencni ključ već na mestu, kontaktirajte support@cal.com za pomoć.",
|
||||
"enterprise_license_sales": "Za kupovinu komercijalne licence, molimo vas da kontaktirate naš prodajni tim. Ako već imate licencni ključ, obratite se support@cal.com za pomoć.",
|
||||
"missing_license": "Nedostaje Licenca",
|
||||
"next_steps": "Sledeći koraci",
|
||||
"acquire_commercial_license": "Steknite komercijalnu licencu",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Izaberite vreme",
|
||||
"select_date": "Izaberite datum",
|
||||
"connecting_you_to_someone": "Povezujemo vas sa nekim.",
|
||||
"please_do_not_close_this_tab": "Molimo vas da ne zatvarate ovu karticu",
|
||||
"please_do_not_close_this_tab": "Молимо вас да не затварате или освежавате ову страницу",
|
||||
"see_all_available_times": "Pogledajte sva dostupna vremena",
|
||||
"org_team_names_example_1": "npr. Marketing tim",
|
||||
"org_team_names_example_2": "npr. Prodajni tim",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Sprečite članove da kreiraju svoje tipove događaja.",
|
||||
"add_to_event_type": "Dodaj u tip događaja",
|
||||
"create_account_password": "Kreiraj lozinku za nalog",
|
||||
"create_account_with_saml": "Креирајте налог помоћу SAML-а",
|
||||
"error_creating_account_password": "Neuspešno kreiranje lozinke za nalog",
|
||||
"cannot_create_account_password_cal_provider": "Ne može se kreirati lozinka za naloge kalendara",
|
||||
"cannot_create_account_password_already_existing": "Ne može se kreirati lozinka za već postojeće naloge",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Poveži Outlook kalendar",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook kalendar povezan",
|
||||
"outlook_connect_atom_loading_label": "Provera Outlook kalendara",
|
||||
"stripe_connect_atom_label": "Повежите се са Stripe-ом",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe је повезан",
|
||||
"stripe_connect_atom_loading_label": "Провера Stripe акредитива",
|
||||
"booking_question_response_variables": "Varijable odgovora na pitanja za rezervaciju",
|
||||
"managed_by_teamAdmins": "Upravljaju {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opcija",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Ograničite koliko puta članovi mogu biti zakazani za sve vrste timskih događaja (kolektivne i round-robin)",
|
||||
"booking_limits_updated_successfully": "Ograničenja zakazivanja su uspešno ažurirana",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemate ovlašćenje da izvršite ovu promenu u rezervaciji",
|
||||
"hide_calendar_event_details": "Sakrij detalje događaja u deljenim kalendarima",
|
||||
"description_hide_calendar_event_details": "Kada je kalendar podeljen, događaji su vidljivi čitaocima, ali su njihovi detalji skriveni od onih koji nemaju pravo izmene.",
|
||||
"last_number_of_days": "poslednjih {{count}} dana",
|
||||
"month_to_date": "od početka meseca do danas",
|
||||
"year_to_date": "od početka godine do danas",
|
||||
"custom_range": "prilagođeni opseg",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodajte svoje nove stringove iznad ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Logga in med Google",
|
||||
"signin_with_saml": "Logga in med SAML",
|
||||
"signin_with_saml_oidc": "Logga in med SAML/OIDC",
|
||||
"continue_with_email": "Fortsätt med e-post",
|
||||
"continue_with_google": "Fortsätt med Google",
|
||||
"last_used": "Senast använd",
|
||||
"you_will_need_to_generate": "Du måste generera en åtkomsttoken från ditt gamla schemaläggningsverktyg.",
|
||||
"import": "Importera",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} installerade",
|
||||
"verify_wallet": "Verifiera plånbok",
|
||||
"create_events_on": "Skapa händelser i",
|
||||
"enterprise_license": "Det här är en företagsfunktion",
|
||||
"enterprise_license": "Detta är en kommersiell funktion",
|
||||
"enterprise_license_locally": "Du kan testa denna funktion lokalt men inte i produktion.",
|
||||
"enterprise_license_sales": "För att uppgradera till företagsversionen, kontakta vårt säljteam. Om en licensnyckel redan är på plats, kontakta support@cal.com för hjälp.",
|
||||
"enterprise_license_sales": "För att köpa en kommersiell licens, vänligen kontakta vårt säljteam. Om en licensnyckel redan är på plats, vänligen kontakta support@cal.com för hjälp.",
|
||||
"missing_license": "Licens saknas",
|
||||
"next_steps": "Kommande steg",
|
||||
"acquire_commercial_license": "Skaffa en kommersiell licens",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Välj tid",
|
||||
"select_date": "Välj datum",
|
||||
"connecting_you_to_someone": "Vi kopplar dig till någon.",
|
||||
"please_do_not_close_this_tab": "Stäng inte denna flik",
|
||||
"please_do_not_close_this_tab": "Vänligen stäng eller uppdatera inte den här sidan",
|
||||
"see_all_available_times": "Se alla tillgängliga tider",
|
||||
"org_team_names_example_1": "t.ex. marknadsföringsteam",
|
||||
"org_team_names_example_2": "t.ex. säljteam",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Förhindra medlemmar från att skapa egna händelsetyper.",
|
||||
"add_to_event_type": "Lägg till i händelsetyp",
|
||||
"create_account_password": "Skapa kontolösenord",
|
||||
"create_account_with_saml": "Skapa konto med SAML",
|
||||
"error_creating_account_password": "Misslyckades med att skapa kontolösenord",
|
||||
"cannot_create_account_password_cal_provider": "Kan inte skapa kontolösenord för cal-konton",
|
||||
"cannot_create_account_password_already_existing": "Kan inte skapa kontolösenord för redan skapade konton",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Anslut Outlook Kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Ansluten Outlook Kalender",
|
||||
"outlook_connect_atom_loading_label": "Kontrollerar Outlook Kalender",
|
||||
"stripe_connect_atom_label": "Anslut till Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe ansluten",
|
||||
"stripe_connect_atom_loading_label": "Kontrollerar Stripe-uppgifter",
|
||||
"booking_question_response_variables": "Variabler för bokningsfrågesvar",
|
||||
"managed_by_teamAdmins": "Hanteras av {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} alternativ",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Begränsa hur många gånger medlemmar kan bokas för alla typer av teamhändelser (kollektivt och round-robin)",
|
||||
"booking_limits_updated_successfully": "Bokningsgränser uppdaterades framgångsrikt",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har inte behörighet att göra denna ändring i bokningen",
|
||||
"hide_calendar_event_details": "Dölj kalenderhändelsedetaljer på delade kalendrar",
|
||||
"description_hide_calendar_event_details": "När en kalender delas är händelser synliga för läsare, men deras detaljer är dolda för dem utan skrivåtkomst.",
|
||||
"last_number_of_days": "senaste {{count}} dagarna",
|
||||
"month_to_date": "månad till datum",
|
||||
"year_to_date": "år till datum",
|
||||
"custom_range": "anpassat intervall",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"signin_with_google": "Google ile giriş yap",
|
||||
"signin_with_saml": "SAML ile giriş yap",
|
||||
"signin_with_saml_oidc": "SAML/OIDC ile giriş yapın",
|
||||
"continue_with_email": "E-posta ile devam et",
|
||||
"continue_with_google": "Google ile devam et",
|
||||
"last_used": "Son kullanılan",
|
||||
"you_will_need_to_generate": "Entegrasyon sayfasında bir erişim token'ı oluşturmanız gerekir.",
|
||||
"import": "İçe aktar",
|
||||
@@ -1027,9 +1029,9 @@
|
||||
"installed_other": "{{count}} yüklü",
|
||||
"verify_wallet": "Cüzdanı Doğrula",
|
||||
"create_events_on": "Şurada etkinlik oluşturun:",
|
||||
"enterprise_license": "Bu bir kurumsal özelliktir",
|
||||
"enterprise_license": "Bu ticari bir özelliktir",
|
||||
"enterprise_license_locally": "Bu özelliği yerel olarak test edebilirsiniz ama üretim ortamında değil.",
|
||||
"enterprise_license_sales": "Kurumsal sürüme yükseltmek için satış ekibimize ulaşın. Lisans anahtarı zaten varsa support@cal.com adresine başvurun.",
|
||||
"enterprise_license_sales": "Ticari lisans satın almak için satış ekibimizle iletişime geçin. Zaten bir lisans anahtarınız varsa, yardım için lütfen support@cal.com adresine başvurun.",
|
||||
"missing_license": "Eksik Lisans",
|
||||
"next_steps": "Sonraki Adımlar",
|
||||
"acquire_commercial_license": "Ticari bir lisans edinin",
|
||||
@@ -2166,7 +2168,7 @@
|
||||
"select_time": "Saati Seçin",
|
||||
"select_date": "Tarihi Seçin",
|
||||
"connecting_you_to_someone": "Sizi birine bağlıyoruz.",
|
||||
"please_do_not_close_this_tab": "Lütfen bu sekmeyi kapatmayın",
|
||||
"please_do_not_close_this_tab": "Lütfen bu sayfayı kapatmayın veya yenilemeyin",
|
||||
"see_all_available_times": "Tüm müsait saatleri görün",
|
||||
"org_team_names_example_1": "Örneğin. Pazarlama ekibi",
|
||||
"org_team_names_example_2": "Örn. Satış Ekibi",
|
||||
@@ -2480,6 +2482,7 @@
|
||||
"lock_org_users_eventtypes_description": "Üyelerin kendi etkinlik türlerini oluşturmasını engelle.",
|
||||
"add_to_event_type": "Etkinlik türüne ekle",
|
||||
"create_account_password": "Hesap şifresi oluştur",
|
||||
"create_account_with_saml": "SAML ile hesap oluştur",
|
||||
"error_creating_account_password": "Hesap şifresi oluşturulamadı",
|
||||
"cannot_create_account_password_cal_provider": "Cal hesapları için hesap şifresi oluşturulamıyor",
|
||||
"cannot_create_account_password_already_existing": "Zaten oluşturulmuş olanlar için hesap şifresi oluşturulamaz",
|
||||
@@ -2598,6 +2601,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Takvimi Bağla",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Takvimi Bağlı",
|
||||
"outlook_connect_atom_loading_label": "Outlook Takvimi Kontrol Ediliyor",
|
||||
"stripe_connect_atom_label": "Stripe'a Bağlan",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Bağlı",
|
||||
"stripe_connect_atom_loading_label": "Stripe kimlik bilgileri kontrol ediliyor",
|
||||
"booking_question_response_variables": "Rezervasyon soru yanıt değişkenleri",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} tarafından yönetiliyor",
|
||||
"number_of_options": "{{count}} seçenek",
|
||||
@@ -2609,5 +2615,11 @@
|
||||
"limit_team_booking_frequency_description": "Ekip üyelerinin tüm ekip etkinlik türlerinde (kolektif ve round-robin) kaç kez rezerve edilebileceğini sınırlayın",
|
||||
"booking_limits_updated_successfully": "Rezervasyon sınırları başarıyla güncellendi",
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu rezervasyonda değişiklik yapma yetkiniz yok",
|
||||
"hide_calendar_event_details": "Paylaşılan takvimlerde etkinlik ayrıntılarını gizle",
|
||||
"description_hide_calendar_event_details": "Bir takvim paylaşıldığında, etkinlikler okuyuculara görünür ancak yazma erişimi olmayanlar için ayrıntılar gizlenir.",
|
||||
"last_number_of_days": "son {{count}} gün",
|
||||
"month_to_date": "ayın başından bugüne",
|
||||
"year_to_date": "yılın başından bugüne",
|
||||
"custom_range": "özel aralık",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user