Fix/login username registration (#2241)
* username update from getting-started when received as query param * Added test for onboarding username update * Now saving username saved in localStorage * remove username field * Removed wordlist * Implement checkoutUsername as api endpoint * Remove unused lib utils not empty Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
parent
1a77e4046e
commit
3341074bb2
4 changed files with 99 additions and 19 deletions
13
apps/web/pages/api/username.ts
Normal file
13
apps/web/pages/api/username.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||||
|
|
||||||
|
type Response = {
|
||||||
|
available: boolean;
|
||||||
|
premium: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||||
|
const result = await checkPremiumUsername(req.body.username);
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { IdentityProvider, Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
@ -19,11 +19,11 @@ import * as z from "zod";
|
||||||
|
|
||||||
import getApps from "@calcom/app-store/utils";
|
import getApps from "@calcom/app-store/utils";
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
|
import { ResponseUsernameApi } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
@ -152,15 +152,8 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
/** Onboarding Steps */
|
/** Onboarding Steps */
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const detectStep = () => {
|
const detectStep = () => {
|
||||||
|
// Always set timezone if new user
|
||||||
let step = 0;
|
let step = 0;
|
||||||
const hasSetUserNameOrTimeZone =
|
|
||||||
props.user?.name &&
|
|
||||||
props.user?.timeZone &&
|
|
||||||
!props.usernameParam &&
|
|
||||||
props.user?.identityProvider === IdentityProvider.CAL;
|
|
||||||
if (hasSetUserNameOrTimeZone) {
|
|
||||||
step = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null);
|
const hasConfigureCalendar = props.integrations.some((integration) => integration.credential !== null);
|
||||||
if (hasConfigureCalendar) {
|
if (hasConfigureCalendar) {
|
||||||
|
@ -259,6 +252,44 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
token: string;
|
token: string;
|
||||||
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
|
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
|
||||||
|
|
||||||
|
const fetchUsername = async (username: string) => {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/username`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username: username.trim() }),
|
||||||
|
method: "POST",
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
const data = (await response.json()) as ResponseUsernameApi;
|
||||||
|
return { response, data };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should update username on user when being redirected from sign up and doing google/saml
|
||||||
|
useEffect(() => {
|
||||||
|
async function validateAndSave(username) {
|
||||||
|
const { data } = await fetchUsername(username);
|
||||||
|
|
||||||
|
// Only persist username if its available and not premium
|
||||||
|
// premium usernames are saved via stripe webhook
|
||||||
|
if (data.available && !data.premium) {
|
||||||
|
await updateUser({
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove it from localStorage
|
||||||
|
window.localStorage.removeItem("username");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looking for username on localStorage
|
||||||
|
const username = window.localStorage.getItem("username");
|
||||||
|
if (username) {
|
||||||
|
validateAndSave(username);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
|
const availabilityForm = useForm({ defaultValues: { schedule: DEFAULT_SCHEDULE } });
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
|
@ -398,11 +429,12 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
mutation.mutate({
|
const userUpdateData = {
|
||||||
username: usernameRef.current?.value,
|
|
||||||
name: nameRef.current?.value,
|
name: nameRef.current?.value,
|
||||||
timeZone: selectedTimeZone,
|
timeZone: selectedTimeZone,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
mutation.mutate(userUpdateData);
|
||||||
|
|
||||||
if (mutationComplete) {
|
if (mutationComplete) {
|
||||||
await mutationAsync;
|
await mutationAsync;
|
||||||
|
@ -588,7 +620,8 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
className="justify-center"
|
className="justify-center"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={debouncedHandleConfirmStep}
|
onClick={debouncedHandleConfirmStep}
|
||||||
EndIcon={ArrowRightIcon}>
|
EndIcon={ArrowRightIcon}
|
||||||
|
data-testid={`continue-button-${currentStep}`}>
|
||||||
{steps[currentStep].confirmText}
|
{steps[currentStep].confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
|
@ -619,8 +652,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: NextPageContext) {
|
export async function getServerSideProps(context: NextPageContext) {
|
||||||
const usernameParam = asStringOrNull(context.query.username);
|
|
||||||
|
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
@ -720,7 +751,6 @@ export async function getServerSideProps(context: NextPageContext) {
|
||||||
connectedCalendars,
|
connectedCalendars,
|
||||||
eventTypes,
|
eventTypes,
|
||||||
schedules,
|
schedules,
|
||||||
usernameParam,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,24 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
test.describe("Onboarding", () => {
|
test.describe("Onboarding", () => {
|
||||||
test.use({ storageState: "playwright/artifacts/onboardingStorageState.json" });
|
test.use({ storageState: "playwright/artifacts/onboardingStorageState.json" });
|
||||||
|
|
||||||
|
// You want to always reset account completedOnboarding after each test
|
||||||
|
test.afterEach(async () => {
|
||||||
|
// Revert DB change
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
email: "onboarding@example.com",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
username: "onboarding",
|
||||||
|
completedOnboarding: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("redirects to /getting-started after login", async ({ page }) => {
|
test("redirects to /getting-started after login", async ({ page }) => {
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
await page.waitForNavigation({
|
await page.waitForNavigation({
|
||||||
|
@ -11,4 +27,23 @@ test.describe("Onboarding", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Onboarding", () => {
|
||||||
|
test("update onboarding username via localstorage", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("username", "alwaysavailable");
|
||||||
|
}, {});
|
||||||
|
// Try to go getting started with a available username
|
||||||
|
await page.goto("/getting-started");
|
||||||
|
// Wait for useEffectUpdate to run
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.findUnique({
|
||||||
|
where: { email: "onboarding@example.com" },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedUser?.username).toBe("alwaysavailable");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
|
|
||||||
export async function checkPremiumUsername(_username: string): Promise<{
|
export type ResponseUsernameApi = {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
}> {
|
};
|
||||||
|
|
||||||
|
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
|
||||||
const username = slugify(_username);
|
const username = slugify(_username);
|
||||||
const response = await fetch("https://cal.com/api/username", {
|
const response = await fetch("https://cal.com/api/username", {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
Loading…
Reference in a new issue