Merge branch 'main' into feat/organizations

This commit is contained in:
Leo Giovanetti 2023-06-07 10:50:58 -03:00
commit b936e9c8d3
81 changed files with 1336 additions and 144 deletions

View File

@ -416,7 +416,7 @@ Make sure to complete section "Obtaining the Google API Credentials". After the
following
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
1. Under 'OAuth concent screen', click "PUBLISH APP"
1. Under 'OAuth consent screen', click "PUBLISH APP"
### Obtaining Microsoft Graph Client ID and Secret

View File

@ -1133,12 +1133,7 @@ export const EmbedDialog = () => {
const router = useRouter();
const embedUrl: string = router.query.embedUrl as string;
return (
<Dialog
name="embed"
clearQueryParamsOnClose={queryParamsForDialog}
onOpenChange={(open) => {
if (!open) window.resetEmbedStatus();
}}>
<Dialog name="embed" clearQueryParamsOnClose={queryParamsForDialog}>
{!router.query.embedType ? (
<ChooseEmbedTypesDialogContent />
) : (

View File

@ -1,11 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
const signupSchema = z.object({
username: z.string(),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return;
@ -17,7 +26,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const data = req.body;
const { email, password } = data;
const { email, password, language } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
@ -26,16 +36,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
if (!userEmail || !userEmail.includes("@")) {
res.status(422).json({ message: "Invalid email" });
return;
}
if (!password || password.trim().length < 7) {
res.status(422).json({ message: "Invalid input - password should be at least 7 characters long." });
return;
}
// There is an existingUser if the username matches
// OR if the email matches AND either the email is verified
// or both username and password are set
@ -106,5 +106,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
await sendEmailVerification({
email: userEmail,
username,
language,
});
res.status(201).json({ message: "Created user" });
}

View File

@ -0,0 +1,48 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
const verifySchema = z.object({
token: z.string(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (!foundToken) {
return res.status(401).json({ message: "No token found" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
const user = await prisma.user.update({
where: {
email: foundToken?.identifier,
},
data: {
emailVerified: new Date(),
},
});
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id: foundToken?.id,
},
});
const hasCompletedOnboarding = user.completedOnboarding;
res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}

View File

@ -60,9 +60,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("OrganizerRequestEmail", {
calEvent: evt,
attendee: evt.organizer,
renderEmail("VerifyAccountEmail", {
language: t,
user: {
name: "Pro Example",
email: "pro@example.com",
},
verificationEmailLink:
"http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
})
);
res.end();

View File

@ -0,0 +1,60 @@
import { MailOpenIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
import { Button, EmptyScreen, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
function VerifyEmailPage() {
const { data } = useEmailVerifyCheck();
const { data: session } = useSession();
const router = useRouter();
const { t } = useLocale();
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
useEffect(() => {
if (data?.isVerified) {
router.replace("/getting-started");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.isVerified]);
return (
<div className="h-[100vh] w-full ">
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="max-w-3xl">
<EmptyScreen
border
dashedBorder={false}
Icon={MailOpenIcon}
headline={t("check_your_email")}
description={t("verify_email_page_body", { email: session?.user?.email, appName: APP_NAME })}
className="bg-default"
buttonRaw={
<Button
color="minimal"
className="underline"
loading={mutation.isLoading}
onClick={() => {
showToast("Send email", "success");
mutation.mutate();
}}>
Resend Email
</Button>
}
/>
</div>
</div>
</div>
);
}
export default VerifyEmailPage;
VerifyEmailPage.PageWrapper = PageWrapper;

View File

@ -9,6 +9,7 @@ import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -29,10 +30,10 @@ type FormValues = {
};
export default function Signup({ prepopulateFormValues, token }: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const { t, i18n } = useLocale();
const router = useRouter();
const flags = useFlagMap();
const telemetry = useTelemetry();
const methods = useForm<FormValues>({
defaultValues: prepopulateFormValues,
});
@ -52,6 +53,7 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps<t
await fetch("/api/auth/signup", {
body: JSON.stringify({
...data,
language: i18n.language,
}),
headers: {
"Content-Type": "application/json",
@ -61,11 +63,12 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps<t
.then(handleErrors)
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
await signIn<"credentials">("credentials", {
...data,
callbackUrl: router.query.callbackUrl
? `${WEBAPP_URL}/${router.query.callbackUrl}`
: `${WEBAPP_URL}/getting-started`,
: `${WEBAPP_URL}/${verifyOrGettingStarted}`,
});
})
.catch((err) => {

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "قم بإنهاء إعداد حسابك في {{appName}}! متبقي بضع خطوات فقط لحل جميع مشاكل الجدولة لديك.",
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
"verify_email_banner_button": "إرسال بريد إلكتروني",
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{title}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
@ -1794,6 +1795,7 @@
"complete_your_booking": "أكمل الحجز",
"complete_your_booking_subject": "أكمل الحجز: {{title}} في {{date}}",
"confirm_your_details": "تأكيد التفاصيل الخاصة بك",
"never_expire": "لا تنتهي الصلاحية أبدًا",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "أنت على وشك استحصال مبلغ {{amount, currency}} من أحد الحضور. هل أنت متأكد من أنك تريد المتابعة؟",
"charge_attendee": "استحصال مبلغ {{amount, currency}} من أحد الحضور",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Dokončete nastavení svého účtu {{appName}}! Jste jen několik kroků od vyřešení všech svých problémů s plánováním.",
"have_any_questions": "Máte otázky? Jsme tu, abychom vám pomohli.",
"reset_password_subject": "{{appName}}: Pokyny pro obnovení hesla",
"verify_email_banner_button": "Odeslat e-mail",
"event_declined_subject": "Odmítnuto: {{title}} v {{date}}",
"event_cancelled_subject": "Zrušeno: {{title}} v {{date}}",
"event_request_declined": "Žádost o událost byla zamítnuta",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Dokončete svou rezervaci",
"complete_your_booking_subject": "Dokončete svou rezervaci: {{title}} dne {{date}}",
"confirm_your_details": "Potvrďte své údaje",
"never_expire": "Platnost nikdy neuplyne",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Chystáte se účtovat účastníkovi {{amount, currency}}. Opravdu chcete pokračovat?",
"charge_attendee": "Naúčtovat účastníkovi {{amount, currency}}",

View File

@ -10,6 +10,7 @@
"calcom_explained_new_user": "Afslut opsætningen af din {{appName}} konto! Du er kun få skridt fra at løse alle dine planlægningsproblemer.",
"have_any_questions": "Har du spørgsmål? Vi er her for at hjælpe.",
"reset_password_subject": "{{appName}}: Nulstil adgangskodeinstruktioner",
"verify_email_banner_button": "Send e-mail",
"event_declined_subject": "Afvist: {{title}} den {{date}}",
"event_cancelled_subject": "Aflyst: {{title}} den {{date}}",
"event_request_declined": "Din begivenhedsanmodning er blevet afvist",
@ -1600,5 +1601,6 @@
"this_will_be_the_placeholder": "Dette vil være pladsholderen",
"verification_code": "Bekræftelseskode",
"verify": "Bekræft",
"timezone_variable": "Tidszone"
"timezone_variable": "Tidszone",
"never_expire": "Udløber aldrig"
}

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Beenden Sie die Einrichtung Ihres {{appName}} -Kontos! Sie sind nur ein paar Schritte von der Lösung aller Probleme in der Zeitplanung entfernt.",
"have_any_questions": "Haben Sie Fragen? Wir sind hier um Ihnen zu helfen.",
"reset_password_subject": "{{appName}}: Anleitung zum Zurücksetzen des Passworts",
"verify_email_banner_button": "E-Mail senden",
"event_declined_subject": "Abgelehnt: {{title}} am {{date}}",
"event_cancelled_subject": "Storniert: {{title}} um {{date}}",
"event_request_declined": "Ihre Event-Anfrage wurde abgelehnt",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Schließen Sie Ihre Buchung ab",
"complete_your_booking_subject": "Schließen Sie Ihre Buchung ab: {{title}} am {{date}}",
"confirm_your_details": "Bestätigen Sie Ihre Daten",
"never_expire": "Läuft niemals ab",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Sie sind im Begriff, den Teilnehmer {{amount, currency}} in Rechnung zu stellen. Sind Sie sicher, dass Sie fortfahren möchten?",
"charge_attendee": "Teilnehmer {{amount, currency}} berechnen",

View File

@ -11,6 +11,16 @@
"calcom_explained_new_user": "Finish setting up your {{appName}} account! Youre just a few steps away from solving all your scheduling problems.",
"have_any_questions": "Have questions? We're here to help.",
"reset_password_subject": "{{appName}}: Reset password instructions",
"verify_email_subject": "{{appName}}: Verify your account",
"check_your_email": "Check your email",
"verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.",
"verify_email_banner_body": "Please verify your email adress to guarantee the best email and calendar deliverability from {{appName}}.",
"verify_email_banner_button": "Send email",
"verify_email_email_header": "Verify your email address",
"verify_email_email_button": "Verify email",
"verify_email_email_body": "Please verify your email address by clicking the button below.",
"verify_email_email_link_text": "Here's the link incase you don't like clicking buttons:",
"email_sent": "Email sent successfully",
"event_declined_subject": "Declined: {{title}} at {{date}}",
"event_cancelled_subject": "Cancelled: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
@ -1687,7 +1697,7 @@
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
"show_on_booking_page":"Show on booking page",
"show_on_booking_page": "Show on booking page",
"get_started_zapier_templates": "Get started with Zapier templates",
"team_is_unpublished": "{{team}} is unpublished",
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
@ -1819,6 +1829,17 @@
"complete_your_booking": "Complete your booking",
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
"confirm_your_details": "Confirm your details",
"copy_invite_link": "Copy invite link",
"edit_invite_link": "Edit link settings",
"invite_link_copied": "Invite link copied",
"invite_link_deleted": "Invite link deleted",
"invite_link_updated": "Invite link settings saved",
"link_expires_after": "Links set to expire after...",
"one_day": "1 day",
"seven_days": "7 days",
"thirty_days": "30 days",
"never_expire": "Never expires",
"team_invite_received": "You have been invited to join {{teamName}}",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "You are about to charge the attendee {{amount, currency}}. Are you sure you want to continue?",
"charge_attendee": "Charge attendee {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "¡Termine de configurar su cuenta de {{appName}}! Solo le faltan unos pasos para resolver todos sus problemas de programación.",
"have_any_questions": "¿Tienes preguntas? Estamos aquí para ayudar.",
"reset_password_subject": "{{appName}}: Instrucciones para restablecer la contraseña",
"verify_email_banner_button": "Enviar correo electrónico",
"event_declined_subject": "Rechazado: {{title}} en {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} en {{date}}",
"event_request_declined": "Su solicitud de reunión ha sido rechazada",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Complete su reserva",
"complete_your_booking_subject": "Complete su reserva: {{title}} el {{date}}",
"confirm_your_details": "Confirme sus datos",
"never_expire": "Nunca caduca",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Está a punto de cobrarle {{amount, currency}} al asistente. ¿Está seguro de que desea continuar?",
"charge_attendee": "Cobrarle al asistente {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Terminez la configuration de votre compte {{appName}} ! Vous n'êtes qu'à quelques pas de résoudre tous vos problèmes de planification.",
"have_any_questions": "Vous avez des questions ? Nous sommes là pour vous aider.",
"reset_password_subject": "{{appName}} : Instructions de réinitialisation de mot de passe",
"verify_email_banner_button": "Envoyer un e-mail",
"event_declined_subject": "Refusé : {{title}} le {{date}}",
"event_cancelled_subject": "Annulé : {{title}} le {{date}}",
"event_request_declined": "Votre demande d'événement a été refusée",
@ -1817,6 +1818,17 @@
"complete_your_booking": "Terminer votre réservation",
"complete_your_booking_subject": "Terminer votre réservation : {{title}} le {{date}}",
"confirm_your_details": "Confirmez vos coordonnées",
"copy_invite_link": "Copier le lien d'invitation",
"edit_invite_link": "Modifier les paramètres du lien",
"invite_link_copied": "Lien d'invitation copié",
"invite_link_deleted": "Lien d'invitation supprimé",
"invite_link_updated": "Paramètres de lien d'invitation enregistrés",
"link_expires_after": "Les liens ont été définis pour expirer après...",
"one_day": "1 jour",
"seven_days": "7 jours",
"thirty_days": "30 jours",
"never_expire": "N'expire jamais",
"team_invite_received": "Vous avez été invité(e) à rejoindre {{teamName}}",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Vous êtes sur le point de facturer {{amount, currency}} au participant. Voulez-vous vraiment continuer ?",
"charge_attendee": "Facturer {{amount, currency}} au participant",

View File

@ -11,6 +11,16 @@
"calcom_explained_new_user": "סיים להגדיר את החשבון של {{appName}}! אתה רק מספר צעדים מפתרון כל בעיות התזמון שלך.",
"have_any_questions": "יש לך שאלות? אנחנו כאן כדי לעזור.",
"reset_password_subject": "{{appName}}: הנחיות לאיפוס סיסמה",
"verify_email_subject": "{{appName}}: אמת את החשבון",
"check_your_email": "בדוק את הדוא״ל שלך",
"verify_email_page_body": "שלחנו מייל ל- {{email}}. חשוב שתאמת את כתובת הדוא״ל שלך כדי להבטיח את תעבורת הדוא״ל והיומן מ- {{appName}}.",
"verify_email_banner_body": "אנא אמת את חשבון הדוא״ל שלך כדי להבטיח את תעבורת מ- {{appName}}.",
"verify_email_banner_button": "שליחת דוא\"ל",
"verify_email_email_header": "אמת את חשבון הדוא״ל שלך",
"verify_email_email_button": "אמת דוא״ל",
"verify_email_email_body": "אנא אמת את חשבון הדוא״ל שלך על ידי לחיצה על הכפתור מטה.",
"verify_email_email_link_text": "הנה הקישור, אם אתה לא אוהב ללחוץ על כפתורים:",
"email_sent": "מייל נשלח בהצלחה",
"event_declined_subject": "נדחה: {{title}} ב- {{date}}",
"event_cancelled_subject": "בוטל: {{title}} ב- {{date}}",
"event_request_declined": "בקשת האירוע שלך נדחתה",
@ -195,6 +205,7 @@
"page_doesnt_exist": "דף זה אינו קיים.",
"check_spelling_mistakes_or_go_back": "ודא כי שאין שגיאות איות או חזור/י לדף הקודם.",
"404_page_not_found": "404: לא ניתן למצוא את הדף הזה.",
"booker_event_not_found": "לא מצאנו את הארוע שניסית להזמין.",
"getting_started": "תחילת העבודה",
"15min_meeting": "פגישה של 15 דקות",
"30min_meeting": "פגישה של 30 דקות",
@ -293,6 +304,10 @@
"success": "הפעולה בוצעה בהצלחה",
"failed": "הפעולה נכשלה",
"password_has_been_reset_login": "הסיסמה שלך אופסה. עכשיו ניתן להיכנס עם הסיסמה החדשה.",
"bookerlayout_title": "פריסה",
"bookerlayout_default_title": "תצוגת ברירת מחדל",
"bookerlayout_description": "אתה יכול לבחור כמה והמתזמנים שלך יכולים לשנות תצוגות.",
"bookerlayout_user_settings_title": "פריסת תזמון",
"unexpected_error_try_again": "אירעה שגיאה בלתי צפויה. נא לנסות שוב.",
"sunday_time_error": "שעה לא תקפה ביום ראשון",
"monday_time_error": "שעה לא תקפה ביום שני",
@ -1794,6 +1809,7 @@
"complete_your_booking": "יש להשלים את ההזמנה",
"complete_your_booking_subject": "יש להשלים את ההזמנה: {{title}} ב-{{date}}",
"confirm_your_details": "אישור הפרטים שלך",
"never_expire": "התוקף לעולם לא יפוג",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "את/ה עומד/ת לחייב את המשתתף/ת בסכום של {{amount, currency}}. בטוח שברצונך להמשיך?",
"charge_attendee": "לחייב את המשתתף/ת ב-{{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Completa la configurazione del tuo account {{appName}}! Mancano solo pochi passi per risolvere tutti i problemi di pianificazione.",
"have_any_questions": "Hai domande? Siamo qui per aiutare.",
"reset_password_subject": "{{appName}}: istruzioni per reimpostare la password",
"verify_email_banner_button": "Invia e-mail",
"event_declined_subject": "Rifiutato: {{title}} il {{date}}",
"event_cancelled_subject": "Cancellato: {{title}} il {{date}}",
"event_request_declined": "La tua richiesta per l'evento è stata rifiutata",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Completa la prenotazione",
"complete_your_booking_subject": "Completa la prenotazione: {{title}} del {{date}}",
"confirm_your_details": "Conferma i tuoi dati",
"never_expire": "Nessuna scadenza",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Stai per addebitare {{amount, currency}} al partecipante. Continuare?",
"charge_attendee": "Addebita {{amount, currency}} al partecipante",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "{{appName}} アカウントのセットアップを完了してください。あと数ステップでスケジューリングの問題をすべて解決できます。",
"have_any_questions": "ご不明な点があれば、お気軽にお問い合わせください。",
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
"verify_email_banner_button": "メールを送信",
"event_declined_subject": "却下: {{title}} {{date}}",
"event_cancelled_subject": "キャンセル: {{title}} {{date}}",
"event_request_declined": "イベントのリクエストが拒否されました",
@ -240,8 +241,8 @@
"set_availability": "利用可否の設定",
"continue_without_calendar": "カレンダーなしで続行",
"connect_your_calendar": "カレンダーに接続",
"connect_your_video_app": "ビデオアプリを接続",
"connect_your_video_app_instructions": "自分のイベントタイプでビデオアプリを使用するにはビデオアプリを接続します。",
"connect_your_video_app": "ビデオ アプリを接続",
"connect_your_video_app_instructions": "自分のイベント タイプでビデオ アプリを使用するにはビデオ アプリを接続します。",
"connect_your_calendar_instructions": "カレンダーに接続すると、予定済の忙しい時間と新しいイベントを自動的に確認できます。",
"set_up_later": "あとで設定",
"current_time": "現在の時刻",
@ -371,7 +372,7 @@
"create_webhook": "Webhook を作成",
"booking_cancelled": "予約がキャンセルされました",
"booking_rescheduled": "予約の再スケジュール済",
"recording_ready": "レコーディングのダウンロードリンクの準備ができています",
"recording_ready": "レコーディングのダウンロード リンクの準備ができています",
"booking_created": "予約を作成しました",
"meeting_ended": "ミーティングが終了しました",
"form_submitted": "フォームが送信されました",
@ -757,9 +758,9 @@
"length": "長さ",
"minimum_booking_notice": "最低頻度の通知",
"offset_toggle": "オフセット開始時間",
"offset_toggle_description": "オフセットの時間帯は指定された何分かまで予約者に表示されます",
"offset_start": "までオフセット",
"offset_start_description": "これにより例えば、{{ originalTime }}ではなく{{ adjustedTime }}に時間帯が予約者に表示されます",
"offset_toggle_description": "オフセットの時間帯は指定された時間 (分) まで予約者に表示されます",
"offset_start": "オフセット時間 (分)",
"offset_start_description": "これにより例えば、{{ originalTime }}ではなく{{ adjustedTime }}に予約者に時間帯が表示されます",
"slot_interval": "時間帯の間隔",
"slot_interval_default": "イベントの長さを使用する (デフォルト)",
"delete_event_type": "イベントの種類を削除しますか?",
@ -1047,7 +1048,7 @@
"event_cancelled_trigger": "イベントキャンセル時に",
"new_event_trigger": "新しいイベント予約時に",
"email_host_action": "ホストにメールを送信",
"email_attendee_action": "メールを出席者に送信",
"email_attendee_action": "出席者にメールを送る",
"sms_number_action": "特定の番号に SMS を送信",
"workflows": "ワークフロー",
"new_workflow_btn": "新しいワークフロー",
@ -1621,7 +1622,7 @@
"email_user_cta": "招待を表示",
"email_no_user_invite_heading": "{{appName}} のチームに参加するよう招待されました",
"email_no_user_invite_subheading": "{{invitedBy}} は {{appName}} のチームに参加するようあなたを招待しました。{{appName}} は、イベント調整スケジューラーです。チームと延々とメールのやりとりをすることなくミーティングのスケジュール設定を行うことができます。",
"email_user_invite_subheading": "{{invitedBy}}があなたを{{appName}}の「{{teamName}}」に参加するよう招待しました。{{appName}}はイベント調整スケジューラーで、あなたとチームは延々とメールのやりとりをすることなく、ミーティングをスケジュール設定することができます。",
"email_user_invite_subheading": "{{invitedBy}}から{{appName}}の「{{teamName}}」に参加するよう招待されました。{{appName}}はイベント調整スケジューラーで、チーム内で延々とメールのやりとりをすることなく、ミーティングのスケジュールを設定できます。",
"email_no_user_invite_steps_intro": "いくつかの短い手順を踏むだけで、すぐにチームとの間のストレスフリーなスケジュール設定をお楽しみいただけます。",
"email_no_user_step_one": "ユーザー名を選択",
"email_no_user_step_two": "カレンダーアカウントを接続",
@ -1698,14 +1699,14 @@
"no_responses_yet": "回答はまだありません",
"this_will_be_the_placeholder": "これがプレースホルダーになります",
"error_booking_event": "イベントを予約する際にエラーが発生しました。ページを再読み込みして、もう一度お試しください",
"timeslot_missing_title": "時間帯を選んでいません",
"timeslot_missing_title": "時間帯が選択されていません",
"timeslot_missing_description": "イベントを予約するには時間帯を選んでください。",
"timeslot_missing_cta": "時間帯を選ぶ",
"switch_monthly": "月ごとの表示に切り替える",
"switch_weekly": "週ごとの表示に切り替える",
"switch_multiday": "日ごとの表示に切り替える",
"num_locations": "{{num}}件の場所のオプション",
"select_on_next_step": "次のステップについて選ぶ",
"num_locations": "{{num}} 件の場所の選択肢",
"select_on_next_step": "次のステップ選ぶ",
"this_meeting_has_not_started_yet": "このミーティングはまだ開始されていません",
"this_app_requires_connected_account": "{{appName}} には接続された {{dependencyName}} アカウントが必要です",
"connect_app": "{{dependencyName}} に接続する",
@ -1793,34 +1794,35 @@
"seats_and_no_show_fee_error": "現在、座席の有効化と不参加費用の請求はできません",
"complete_your_booking": "予約を完了する",
"complete_your_booking_subject": "次の予約を完了する: {{date}} の {{title}}",
"confirm_your_details": "詳細情報を確認",
"confirm_your_details": "詳細を確認",
"never_expire": "有効期限なし",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "出席者に{{amount, currency}}を請求しようとしています。続けますか",
"charge_attendee": "出席者に{{amount, currency}}を請求",
"payment_app_commission": "支払い(取引ごとに {{paymentFeePercentage}}% + {{fee, currency}} の手数料)が必要です",
"charge_card_dialog_body": "出席者に {{amount, currency}} を請求しようとしています。続けますか?",
"charge_attendee": "出席者に {{amount, currency}} を請求",
"payment_app_commission": "支払い (取引ごとに {{paymentFeePercentage}}% + {{fee, currency}} の手数料) が必要です",
"email_invite_team": "{{email}} が招待されました",
"email_invite_team_bulk": "{{userCount}} 人のユーザーが招待されました",
"error_collecting_card": "カードの収集中にエラーが発生しました",
"image_size_limit_exceed": "アップロードする画像のサイズは 5 MB 以下である必要があります",
"inline_embed": "インラインに埋め込む",
"load_inline_content": "あなたのイベントタイプを他のウェブサイトコンテンツにインラインで直接読み込",
"floating_pop_up_button": "フローティングポップアップボタン",
"floating_button_trigger_modal": "あなたのイベントタイプのモーダルをトリガーするフローティングボタンをサイトに設置",
"load_inline_content": "イベントタイプを他のウェブサイト コンテンツにインラインで直接読み込みます。",
"floating_pop_up_button": "フローティング ポップアップ ボタン",
"floating_button_trigger_modal": "イベント タイプのモーダルをトリガーするフローティング ボタンをサイトに設置します。",
"pop_up_element_click": "要素のクリックでポップアップ",
"open_dialog_with_element_click": "誰かが要素をクリックしたら Cal ダイアログをオープン",
"need_help_embedding": "ヘルプが必要ですか? Cal を Wix や Squarespace、WordPress に埋め込むためのガイドを見て、よくある質問を確認したり、高度な埋め込みオプションを検討してください。",
"open_dialog_with_element_click": "誰かが要素をクリックしたら Cal ダイアログをオープンします。",
"need_help_embedding": "サポートが必要ですか? Cal を Wix や Squarespace、WordPress に埋め込むためのガイドを読んだり、よくある質問を確認したり、高度な埋め込みオプションを試してみたりしてください。",
"book_my_cal": "Cal を予約",
"invite_as": "として招待",
"invite_as": "次の役割で招待",
"form_updated_successfully": "フォームは正常に更新されました。",
"email_not_cal_member_cta": "チームに参加",
"disable_attendees_confirmation_emails": "出席者にデフォルトの確認メールを送らない",
"disable_attendees_confirmation_emails": "出席者へのデフォルトの確認メールを無効にする",
"disable_attendees_confirmation_emails_description": "このイベントタイプでは少なくとも1つのワークフローがアクティブで、イベントが予約されると出席者にメールが送られます。",
"disable_host_confirmation_emails": "ホストにデフォルトの確認メールを送らない",
"disable_host_confirmation_emails": "ホストへのデフォルトの確認メールを無効にする",
"disable_host_confirmation_emails_description": "このイベントタイプでは少なくとも1つのワークフローがアクティブで、イベントが予約されるとホストにメールが送られます。",
"add_an_override": "上書きを追加",
"import_from_google_workspace": "Google Workspace からユーザーをインポート",
"connect_google_workspace": "Google Workspace を接続",
"google_workspace_admin_tooltip": "この機能を使うには Workspace の管理者になる必要があります",
"first_event_type_webhook_description": "このイベントタイプのための最初のウェブフックを作成",
"create_for": "のために作成"
"first_event_type_webhook_description": "このイベントタイプの最初のウェブフックを作成",
"create_for": "作成対象"
}

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "{{appName}} 계정 설정을 완료하세요! 몇 단계만 거치면 모든 일정 문제를 해결할 수 있습니다.",
"have_any_questions": "질문이 있나요? 도와드릴게요.",
"reset_password_subject": "{{appName}}: 비밀번호 재설정 방법",
"verify_email_banner_button": "이메일 보내기",
"event_declined_subject": "거절됨: {{date}} {{title}}",
"event_cancelled_subject": "취소됨: {{date}} {{title}}",
"event_request_declined": "회의 요청이 거절되었습니다.",
@ -1794,6 +1795,7 @@
"complete_your_booking": "예약을 완료하세요",
"complete_your_booking_subject": "예약 완료: {{title}} 날짜 {{date}}",
"confirm_your_details": "세부 정보 확인",
"never_expire": "만료되지 않음",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "참석자에게 {{amount, currency}}을 청구하려고 합니다. 계속 진행하시겠습니까?",
"charge_attendee": "참석자에게 {{amount, currency}}을 청구합니다.",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Voltooi het instellen van uw {{appName}}-account! U bent slechts enkele stappen verwijderd van het oplossen van al uw planningsproblemen.",
"have_any_questions": "Heeft u vragen? We zijn er om te helpen.",
"reset_password_subject": "{{appName}}: Instructies voor het opnieuw instellen van uw wachtwoord",
"verify_email_banner_button": "E-mail versturen",
"event_declined_subject": "Afgewezen: {{title}}",
"event_cancelled_subject": "Geannuleerd: {{title}} op {{date}}",
"event_request_declined": "Uw gebeurtenisverzoek is geweigerd",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Voltooi uw boeking",
"complete_your_booking_subject": "Voltooi uw boeking: {{title}} op {{date}}",
"confirm_your_details": "Bevestig uw gegevens",
"never_expire": "Verloopt nooit",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "U staat op het punt om de deelnemer {{amount, currency}} in rekening te brengen. Weet u zeker dat u wilt doorgaan?",
"charge_attendee": "Deelnemer {{amount, currency}} in rekening brengen",

View File

@ -9,6 +9,7 @@
"calcom_explained": "{{appName}} er en åpen kildekodeversjon av Calendly som gir deg kontroll over dine egne data, arbeidsflyt og utseende.",
"have_any_questions": "Har du spørsmål? Vi er her for å hjelpe.",
"reset_password_subject": "{{appName}}: Instruksjoner for tilbakestilling av passord",
"verify_email_banner_button": "Send e-post",
"event_declined_subject": "Avslått: {{title}} kl. {{date}}",
"event_cancelled_subject": "Avbrutt: {{title}} kl. {{date}}",
"event_request_declined": "Din hendelsesforespørsel har blitt avvist",
@ -1447,5 +1448,6 @@
"email_no_user_cta": "Opprett brukeren din",
"change_default_conferencing_app": "Sett som standard",
"booking_confirmation_failed": "Booking-bekreftelse feilet",
"timezone_variable": "Tidssone"
"timezone_variable": "Tidssone",
"never_expire": "Utløper aldri"
}

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Dokończ konfigurację konta aplikacji {{appName}}! Od rozwiązania wszystkich Twoich problemów z układaniem grafików dzieli Cię tylko kilka kroków.",
"have_any_questions": "Masz pytania? Jesteśmy tutaj, aby pomóc.",
"reset_password_subject": "{{appName}}: Instrukcje resetowania hasła",
"verify_email_banner_button": "Wyślij wiadomość e-mail",
"event_declined_subject": "Odrzucono: {{title}} w dniu {{date}}",
"event_cancelled_subject": "Anulowano: {{title}} w {{date}}",
"event_request_declined": "Twoja prośba o wydarzenie została odrzucona",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Ukończ rezerwację",
"complete_your_booking_subject": "Ukończ rezerwację: {{title}} dnia {{date}}",
"confirm_your_details": "Potwierdź swoje dane",
"never_expire": "Nigdy nie wygasa",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Zamierzasz obciążyć uczestnika kwotą {{amount, currency}}. Czy na pewno chcesz kontynuować?",
"charge_attendee": "Pobierz od uczestnika opłatę w wysokości {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Termine de configurar sua conta do {{appName}}! Falta pouco para você solucionar todos os seus problemas de agendamento.",
"have_any_questions": "Precisa de ajuda? Estamos aqui para ajudar.",
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
"verify_email_banner_button": "Enviar e-mail",
"event_declined_subject": "Recusado: {{title}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
"event_request_declined": "Sua solicitação de reunião foi recusada",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Conclua sua reserva",
"complete_your_booking_subject": "Conclua sua reserva: {{title}} à(s) {{date}}",
"confirm_your_details": "Confirme suas informações",
"never_expire": "Nunca expira",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Você está prestes a cobrar {{amount, currency}} do participante. Tem certeza de que deseja continuar?",
"charge_attendee": "Cobrar {{amount, currency}} do participante",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Conclua a configuração da sua conta {{appName}}! Está apenas a alguns passos de resolver todos os seus problemas com agendamentos.",
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
"verify_email_banner_button": "Enviar e-mail",
"event_declined_subject": "Declinado: {{title}} em {{date}}",
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
"event_request_declined": "O seu pedido de evento foi declinado",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Concluir a sua reserva",
"complete_your_booking_subject": "Concluir a sua reserva: {{title}} a {{date}}",
"confirm_your_details": "Confirme os seus dados",
"never_expire": "Nunca expira",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Está em vias de cobrar {{amount, currency}} ao participante. Tem a certeza que pretende continuar?",
"charge_attendee": "Cobrar {{amount, currency}} ao participante",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Finalizați configurarea contului {{appName}}! Mai aveți doar câțiva pași până la soluționarea tuturor problemelor legate de program.",
"have_any_questions": "Aveți întrebări? Suntem aici pentru a vă ajuta.",
"reset_password_subject": "{{appName}}: instrucțiuni de resetare a parolei",
"verify_email_banner_button": "Trimiteți un e-mail",
"event_declined_subject": "Refuzat: {{title}} în {{date}}",
"event_cancelled_subject": "Anulat: {{title}} în {{date}}",
"event_request_declined": "Solicitarea pentru eveniment a fost refuzată",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Finalizați rezervarea",
"complete_your_booking_subject": "Finalizați rezervarea: {{title}}, pe {{date}}",
"confirm_your_details": "Confirmați datele dvs.",
"never_expire": "Nu expiră niciodată",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Urmează să percepeți de la participant {{amount, currency}}. Sigur doriți să continuați?",
"charge_attendee": "Percepeți {{amount, currency}} de la participant",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Завершите настройку учетной записи {{appName}}. Вы в считанных шагах от решения всех своих проблем с планированием.",
"have_any_questions": "Есть вопросы? Мы здесь, чтобы помочь.",
"reset_password_subject": "{{appName}}: Инструкция по сбросу пароля",
"verify_email_banner_button": "Отправить письмо",
"event_declined_subject": "Отклонено: {{title}} {{date}}",
"event_cancelled_subject": "Отменено: {{title}} {{date}}",
"event_request_declined": "Ваш запрос на встречу отклонен",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Завершить бронирование",
"complete_your_booking_subject": "Завершите бронирование: {{title}}, {{date}}",
"confirm_your_details": "Подтвердите свои данные",
"never_expire": "Не ограничен",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Вы собираетесь получить с участника {{amount, currency}}. Продолжить?",
"charge_attendee": "Получить с участника {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Završite podešavanje vašeg {{appName}} naloga! Na par koraka ste od rešavanja svih problema sa rasporedom.",
"have_any_questions": "Imate pitanja? Tu smo da pomognemo.",
"reset_password_subject": "{{appName}}: Upustva za resetovanje lozinke",
"verify_email_banner_button": "Pošaljite imejl",
"event_declined_subject": "Odbijeno: {{title}} datuma {{date}}",
"event_cancelled_subject": "Otkazano: {{title}} datuma {{date}}",
"event_request_declined": "Vaš zahtev za događaj je odbijen",
@ -1798,6 +1799,7 @@
"complete_your_booking": "Završite svoje zakazivanje",
"complete_your_booking_subject": "Završite svoje zakazivanje: {{title}} dana {{date}}",
"confirm_your_details": "Potvrdite svoje podatke",
"never_expire": "Nikada ne ističe",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Upravo ćete naplatiti polazniku {{amount, currency}}. Jeste li sigurni da želite da nastavite?",
"charge_attendee": "Naplatite polazniku {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Slutför konfigurationen av ditt {{appName}}-konto! Du är bara några steg från att lösa alla dina schemaläggningsproblem.",
"have_any_questions": "Har du frågor? Vi är här för att hjälpa till.",
"reset_password_subject": "{{appName}}: Instruktioner för att återställa ditt lösenord",
"verify_email_banner_button": "Skicka e-post",
"event_declined_subject": "Avvisades: {{title}} kl. {{date}}",
"event_cancelled_subject": "Avbröt: {{title}} kl. {{date}}",
"event_request_declined": "Din bokningsförfrågan har avbjöts",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Slutför din bokning",
"complete_your_booking_subject": "Slutför din bokning: {{title}} {{date}}",
"confirm_your_details": "Bekräfta dina uppgifter",
"never_expire": "Förfaller aldrig",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Du är på väg att debitera deltagaren {{amount, currency}}. Är du säker på att du vill fortsätta?",
"charge_attendee": "Debitera deltagare {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "{{appName}} hesabınızın kurulumunu tamamlayın! Planlama konusunda yaşadığınız tüm sorunlarınızı çözmekten sadece birkaç adım uzaktasınız.",
"have_any_questions": "Sorularınız mı var? Size yardımcı olmak için buradayız.",
"reset_password_subject": "{{appName}}: Şifre sıfırlama talimatları",
"verify_email_banner_button": "E-posta gönder",
"event_declined_subject": "Reddedildi: {{title}}, {{date}} tarihinde",
"event_cancelled_subject": "İptal edildi: {{title}}, {{date}} tarihinde",
"event_request_declined": "Etkinlik talebiniz reddedildi",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Rezervasyonunuzu tamamlayın",
"complete_your_booking_subject": "Rezervasyonunuzu tamamlayın: {{title}}, {{date}}",
"confirm_your_details": "Bilgilerinizi onaylayın",
"never_expire": "Süresiz kullanım",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Katılımcıdan {{amount, currency}} tahsil etmek üzeresiniz. Devam etmek istediğinizden emin misiniz?",
"charge_attendee": "Katılımcıdan {{amount, currency}} ücret tahsil edin",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Завершіть налаштування облікового запису {{appName}}! Ще кілька кроків, і всі проблеми з плануванням, які виникали у вас, буде вирішено.",
"have_any_questions": "Маєте запитання? Ми допоможемо.",
"reset_password_subject": "{{appName}}: інструкції зі скидання пароля",
"verify_email_banner_button": "Надіслати лист",
"event_declined_subject": "Відхилено: {{title}}, {{date}}",
"event_cancelled_subject": "Скасовано: {{title}}, {{date}}",
"event_request_declined": "Ваш запит на захід відхилено",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Завершіть бронювання",
"complete_your_booking_subject": "Завершіть бронювання: {{title}} від {{date}}",
"confirm_your_details": "Підтвердьте ваші дані",
"never_expire": "Немає терміну дії",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Ви збираєтеся списати кошти з рахунку учасника: {{amount, currency}}. Бажаєте продовжити?",
"charge_attendee": "Списати з рахунку учасника {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "Hoàn tất thiết lập tài khoản của bạn trong {{appName}}! Bạn chỉ còn vài bước nữa là giải quyết được mọi vấn đề về việc lên lịch kế hoạch.",
"have_any_questions": "Có câu hỏi? Chúng tôi luôn ở đây để giúp bạn.",
"reset_password_subject": "{{appName}}: Hướng dẫn thay đổi mật khẩu",
"verify_email_banner_button": "Gửi email",
"event_declined_subject": "Đã từ chối: {{title}} tại {{date}}",
"event_cancelled_subject": "Đã huỷ: {{title}} tại {{date}}",
"event_request_declined": "Lời mời sự kiện của bạn đã bị từ chối",
@ -1794,6 +1795,7 @@
"complete_your_booking": "Hoàn thành lịch hẹn của bạn",
"complete_your_booking_subject": "Hoàn thành lịch hẹn của bạn: {{title}} vào {{date}}",
"confirm_your_details": "Xác nhận các chi tiết của bạn",
"never_expire": "Không bao giờ hết hiệu lực",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "Bạn sắp sửa thu phí người tham gia một khoản {{amount, currency}}. Bạn có chắc chắn muốn tiếp tục?",
"charge_attendee": "Thu phí người tham gia một khoản {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "完成您的 {{appName}} 账户设置!您距离解决所有日程安排问题仅几步之遥。",
"have_any_questions": "有疑问?获取我们的帮助。",
"reset_password_subject": "{{appName}}: 重置密码教程",
"verify_email_banner_button": "发送电子邮件",
"event_declined_subject": "拒绝:{{title}} 在 {{date}}",
"event_cancelled_subject": "取消:{{title}} 在 {{date}}",
"event_request_declined": "您的活动预约请求已被拒绝",
@ -293,6 +294,18 @@
"success": "成功",
"failed": "失败",
"password_has_been_reset_login": "您的密码已重置。您现在可以使用您的新密码登录。",
"bookerlayout_title": "布局",
"bookerlayout_default_title": "默认视图",
"bookerlayout_description": "您可以选择多个布局,这样预约者可以切换视图。",
"bookerlayout_user_settings_title": "预约布局",
"bookerlayout_user_settings_description": "您可以选择多个布局,这样预约者可以切换视图。可针对每个事件进行替换。",
"bookerlayout_month_view": "月",
"bookerlayout_week_view": "每周",
"bookerlayout_column_view": "列",
"bookerlayout_error_min_one_enabled": "至少要启用一个布局。",
"bookerlayout_error_default_not_enabled": "您选择作为默认视图的布局不是启用布局的一部分。",
"bookerlayout_error_unknown_layout": "您选择的布局不是有效的布局。",
"bookerlayout_override_global_settings": "您可以在<2>设置/外观</2>或<6>只针对此事件替换</6>中管理所有事件类型的布局。",
"unexpected_error_try_again": "发生意外错误,请重试。",
"sunday_time_error": "周日有无效时间",
"monday_time_error": "周一有无效时间",
@ -1795,6 +1808,17 @@
"complete_your_booking": "完成您的预约",
"complete_your_booking_subject": "完成您的预约: {{date}} 的 {{title}}",
"confirm_your_details": "确认您的详细信息",
"copy_invite_link": "复制邀请链接",
"edit_invite_link": "编辑链接设置",
"invite_link_copied": "邀请链接已复制",
"invite_link_deleted": "邀请链接已删除",
"invite_link_updated": "邀请链接设置已保存",
"link_expires_after": "设置的链接过期时间为...",
"one_day": "1 天",
"seven_days": "7 天",
"thirty_days": "30 天",
"never_expire": "永不过期",
"team_invite_received": "您已被邀请加入 {{teamName}}",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "您即将向参与者收取费用 {{amount, currency}}。您确定要继续吗?",
"charge_attendee": "向参与者收取费用 {{amount, currency}}",

View File

@ -11,6 +11,7 @@
"calcom_explained_new_user": "設定好您的 {{appName}} 帳號!再完成幾個步驟,就能解決您所有的預定問題。",
"have_any_questions": "有問題嗎?我們隨時在此幫助。",
"reset_password_subject": "{{appName}}: 重新設置密碼說明",
"verify_email_banner_button": "傳送電子郵件",
"event_declined_subject": "已拒絕:{{date}} 與 {{title}}",
"event_cancelled_subject": "已取消:{{date}} 與 {{title}}",
"event_request_declined": "活動請求遭到拒絕",
@ -1794,6 +1795,7 @@
"complete_your_booking": "完成您的預約",
"complete_your_booking_subject": "完成您的預約:{{date}} 的 {{title}}",
"confirm_your_details": "確認詳細資料",
"never_expire": "永不過期",
"currency_string": "{{amount, currency}}",
"charge_card_dialog_body": "您即將向與會者收取 {{amount, currency}}。確定要繼續嗎?",
"charge_attendee": "向與會者收取 {{amount, currency}}",

View File

@ -26,6 +26,8 @@ export async function ssrInit(context: GetServerSidePropsContext) {
// always preload "viewer.public.i18n"
await ssr.viewer.public.i18n.fetch();
// So feature flags are available on first render
await ssr.viewer.features.map.prefetch();
return ssr;
}

View File

@ -6,6 +6,8 @@ import { getEventName } from "@calcom/core/event";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
@ -242,6 +244,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
};
export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
await sendEmail(() => new AccountVerifyEmail(verificationInput));
};
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }

View File

@ -0,0 +1,60 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type EmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
verificationEmailLink: string;
};
export const VerifyAccountEmail = (
props: EmailVerifyLink & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("verify_email_email_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_email_body", { appName: APP_NAME })}</>
</p>
<CallToAction label={props.language("verify_email_email_button")} href={props.verificationEmailLink} />
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_email_link_text")}</>
<br />
<a href={props.verificationEmailLink}>{props.verificationEmailLink}</a>
</p>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")} <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team")}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@ -23,6 +23,7 @@ export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export { VerifyAccountEmail } from "./VerifyAccountEmail";
export * from "@calcom/app-store/routing-forms/emails/components";
export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";

View File

@ -0,0 +1,49 @@
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type EmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
verificationEmailLink: string;
};
export default class AccountVerifyEmail extends BaseEmail {
verifyAccountInput: EmailVerifyLink;
constructor(passwordEvent: EmailVerifyLink) {
super();
this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
this.verifyAccountInput = passwordEvent;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
subject: this.verifyAccountInput.language("verify_email_subject", {
appName: APP_NAME,
}),
html: renderEmail("VerifyAccountEmail", this.verifyAccountInput),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })}
${this.verifyAccountInput.language("verify_email_email_header")}
${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })},
${this.verifyAccountInput.language("verify_email_email_body", { appName: APP_NAME })}
${this.verifyAccountInput.language("verify_email_email_link_text")}
${this.verifyAccountInput.verificationEmailLink}
${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language("the_calcom_team")}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@ -1,34 +1,41 @@
import type { EmbedThemeConfig } from "./embed-iframe";
export default function EmbedInitIframe() {
if (typeof window === "undefined" || window.isEmbed) {
return;
}
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
const embedNameSpaceFromQueryParam = new URL(document.URL).searchParams.get("embed");
/* Iframe Name */
window.name.includes("cal-embed");
// Namespace is initially set in query param `embed` but the query param might get lost during soft navigation
// So, we also check for the namespace in `window.name` which is set when iframe is created by embed.ts and persists for the duration of iframe's life
// Note that, window.name isn't lost during hard navigation as well. Though, hard navigation isn't something that would happen in the app, but it's critical to be able to detect embed mode even after that(just in case)
// We might just use window.name but if just in case something resets the `window.name`, we will still have the namespace in query param
// It must be null for non-embed scenario.
const embedNamespace =
typeof embedNameSpaceFromQueryParam === "string"
? embedNameSpaceFromQueryParam
: window.name.includes("cal-embed=")
? window.name.replace(/cal-embed=(.*)/, "$1").trim()
: null;
window.isEmbed = () => {
// Once an embed mode always an embed mode
return calEmbedMode;
};
window.resetEmbedStatus = () => {
try {
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
window.sessionStorage.removeItem("calEmbedMode");
} catch (e) {}
// By default namespace is "". That would also work if we just check the type of variable
return typeof embedNamespace == "string";
};
window.getEmbedTheme = () => {
// Note that embedStore.theme is lost if hard navigation occurs.(Though, it isn't something that we expect to happen normally)
if (window.CalEmbed.embedStore.theme) {
// It is important to ensure that the theme is consistent during browsing so that ThemeProvider doesn't get different themes to show and it avoids theme switching.
return window.CalEmbed.embedStore.theme;
}
const url = new URL(document.URL);
return url.searchParams.get("theme") as "light" | "dark" | null;
return url.searchParams.get("theme") as EmbedThemeConfig | null;
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
return embedNamespace;
};
window.CalEmbed = window.CalEmbed || {};

View File

@ -159,7 +159,7 @@
<div id="namespaces-test">
<div class="inline-embed-container" id="cal-booking-place-default">
<h3>
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)]</a>
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)](Default Namespace)</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>

