Compare commits

...
13 Commits
Author SHA1 Message Date
Alex van Andel 5bd0428d18 v3.8.0
release.yml / release (push) Canceled after 0s
2024-02-15 15:54:47 +00:00
c7089b9db6 feat: store event-type filters to localStorage (#13407)
* Updated useTypedQuery to store values to localstorage

* removed the redundant function params

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-02-15 10:03:42 -05:00
Morgan 81ad00a816 fix: safely delete userPassword (#13705)
* fix: safely delete userPassword

* fixup! fix: safely delete userPassword

* fixup! Merge branch 'main' into fix-safely-delete-userpassword
2024-02-15 13:19:31 +00:00
Udit TakkarandMorgan b5730da56d fix: password reset when provider is changed (#13706)
* fix: password reset when provider is changed

* chore: remove log

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-02-15 13:19:11 +00:00
Zeeshan BhatiandJoe Au-Yeung 9455d14ad5 feat: Add Google Profile Photo when connecting Google Calendar (#13627)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-15 11:51:24 +00:00
LAVIN RAJ MOHANandUdit Takkar a056160404 fix(fix/null-safety):#13673 (#13674)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-15 07:17:19 +00:00
Omar López 6d53cd84f8 v3.7.19
release.yml / release (push) Canceled after 0s
2024-02-14 14:19:56 -07:00
Omar López 30cb8f745d revert: Revert UserPassword (#13680) (#13685)
* Revert "revert: UserPassword (#13680)"

This reverts commit 0a68c6d048.

* fix: avoid updates for possible missing passwords
2024-02-14 16:53:27 +00:00
bafa3c97ac fix: Organizations Admin improvements (#13656)
* Admin improvements

* chore: improve spacing

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-14 10:34:45 -03:00
Alex van Andel bb48ccbad9 v3.7.18
release.yml / release (push) Canceled after 0s
2024-02-14 12:42:33 +00:00
Alex van Andel 0a68c6d048 revert: UserPassword (#13680) 2024-02-14 12:36:29 +00:00
Peer RichelsenandHariom bb9f8e5d38 fix: dont link to org url inside embeds (#13668)
* dont link to org url inside embeds

* Add comment

* Fix type issue

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-02-14 11:31:24 +05:30
Carina WollendorferandCarinaWolli c99bb190f1 fix: route always selects 'custom' after saving an event redirect (#13665)
* fix to not always switch to custom on fresh load

* type fix

* type fix

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-13 22:08:03 +00:00
20 changed files with 168 additions and 41 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.7.17",
"version": "3.8.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+6 -2
View File
@@ -53,11 +53,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const hashedPassword = await hashPassword(newPassword);
await prisma.userPassword.update({
await prisma.userPassword.upsert({
where: {
userId: user.id,
},
data: {
create: {
hash: hashedPassword,
userId: user.id,
},
update: {
hash: hashedPassword,
},
});
+3 -2
View File
@@ -42,8 +42,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
data: {
password: {
update: {
hash: hashedPassword,
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(),
@@ -47,7 +47,7 @@ export default function Apps({ apps }: PageProps) {
<div className="mb-16">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps
.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
.map((app) => {
return <AppCard key={app.slug} app={app} />;
})}
@@ -2195,7 +2195,7 @@
"create_your_org_description": "Upgrade to Enterprise and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
"other_payment_app_enabled": "You can only enable one payment app per event type",
"admin_delete_organization_description": "<ul><li>Teams that are member of this organization will also be deleted along with their event-types</li><li>Users that were part of the organization will not be deleted and their event-types will also remain intact.</li><li>Usernames would be changed to allow them to exist outside the organization</li></ul>",
"admin_delete_organization_title": "Delete organization?",
"admin_delete_organization_title": "Delete {{organizationName}}?",
"published": "Published",
"unpublished": "Unpublished",
"publish": "Publish",
@@ -11,6 +11,7 @@ import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
export const scopes = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/userinfo.profile",
];
let client_id = "";
@@ -1,9 +1,11 @@
import type { Auth } from "googleapis";
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -91,6 +93,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
throw new HttpError({ message: "Internal Error", statusCode: 500 });
}
// Update the user's profile photo with google profile photo if it's null
// Since we don't want to block the user from using the app if this fails, we don't await this
updateProfilePhoto(oAuth2Client, req.session.user.id);
const credential = await prisma.credential.create({
data: {
type: "google_calendar",
@@ -156,6 +162,23 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
);
}
async function updateProfilePhoto(oAuth2Client: Auth.OAuth2Client, userId: number) {
try {
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await prisma.user.update({
where: { id: userId, avatarUrl: null, avatar: null },
data: {
avatarUrl: userDetails.data.picture,
},
});
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import React, { useCallback, useState } from "react";
import React, { useCallback, useState, useEffect } from "react";
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import type { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
@@ -98,7 +98,7 @@ const Route = ({
const index = routes.indexOf(route);
const { data: eventTypesByGroup } = trpc.viewer.eventTypes.getByViewer.useQuery({
const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({
forRoutingForms: true,
});
@@ -138,9 +138,15 @@ const Route = ({
? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1)
: "";
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>(
!isRouter(route) ? route.action.value.split("/").pop() ?? "" : ""
);
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>("");
useEffect(() => {
if (!isLoading) {
const isCustom =
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "");
}
}, [isLoading]);
const onChange = (route: Route, immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
@@ -1,4 +1,5 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { AuthOptions, Session } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
@@ -806,18 +807,32 @@ export const AUTH_OPTIONS: AuthOptions = {
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
) {
await prisma.user.update({
const updatedUser = await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
password: {
delete: true,
},
email: user.email,
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
// safely delete password from UserPassword table if it exists
try {
await prisma.userPassword.delete({
where: { userId: updatedUser.id },
});
} catch (err) {
if (
err instanceof PrismaClientKnownRequestError &&
(err.code === "P2025" || err.code === "P2016")
) {
log.warn("UserPassword not found for user", safeStringify(existingUserWithEmail));
} else {
log.warn("Could not delete UserPassword for user", safeStringify(existingUserWithEmail));
}
}
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
@@ -73,7 +73,7 @@ function AdminOrgTable() {
});
};
const [orgToDelete, setOrgToDelete] = useState<number | null>(null);
const [orgToDelete, setOrgToDelete] = useState<(typeof data)[number] | null>(null);
return (
<div>
<Table>
@@ -189,7 +189,7 @@ function AdminOrgTable() {
id: "delete",
label: t("delete"),
onClick: () => {
setOrgToDelete(org.id);
setOrgToDelete(org);
},
icon: Trash,
},
@@ -202,12 +202,12 @@ function AdminOrgTable() {
</Body>
</Table>
<DeleteOrgDialog
orgId={orgToDelete}
org={orgToDelete}
onClose={() => setOrgToDelete(null)}
onConfirm={() => {
if (!orgToDelete) return;
deleteMutation.mutate({
orgId: orgToDelete,
orgId: orgToDelete.id,
});
}}
/>
@@ -232,28 +232,35 @@ AdminOrgList.getLayout = getLayout;
export default AdminOrgList;
const DeleteOrgDialog = ({
orgId,
org,
onConfirm,
onClose,
}: {
orgId: number | null;
org: {
id: number;
name: string;
} | null;
onConfirm: () => void;
onClose: () => void;
}) => {
const { t } = useLocale();
if (!org) {
return null;
}
return (
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
<Dialog name="delete-user" open={!!orgId} onOpenChange={(open) => (open ? () => {} : onClose())}>
<Dialog name="delete-user" open={!!org.id} onOpenChange={(open) => (open ? () => {} : onClose())}>
<ConfirmationDialogContent
title={t("admin_delete_organization_title")}
title={t("admin_delete_organization_title", {
organizationName: org.name,
})}
confirmBtnText={t("delete")}
cancelBtnText={t("cancel")}
variety="danger"
onConfirm={onConfirm}>
<Trans
i18nKey="admin_delete_organization_description"
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
components={{ li: <li />, ul: <ul className="ml-4 mt-5 list-disc space-y-2" /> }}>
<ul>
<li>
Teams that are member of this organization will also be deleted along with their event-types
@@ -17,9 +17,10 @@ import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
export type IEventTypeFilter = IEventTypesFilters[0];
export const TEAMS_FILTER_KEY = "TEAMS_FILTER_KEY";
function useFilterQuery() {
// passthrough allows additional params to not be removed
return useTypedQuery(filterQuerySchema.passthrough());
return useTypedQuery(filterQuerySchema.passthrough(), TEAMS_FILTER_KEY);
}
export const TeamsFilter = ({
@@ -80,9 +80,13 @@ export const deleteDnsRecord = async (domain: string) => {
assertCloudflareEnvVars(process.env);
const dnsRecordToDelete = await getDnsRecordToDelete();
await deleteDnsRecord(dnsRecordToDelete);
if (dnsRecordToDelete) {
await deleteDnsRecord(dnsRecordToDelete);
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
} else {
log.info(`CNAME not found in Cloudflare: ${domain}. Nothing to delete`);
}
log.info(`Deleted dns-record in Cloudflare: ${domain}`);
return true;
async function getDnsRecordToDelete() {
@@ -134,7 +138,7 @@ export const deleteDnsRecord = async (domain: string) => {
});
}
return searchResult.result[0];
return searchResult.result[0] as (typeof searchResult.result)[0] | null;
}
async function deleteDnsRecord(dnsRecordToDelete: { id: string }) {
+8 -1
View File
@@ -1,4 +1,5 @@
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import { deleteDnsRecord, addDnsRecord } from "./deploymentServices/cloudflare";
import {
@@ -6,6 +7,7 @@ import {
createDomain as createVercelDomain,
} from "./deploymentServices/vercel";
const log = logger.getSubLogger({ prefix: ["domainManager/organization"] });
export const deleteDomain = async (slug: string) => {
const domain = `${slug}.${subdomainSuffix()}`;
// We must have some domain deleted
@@ -22,6 +24,7 @@ export const deleteDomain = async (slug: string) => {
isDnsRecordDeleted = await deleteDnsRecord(domain);
}
return isDomainDeleted && isDnsRecordDeleted;
return false;
};
export const createDomain = async (slug: string) => {
@@ -48,6 +51,10 @@ export const renameDomain = async (oldSlug: string | null, newSlug: string) => {
// First create new domain so that if it fails we still have the old domain
await createDomain(newSlug);
if (oldSlug) {
await deleteDomain(oldSlug);
try {
await deleteDomain(oldSlug);
} catch (e) {
log.error(`renameDomain: Failed to delete old domain ${oldSlug}. Do a manual deletion if needed`);
}
}
};
+44 -2
View File
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useCallback, useMemo, useEffect } from "react";
import { z } from "zod";
import { localStorage } from "../webstorage";
import { useRouterQuery } from "./useRouterQuery";
type OptionalKeys<T> = {
@@ -32,7 +33,7 @@ export const queryStringArray = z
.preprocess((a) => z.string().parse(a).split(","), z.string().array())
.or(z.string().array());
export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
export function useTypedQuery<T extends z.AnyZodObject>(schema: T, localStorageKey: string | null = null) {
type Output = z.infer<typeof schema>;
type FullOutput = Required<Output>;
type OutputKeys = Required<keyof FullOutput>;
@@ -43,7 +44,7 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
const unparsedQuery = useRouterQuery();
const pathname = usePathname();
const parsedQuerySchema = schema.safeParse(unparsedQuery);
const useLocalStorage = !!localStorageKey;
let parsedQuery: Output = useMemo(() => {
return {} as Output;
}, []);
@@ -73,6 +74,39 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
// eslint-disable-next-line react-hooks/exhaustive-deps
[parsedQuery, router]
);
//If the value for {localStorageKey} exist in localStorage set it to queryParams
useEffect(() => {
if (!useLocalStorage) return;
const storedValueString = localStorage.getItem(localStorageKey);
if (!storedValueString || storedValueString == "{}") return;
const storedValue: Output = JSON.parse(storedValueString);
if (storedValue) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(storedValue)) {
searchParams.set(String(key), String(value));
}
router.replace(`${pathname}?${searchParams.toString()}`);
}
}, []);
function setLocalStorage<J extends OutputKeys>(key: J, value: Output[J]) {
if (!useLocalStorage) return;
const storedValue: Output = JSON.parse(localStorage.getItem(localStorageKey) || "{}");
const newValue = { ...storedValue, [key]: value };
localStorage.setItem(localStorageKey, JSON.stringify(newValue));
}
function removeByKeyFromLocalStorage(key: OutputOptionalKeys) {
if (!useLocalStorage) return;
const storedValue: Output = JSON.parse(localStorage.getItem(localStorageKey) || "{}");
const newValue = storedValue;
delete newValue[key];
localStorage.setItem(localStorageKey, JSON.stringify(newValue));
}
//Remove {localStorageKey} from the localStorage
function removeAllValuesFromLocalStorage() {
if (!useLocalStorage) return;
localStorage.removeItem(localStorageKey);
}
// Delete a key from the query
function removeByKey(key: OutputOptionalKeys) {
@@ -88,9 +122,13 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
if (existingValue.includes(value)) return; // prevent adding the same value to the array
// @ts-expect-error this is too much for TS it seems
setQuery(key, [...existingValue, value]);
// @ts-expect-error this is too much for TS it seems
setLocalStorage(key, [...existingValue, value]);
} else {
// @ts-expect-error this is too much for TS it seems
setQuery(key, [value]);
// @ts-expect-error this is too much for TS it seems
setLocalStorage(key, [value]);
}
}
@@ -101,9 +139,12 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
// @ts-expect-error this is too much for TS it seems
const newValue = existingValue.filter((item) => item !== value);
setQuery(key, newValue);
setLocalStorage(key, newValue);
} else {
// @ts-expect-error this is too much for TS it seems
removeByKey(key);
//@ts-expect-error this is too much for TS it seems
removeByKeyFromLocalStorage(key);
}
}
@@ -111,6 +152,7 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
function removeAllQueryParams() {
if (pathname !== null) {
router.replace(pathname);
removeAllValuesFromLocalStorage();
}
}
@@ -49,11 +49,15 @@ export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOption
}
const hashedPassword = await hashPassword(newPassword);
await prisma.userPassword.update({
await prisma.userPassword.upsert({
where: {
userId: user.id,
},
data: {
create: {
hash: hashedPassword,
userId: user.id,
},
update: {
hash: hashedPassword,
},
});
@@ -40,7 +40,11 @@ export const adminDeleteHandler = async ({ input }: AdminDeleteOption) => {
});
if (foundOrg.slug) {
await deleteDomain(foundOrg.slug);
try {
await deleteDomain(foundOrg.slug);
} catch (e) {
log.error(`Failed to delete domain ${foundOrg.slug}. Do a manual deletion if needed`);
}
}
await renameUsersToAvoidUsernameConflicts(foundOrg.members.map((member) => member.user));
@@ -45,11 +45,15 @@ export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
});
const hashedPassword = await hashPassword(newPassword);
await prisma.userPassword.update({
await prisma.userPassword.upsert({
where: {
userId: ctx.user.id,
},
data: {
create: {
hash: hashedPassword,
userId: ctx.user.id,
},
update: {
hash: hashedPassword,
},
});
+1 -1
View File
@@ -15,7 +15,7 @@ export type AvatarProps = {
imageSrc?: Maybe<string>;
title?: string;
alt: string;
href?: string;
href?: string | null;
fallback?: React.ReactNode;
accepted?: boolean;
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
@@ -8,7 +8,7 @@ export type AvatarGroupProps = {
image: string;
title?: string;
alt?: string;
href?: string;
href?: string | null;
}[];
className?: string;
truncateAfter?: number;
@@ -1,3 +1,4 @@
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -15,9 +16,12 @@ type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> &
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
const { users, organization, ...rest } = props;
const isEmbed = useIsEmbed();
const items = [
{
href: getBookerBaseUrlSync(organization.slug),
// We don't want booker to be able to see the list of other users or teams inside the embed
href: isEmbed ? null : getBookerBaseUrlSync(organization.slug),
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
alt: organization.name || undefined,
title: organization.name,