View File

@ -1,6 +1,5 @@
import type { GlobalCal, GlobalCalWithoutNs } from "./src/embed";
import type { GlobalCal } from "./src/embed";
type A = GlobalCalWithoutNs;
const Cal = window.Cal as GlobalCal;
const callback = function (e) {
const detail = e.detail;
@ -8,7 +7,7 @@ const callback = function (e) {
};
const searchParams = new URL(document.URL).searchParams;
const only = window.only;
const only = searchParams.get("only");
if (only === "all" || only === "ns:default") {
Cal("init", {

View File

@ -34,7 +34,15 @@ export const getBooking = async (bookingId: string) => {
return booking;
};
export const getEmbedIframe = async ({ page, pathname }: { page: Page; pathname: string }) => {
export const getEmbedIframe = async ({
calNamespace,
page,
pathname,
}: {
calNamespace: string;
page: Page;
pathname: string;
}) => {
// We can't seem to access page.frame till contentWindow is available. So wait for that.
const iframeReady = await page.evaluate(() => {
return new Promise((resolve) => {
@ -66,7 +74,7 @@ export const getEmbedIframe = async ({ page, pathname }: { page: Page; pathname:
// We just verified that iframeReady is true here, so obviously embedIframe is not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const embedIframe = page.frame("cal-embed")!;
const embedIframe = page.frame(`cal-embed=${calNamespace}`)!;
const u = new URL(embedIframe.url());
if (u.pathname === pathname + "/embed") {
return embedIframe;

View File

@ -32,7 +32,7 @@ async function bookFirstFreeUserEventThroughEmbed({
await embedButtonLocator.click();
const embedIframe = await getEmbedIframe({ page, pathname: "/free" });
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/free",
@ -58,12 +58,12 @@ test.describe("Popup Tests", () => {
const calNamespace = "prerendertestLightTheme";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ page, pathname: "/free" });
let embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
expect(embedIframe).toBeFalsy();
await page.click('[data-cal-link="free?light&popup"]');
embedIframe = await getEmbedIframe({ page, pathname: "/free" });
embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/free",
@ -92,8 +92,8 @@ test.describe("Popup Tests", () => {
await addEmbedListeners("popupReschedule");
await page.goto(`/?popupRescheduleId=${booking.uid}`);
await page.click('[data-cal-namespace="popupReschedule"]');
const embedIframe = await getEmbedIframe({ page, pathname: booking.eventSlug });
const calNamespace = "popupReschedule";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: booking.eventSlug });
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
@ -117,12 +117,20 @@ test.describe("Popup Tests", () => {
const calNamespace = "routingFormAuto";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
let embedIframe = await getEmbedIframe({
calNamespace,
page,
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
expect(embedIframe).toBeFalsy();
await page.click(
`[data-cal-namespace=${calNamespace}][data-cal-link="forms/948ae412-d995-4865-875a-48302588de03"]`
);
embedIframe = await getEmbedIframe({ page, pathname: "/forms/948ae412-d995-4865-875a-48302588de03" });
embedIframe = await getEmbedIframe({
calNamespace,
page,
pathname: "/forms/948ae412-d995-4865-875a-48302588de03",
});
if (!embedIframe) {
throw new Error("Routing Form embed iframe not found");
}

View File

@ -12,8 +12,9 @@ test("Inline Iframe - Configured with Dark Theme", async ({
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");
await page.goto("/?only=ns:default");
const embedIframe = await getEmbedIframe({ page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink("", getActionFiredDetails, {
const calNamespace = "";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",

View File

@ -11,7 +11,8 @@ type Theme = "dark" | "light";
export type EmbedThemeConfig = Theme | "auto";
export type UiConfig = {
hideEventTypeDetails?: boolean;
theme?: EmbedThemeConfig;
// If theme not provided we would get null
theme?: EmbedThemeConfig | null;
styles?: EmbedStyles & EmbedNonStylesConfig;
//TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI.
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
@ -46,9 +47,8 @@ declare global {
};
CalComPageStatus: string;
isEmbed?: () => boolean;
resetEmbedStatus: () => void;
getEmbedNamespace: () => string | null;
getEmbedTheme: () => "dark" | "light" | null;
getEmbedTheme: () => EmbedThemeConfig | null;
}
}
@ -434,7 +434,7 @@ if (isBrowser) {
// Exposes certain global variables/fns that are used by the app to get interface with the embed.
embedInit();
const url = new URL(document.URL);
embedStore.theme = window?.getEmbedTheme?.() as UiConfig["theme"];
embedStore.theme = window?.getEmbedTheme?.();
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK

View File

@ -215,7 +215,7 @@ export class Cal {
}) {
const iframe = (this.iframe = document.createElement("iframe"));
iframe.className = "cal-embed";
iframe.name = "cal-embed";
iframe.name = `cal-embed=${this.namespace}`;
const config = this.getConfig();
const { iframeAttrs, ...restQueryObject } = queryObject;

View File

@ -12,7 +12,8 @@ test.describe("Inline Embed", () => {
//TODO: Do it with page.goto automatically
await addEmbedListeners("");
await page.goto("/");
const embedIframe = await getEmbedIframe({ page, pathname: "/pro" });
const calNamespace = "";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink("", getActionFiredDetails, {
pathname: "/pro",
searchParams: {

View File

@ -0,0 +1,66 @@
import { randomBytes } from "crypto";
import { sendEmailVerificationLink } from "@calcom/emails/email-manager";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import rateLimit from "@calcom/lib/rateLimit";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
const limiter = rateLimit({
intervalInMs: 60 * 1000, // 1 minute
});
interface VerifyEmailType {
username?: string;
email: string;
language?: string;
}
export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const flags = await getFeatureFlagMap(prisma);
if (!flags["email-verification"]) {
log.warn("Email verification is disabled - Skipping");
return { ok: true, skipped: true };
}
await prisma.verificationToken.create({
data: {
identifier: email,
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
},
});
const params = new URLSearchParams({
token,
});
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
if (isRateLimited) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "An unexpected error occurred, please try again later.",
cause: "Too many requests",
});
}
await sendEmailVerificationLink({
language: translation,
verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`,
user: {
email,
name: username,
},
});
return { ok: true, skipped: false };
};

View File

@ -3,14 +3,23 @@ import { useRouter } from "next/router";
import { useState } from "react";
import { z } from "zod";
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Badge, Button, showToast, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Plus, ArrowRight, Trash2 } from "@calcom/ui/components/icon";
import {
Avatar,
Badge,
Button,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { ArrowRight, Plus, Trash2 } from "@calcom/ui/components/icon";
const querySchema = z.object({
id: z.string().transform((val) => parseInt(val)),
@ -40,10 +49,16 @@ export const AddNewTeamMembersForm = ({
teamId: number;
}) => {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const showDialog = router.query.inviteModal === "true";
const [memberInviteModal, setMemberInviteModal] = useState(showDialog);
const utils = trpc.useContext();
const [inviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery({ teamId });
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
await utils.viewer.teams.get.invalidate();
@ -70,6 +85,7 @@ export const AddNewTeamMembersForm = ({
showToast(error.message, "error");
},
});
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
onSuccess(data) {
router.push(data.url);
@ -96,20 +112,44 @@ export const AddNewTeamMembersForm = ({
{t("add_team_member")}
</Button>
</div>
<MemberInvitationModal
isOpen={memberInviteModal}
onExit={() => setMemberInviteModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
members={defaultValues.members}
/>
{isLoading ? (
<SkeletonButton />
) : (
<>
<MemberInvitationModal
isOpen={memberInviteModal}
teamId={teamId}
token={team?.inviteToken?.token}
onExit={() => setMemberInviteModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setMemberInviteModal(false);
setInviteLinkSettingsModal(true);
}}
members={defaultValues.members}
/>
{team?.inviteToken && (
<InviteLinkSettingsModal
isOpen={inviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken?.token}
expiresInDays={team.inviteToken?.expiresInDays || undefined}
onExit={() => {
setInviteLinkSettingsModal(false);
setMemberInviteModal(true);
}}
/>
)}
</>
)}
<hr className="border-subtle my-6" />
<Button
EndIcon={ArrowRight}

View File

@ -0,0 +1,115 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, Dialog, DialogContent, DialogFooter, Form, Label, Select, showToast } from "@calcom/ui";
type InvitationLinkSettingsModalProps = {
isOpen: boolean;
teamId: number;
token: string;
expiresInDays?: number;
onExit: () => void;
};
export interface LinkSettingsForm {
expiresInDays: number | undefined;
}
export default function InviteLinkSettingsModal(props: InvitationLinkSettingsModalProps) {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const deleteInviteMutation = trpc.viewer.teams.deleteInvite.useMutation({
onSuccess: () => {
showToast(t("invite_link_deleted"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
props.onExit();
},
onError: (e) => {
showToast(e.message, "error");
},
});
const setInviteExpirationMutation = trpc.viewer.teams.setInviteExpiration.useMutation({
onSuccess: () => {
showToast(t("invite_link_updated"), "success");
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
},
onError: (e) => {
showToast(e.message, "error");
},
});
const expiresInDaysOption = useMemo(() => {
return [
{ value: 1, label: t("one_day") },
{ value: 7, label: t("seven_days") },
{ value: 30, label: t("thirty_days") },
{ value: undefined, label: t("never_expire") },
];
}, [t]);
const linkSettingsFormMethods = useForm<LinkSettingsForm>();
const handleSubmit = (values: LinkSettingsForm) => {
setInviteExpirationMutation.mutate({
token: props.token,
expiresInDays: values.expiresInDays,
});
};
return (
<Dialog
open={props.isOpen}
onOpenChange={() => {
props.onExit();
linkSettingsFormMethods.reset();
}}>
<DialogContent type="creation" title="Invite link settings">
<Form form={linkSettingsFormMethods} handleSubmit={handleSubmit}>
<Controller
name="expiresInDays"
control={linkSettingsFormMethods.control}
render={({ field: { onChange } }) => (
<div>
<Label className="text-emphasis font-medium" htmlFor="expiresInDays">
{t("link_expires_after")}
</Label>
<Select
options={expiresInDaysOption}
defaultValue={expiresInDaysOption.find((option) => option.value === props.expiresInDays)}
className="w-full"
onChange={(val) => onChange(val?.value)}
/>
</div>
)}
/>
<DialogFooter>
<Button
type="button"
color="secondary"
onClick={() => deleteInviteMutation.mutate({ token: props.token })}
className="mr-auto"
data-testid="copy-invite-link-button">
{t("delete")}
</Button>
<Button type="button" color="minimal" onClick={props.onExit}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="ms-2 me-2"
data-testid="invite-new-member-button">
{t("save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -3,9 +3,11 @@ import { Trans } from "next-i18next";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { classNames } from "@calcom/lib";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import {
Button,
Checkbox as CheckboxField,
@ -13,12 +15,14 @@ import {
DialogContent,
DialogFooter,
Form,
TextField,
Label,
showToast,
TextField,
ToggleGroup,
Select,
TextAreaField,
} from "@calcom/ui";
import { Link } from "@calcom/ui/components/icon";
import type { PendingMember } from "../lib/types";
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
@ -27,7 +31,10 @@ type MemberInvitationModalProps = {
isOpen: boolean;
onExit: () => void;
onSubmit: (values: NewMemberForm) => void;
onSettingsOpen: () => void;
teamId: number;
members: PendingMember[];
token?: string;
};
type MembershipRoleOption = {
@ -45,7 +52,27 @@ type ModalMode = "INDIVIDUAL" | "BULK";
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const [modalImportMode, setModalInputMode] = useState<ModalMode>("INDIVIDUAL");
const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({
onSuccess(token) {
copyInviteLinkToClipboard(token);
trpcContext.viewer.teams.get.invalidate();
trpcContext.viewer.teams.list.invalidate();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const copyInviteLinkToClipboard = async (token: string) => {
const inviteLink = `${WEBAPP_URL}/teams?token=${token}`;
await navigator.clipboard.writeText(inviteLink);
showToast(t("invite_link_copied"), "success");
};
const options: MembershipRoleOption[] = useMemo(() => {
return [
{ value: MembershipRole.MEMBER, label: t("member") },
@ -215,6 +242,35 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
/>
</div>
<DialogFooter showDivider>
<div className="mr-auto flex">
<Button
type="button"
color="minimal"
variant="icon"
onClick={() =>
props.token
? copyInviteLinkToClipboard(props.token)
: createInviteMutation.mutate({ teamId: props.teamId })
}
className={classNames("gap-2", props.token && "opacity-50")}
data-testid="copy-invite-link-button">
<Link className="text-default h-4 w-4" aria-hidden="true" />
{t("copy_invite_link")}
</Button>
{props.token && (
<Button
type="button"
color="minimal"
className="ms-2 me-2"
onClick={() => {
props.onSettingsOpen();
newMemberFormMethods.reset();
}}
data-testid="edit-invite-link-button">
{t("edit_invite_link")}
</Button>
)}
</div>
<Button
type="button"
color="minimal"

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSettingsModal";
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@ -26,16 +27,16 @@ import {
Tooltip,
} from "@calcom/ui";
import {
MoreHorizontal,
Check,
X,
Link as LinkIcon,
Edit2,
ExternalLink,
Trash,
LogOut,
Globe,
Link as LinkIcon,
LogOut,
MoreHorizontal,
Send,
Trash,
X,
} from "@calcom/ui/components/icon";
import { TeamRole } from "./TeamPill";
@ -51,11 +52,15 @@ interface Props {
export default function TeamListItem(props: Props) {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const team = props.team;
const router = useRouter();
const showDialog = router.query.inviteModal === "true";
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
const [openInviteLinkSettingsModal, setOpenInviteLinkSettingsModal] = useState(false);
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id });
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
@ -129,6 +134,8 @@ export default function TeamListItem(props: Props) {
<li className="">
<MemberInvitationModal
isOpen={openMemberInvitationModal}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => {
setOpenMemberInvitationModal(false);
}}
@ -141,8 +148,24 @@ export default function TeamListItem(props: Props) {
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setOpenMemberInvitationModal(false);
setOpenInviteLinkSettingsModal(true);
}}
members={teamQuery?.data?.members || []}
/>
{team.inviteToken && (
<InviteLinkSettingsModal
isOpen={openInviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken?.token}
expiresInDays={team.inviteToken?.expiresInDays || undefined}
onExit={() => {
setOpenInviteLinkSettingsModal(false);
setOpenMemberInvitationModal(true);
}}
/>
)}
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
{!isInvitee ? (
<Link

View File

@ -1,10 +1,11 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, ButtonGroup, Label } from "@calcom/ui";
import { Users, RefreshCcw, UserPlus, Mail, Video, EyeOff } from "@calcom/ui/components/icon";
import { Alert, Button, ButtonGroup, Label, showToast } from "@calcom/ui";
import { EyeOff, Mail, RefreshCcw, UserPlus, Users, Video } from "@calcom/ui/components/icon";
import { UpgradeTip } from "../../../tips";
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
@ -12,14 +13,32 @@ import TeamList from "./TeamList";
export function TeamsListing() {
const { t } = useLocale();
const trpcContext = trpc.useContext();
const router = useRouter();
const [inviteTokenChecked, setInviteTokenChecked] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const { data, isLoading } = trpc.viewer.teams.list.useQuery(undefined, {
enabled: inviteTokenChecked,
onError: (e) => {
setErrorMessage(e.message);
},
});
const { mutate: inviteMemberByToken } = trpc.viewer.teams.inviteMemberByToken.useMutation({
onSuccess: (teamName) => {
trpcContext.viewer.teams.list.invalidate();
showToast(t("team_invite_received", { teamName }), "success");
},
onError: (e) => {
showToast(e.message, "error");
},
onSettled: () => {
setInviteTokenChecked(true);
},
});
const teams = useMemo(() => data?.filter((m) => m.accepted) || [], [data]);
const invites = useMemo(() => data?.filter((m) => !m.accepted) || [], [data]);
@ -56,7 +75,13 @@ export function TeamsListing() {
},
];
if (isLoading) {
useEffect(() => {
if (!router) return;
if (router.query.token) inviteMemberByToken({ token: router.query.token as string });
else setInviteTokenChecked(true);
}, [router, inviteMemberByToken, setInviteTokenChecked]);
if (isLoading || !inviteTokenChecked) {
return <SkeletonLoaderTeamList />;
}

View File

@ -11,6 +11,7 @@ import { Plus } from "@calcom/ui/components/icon";
import { getLayout } from "../../../settings/layouts/SettingsLayout";
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import InviteLinkSettingsModal from "../components/InviteLinkSettingsModal";
import MemberInvitationModal from "../components/MemberInvitationModal";
import MemberListItem from "../components/MemberListItem";
import TeamInviteList from "../components/TeamInviteList";
@ -63,12 +64,16 @@ function MembersList(props: MembersListProps) {
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const session = useSession();
const utils = trpc.useContext();
const teamId = Number(router.query.id);
const showDialog = router.query.inviteModal === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const teamId = Number(router.query.id);
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
{ teamId },
@ -169,6 +174,8 @@ const MembersView = () => {
<MemberInvitationModal
isOpen={showMemberInvitationModal}
members={team.members}
teamId={team.id}
token={team.inviteToken?.token}
onExit={() => setShowMemberInvitationModal(false)}
onSubmit={(values) => {
inviteMemberMutation.mutate({
@ -179,6 +186,22 @@ const MembersView = () => {
sendEmailInvitation: values.sendInviteEmail,
});
}}
onSettingsOpen={() => {
setShowMemberInvitationModal(false);
setInviteLinkSettingsModal(true);
}}
/>
)}
{showInviteLinkSettingsModal && team?.inviteToken && (
<InviteLinkSettingsModal
isOpen={showInviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken.token}
expiresInDays={team.inviteToken.expiresInDays || undefined}
onExit={() => {
setInviteLinkSettingsModal(false);
setShowMemberInvitationModal(true);
}}
/>
)}
</>

View File

@ -10,6 +10,7 @@ export type AppFlags = {
workflows: boolean;
"managed-event-types": boolean;
organizations: boolean;
"email-verification": boolean;
"booker-layouts": boolean;
"google-workspace-directory": boolean;
"disable-signup": boolean;

View File

@ -44,7 +44,7 @@ export const BookerLayoutSelector = ({
return (
<>
<Label className="mb-0">{title ? title : t("bookerlayout_title")}</Label>
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">
<p className="text-subtle max-w-full break-words py-1 text-sm">
{description ? description : t("bookerlayout_description")}
</p>
<Controller

View File

@ -18,6 +18,7 @@ import { useFlagMap } from "@calcom/features/flags/context/provider";
import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar";
import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog";
import AdminPasswordBanner from "@calcom/features/users/components/AdminPasswordBanner";
import VerifyEmailBanner from "@calcom/features/users/components/VerifyEmailBanner";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import getBrandColours from "@calcom/lib/getBrandColours";
@ -26,6 +27,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { trpc } from "@calcom/trpc/react";
import useAvatarQuery from "@calcom/trpc/react/hooks/useAvatarQuery";
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import {
@ -123,17 +125,23 @@ function useRedirectToOnboardingIfNeeded() {
const router = useRouter();
const query = useMeQuery();
const user = query.data;
const flags = useFlagMap();
const { data: email } = useEmailVerifyCheck();
const needsEmailVerification = !email?.isVerified && flags["email-verification"];
const isRedirectingToOnboarding = user && shouldShowOnboarding(user);
useEffect(() => {
if (isRedirectingToOnboarding) {
if (isRedirectingToOnboarding && !needsEmailVerification) {
router.replace({
pathname: "/getting-started",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRedirectingToOnboarding]);
}, [isRedirectingToOnboarding, needsEmailVerification]);
return {
isRedirectingToOnboarding,
};
@ -182,6 +190,7 @@ const Layout = (props: LayoutProps) => {
<TeamsUpgradeBanner />
<ImpersonatingBanner />
<AdminPasswordBanner />
<VerifyEmailBanner />
</div>
<div className="flex flex-1" data-testid="dashboard-shell">
{props.SidebarContainer || <SideBarContainer bannersHeight={bannersHeight} />}

View File

@ -0,0 +1,36 @@
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
import { Button, TopBanner, showToast } from "@calcom/ui";
import { useFlagMap } from "../../flags/context/provider";
function VerifyEmailBanner() {
const flags = useFlagMap();
const { t } = useLocale();
const { data } = useEmailVerifyCheck();
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
if (data?.isVerified || !flags["email-verification"]) return null;
return (
<>
<TopBanner
text={t("verify_email_banner_body", { appName: APP_NAME })}
variant="warning"
actions={
<Button
onClick={() => {
mutation.mutate();
showToast(t("email_sent"), "success");
}}>
{t("verify_email_banner_button")}
</Button>
}
/>
</>
);
}
export default VerifyEmailBanner;

View File

@ -7,6 +7,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
@ -52,6 +53,13 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
...baseEventTypeSelect,
},
},
inviteToken: {
select: {
token: true,
expires: true,
expiresInDays: true,
},
},
});
const where: Prisma.TeamFindFirstArgs["where"] = {};
@ -82,6 +90,7 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
}));
return { ...team, eventTypes, members };
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
return (
@ -95,6 +104,7 @@ export async function isTeamAdmin(userId: number, teamId: number) {
})) || false
);
}
export async function isTeamOwner(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {

View File

@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[teamId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "expiresInDays" INTEGER,
ADD COLUMN "teamId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_teamId_key" ON "VerificationToken"("teamId");
-- AddForeignKey
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'email-verification',
true,
'Enable email verification for new users',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;

View File

@ -242,7 +242,7 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
/// @zod.min(1)
name String
/// @zod.min(1)
@ -251,22 +251,23 @@ model Team {
appLogo String?
appIconLogo String?
bio String?
hideBranding Boolean @default(false)
hideBookATeamMember Boolean @default(false)
hideBranding Boolean @default(false)
hideBookATeamMember Boolean @default(false)
members Membership[]
eventTypes EventType[]
workflows Workflow[]
createdAt DateTime @default(now())
createdAt DateTime @default(now())
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
theme String?
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
parentId Int?
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
children Team[] @relation("organization")
orgUsers User[] @relation("scope")
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
children Team[] @relation("organization")
orgUsers User[] @relation("scope")
inviteToken VerificationToken?
webhooks Webhook[]
@@unique([slug, parentId])
@ -293,12 +294,15 @@ model Membership {
}
model VerificationToken {
id Int @id @default(autoincrement())
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
identifier String
token String @unique
expires DateTime
expiresInDays Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teamId Int? @unique
team Team? @relation(fields: [teamId], references: [id])
@@unique([identifier, token])
@@index([token])

View File

@ -0,0 +1,13 @@
import { trpc } from "../trpc";
export function useEmailVerifyCheck() {
const emailCheck = trpc.viewer.shouldVerifyEmail.useQuery(undefined, {
retry(failureCount) {
return failureCount > 3;
},
});
return emailCheck;
}
export default useEmailVerifyCheck;

View File

@ -25,6 +25,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
timeZone: true,
weekStart: true,

View File

@ -18,6 +18,7 @@ import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaul
type AppsRouterHandlerCache = {
me?: typeof import("./me.handler").meHandler;
shouldVerifyEmail?: typeof import("./shouldVerifyEmail.handler").shouldVerifyEmailHandler;
avatar?: typeof import("./avatar.handler").avatarHandler;
deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler;
deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler;
@ -368,4 +369,18 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp({ ctx, input });
}),
shouldVerifyEmail: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
UNSTABLE_HANDLER_CACHE.shouldVerifyEmail = (
await import("./shouldVerifyEmail.handler")
).shouldVerifyEmailHandler;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.shouldVerifyEmail({ ctx });
}),
});

View File

@ -0,0 +1,21 @@
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type ShouldVerifyEmailType = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const shouldVerifyEmailHandler = async ({ ctx }: ShouldVerifyEmailType) => {
const { user } = ctx;
const isVerified = !!user.emailVerified;
const isCalProvider = user.identityProvider === "CAL"; // We dont need to verify on OAUTH providers as they are already verified by the provider
const obj = {
id: user.id,
email: user.email,
isVerified: isVerified || !isCalProvider,
};
return obj;
};

View File

@ -0,0 +1 @@
export {};

View File

@ -6,6 +6,7 @@ import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema";
type AuthRouterHandlerCache = {
changePassword?: typeof import("./changePassword.handler").changePasswordHandler;
verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler;
resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
};
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
@ -46,4 +47,19 @@ export const authRouter = router({
input,
});
}),
resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then(
(mod) => mod.resendVerifyEmail
);
}
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.resendVerifyEmail({
ctx,
});
}),
});

View File

@ -0,0 +1,30 @@
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import logger from "@calcom/lib/logger";
import type { TrpcSessionUser } from "../../../trpc";
type ResendEmailOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => {
if (ctx.user.emailVerified) {
log.info(`User ${ctx.user.id} already verified email`);
return {
ok: true,
skipped: true,
};
}
const email = await sendEmailVerification({
email: ctx.user.email,
username: ctx.user?.username ?? undefined,
language: ctx.user.locale,
});
return email;
};

View File

@ -3,14 +3,18 @@ import { router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateInviteInputSchema } from "./createInvite.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDeleteInviteInputSchema } from "./deleteInvite.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
import { ZInviteMemberInputSchema } from "./inviteMember.schema";
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZPublishInputSchema } from "./publish.schema";
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
@ -32,6 +36,10 @@ type TeamsRouterHandlerCache = {
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler;
listInvites?: typeof import("./listInvites.handler").listInvitesHandler;
createInvite?: typeof import("./createInvite.handler").createInviteHandler;
setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler;
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
};
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
@ -334,4 +342,76 @@ export const viewerTeamsRouter = router({
ctx,
});
}),
createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then(
(mod) => mod.createInviteHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createInvite({
ctx,
input,
});
}),
setInviteExpiration: authedProcedure
.input(ZSetInviteExpirationInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
UNSTABLE_HANDLER_CACHE.setInviteExpiration = await import("./setInviteExpiration.handler").then(
(mod) => mod.setInviteExpirationHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setInviteExpiration({
ctx,
input,
});
}),
deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
UNSTABLE_HANDLER_CACHE.deleteInvite = await import("./deleteInvite.handler").then(
(mod) => mod.deleteInviteHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.deleteInvite({
ctx,
input,
});
}),
inviteMemberByToken: authedProcedure
.input(ZInviteMemberByTokenSchemaInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
UNSTABLE_HANDLER_CACHE.inviteMemberByToken = await import("./inviteMemberByToken.handler").then(
(mod) => mod.inviteMemberByTokenHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.inviteMemberByToken({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,32 @@
import { randomBytes } from "crypto";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TCreateInviteInputSchema } from "./createInvite.schema";
type CreateInviteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateInviteInputSchema;
};
export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) => {
const { teamId } = input;
if (!(await isTeamAdmin(ctx.user.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
identifier: "",
token,
expires: new Date(),
teamId,
},
});
return token;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZCreateInviteInputSchema = z.object({
teamId: z.number(),
});
export type TCreateInviteInputSchema = z.infer<typeof ZCreateInviteInputSchema>;

View File

@ -0,0 +1,33 @@
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TDeleteInviteInputSchema } from "./deleteInvite.schema";
type DeleteInviteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteInviteInputSchema;
};
export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) => {
const { token } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token: token,
},
select: {
teamId: true,
id: true,
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZDeleteInviteInputSchema = z.object({
token: z.string(),
});
export type TDeleteInviteInputSchema = z.infer<typeof ZDeleteInviteInputSchema>;

View File

@ -19,8 +19,7 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
/** We only need to return teams that don't have a `subscriptionId` on their metadata */
teams = teams.filter((m) => {
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && metadata.data?.subscriptionId) return false;
return true;
return !(metadata.success && metadata.data?.subscriptionId);
});
return teams;
};

View File

@ -0,0 +1,66 @@
import { Prisma } from "@prisma/client";
import { updateQuantitySubscriptionFromStripe } from "@calcom/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
type InviteMemberByTokenOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TInviteMemberByTokenSchemaInputSchema;
};
export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByTokenOptions) => {
const { token } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
OR: [{ expiresInDays: null }, { expires: { gte: new Date() } }],
},
include: {
team: {
select: {
name: true,
},
},
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
if (!verificationToken.teamId || !verificationToken.team)
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite token is not associated with any team",
});
try {
await prisma.membership.create({
data: {
teamId: verificationToken.teamId,
userId: ctx.user.id,
role: MembershipRole.MEMBER,
accepted: false,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This user is a member of this team / has a pending invitation.",
});
}
} else throw e;
}
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(verificationToken.teamId);
return verificationToken.team.name;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZInviteMemberByTokenSchemaInputSchema = z.object({
token: z.string(),
});
export type TInviteMemberByTokenSchemaInputSchema = z.infer<typeof ZInviteMemberByTokenSchemaInputSchema>;

View File

@ -14,7 +14,11 @@ export const listHandler = async ({ ctx }: ListOptions) => {
userId: ctx.user.id,
},
include: {
team: true,
team: {
include: {
inviteToken: true,
},
},
},
orderBy: { role: "desc" },
});

View File

@ -43,7 +43,7 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
});
type UserMap = Record<number, (typeof teams)[number]["members"][number]["user"]>;
// flattern users to be unique by id
// flatten users to be unique by id
const users = teams
.flatMap((t) => t.members)
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);

View File

@ -0,0 +1,41 @@
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
type SetInviteExpirationOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TSetInviteExpirationInputSchema;
};
export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpirationOptions) => {
const { token, expiresInDays } = input;
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token: token,
},
select: {
teamId: true,
},
});
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const oneDay = 24 * 60 * 60 * 1000;
const expires = expiresInDays ? new Date(Date.now() + expiresInDays * oneDay) : new Date();
await prisma.verificationToken.update({
where: { token },
data: {
expires,
expiresInDays,
},
});
};

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZSetInviteExpirationInputSchema = z.object({
token: z.string(),
expiresInDays: z.number().optional(),
});
export type TSetInviteExpirationInputSchema = z.infer<typeof ZSetInviteExpirationInputSchema>;

View File

@ -16,6 +16,8 @@ export function EmptyScreen({
buttonOnClick,
buttonRaw,
border = true,
dashedBorder = true,
className,
}: {
Icon?: SVGComponent | IconType;
avatar?: React.ReactElement;
@ -25,14 +27,17 @@ export function EmptyScreen({
buttonOnClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
buttonRaw?: ReactNode; // Used incase you want to provide your own button.
border?: boolean;
}) {
dashedBorder?: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<>
<div
data-testid="empty-screen"
className={classNames(
"min-h-80 flex w-full flex-col items-center justify-center rounded-md p-7 lg:p-20",
border && "border-subtle border border-dashed"
border && "border-subtle border",
dashedBorder && "border-dashed",
className
)}>
{!avatar ? null : (
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full">{avatar}</div>