From dbfbfae4bb47f176bc1cc6da135229af8b415571 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Mon, 20 Jan 2025 15:57:42 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Updated=20auth=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/locales/en/app.json | 35 ++- .../web/src/app/[locale]/(admin)/app-card.tsx | 2 +- .../auth/login/components}/login-page.tsx | 50 ++-- .../[locale]/{ => (auth)}/auth/login/page.tsx | 2 +- .../[locale]/(auth)/components/auth-page.tsx | 29 +++ .../(auth)/components/full-page-card.tsx | 47 ++++ .../(auth)/components/version-badge.tsx | 5 + apps/web/src/app/[locale]/(auth)/layout.tsx | 62 ++++- .../src/app/[locale]/(auth)/login/actions.ts | 16 ++ .../(auth)/login/components/auth-errors.tsx | 21 ++ .../login/components/login-email-form.tsx | 119 +++++++++ .../login/components/login-with-oidc.tsx | 16 ++ .../(auth)/login/components/or-divider.tsx | 9 + .../(auth)/login/components/sso-provider.tsx | 71 +++++ .../src/app/[locale]/(auth)/login/loading.tsx | 2 +- .../app/[locale]/(auth)/login/login-form.tsx | 244 ------------------ .../src/app/[locale]/(auth)/login/page.tsx | 117 +++++++-- .../[locale]/(auth)/login/sso-providers.tsx | 53 ++++ .../login/verify/components/otp-form.tsx | 103 ++++++++ .../app/[locale]/(auth)/login/verify/page.tsx | 68 +++++ .../app/[locale]/(auth)/register/actions.ts | 7 + .../components/register-name-form.tsx | 125 +++++++++ .../(auth)/register/components/schema.ts | 8 + .../app/[locale]/(auth)/register/loading.tsx | 9 + .../src/app/[locale]/(auth)/register/page.tsx | 66 ++++- .../(auth)/register/register-page.tsx | 211 --------------- .../register/verify/components/otp-form.tsx | 121 +++++++++ .../[locale]/(auth)/register/verify/page.tsx | 74 ++++++ .../src/app/[locale]/quick-create/page.tsx | 38 +++ apps/web/src/app/api/trpc/[trpc]/route.ts | 1 - .../app/components/user-language-switcher.tsx | 22 ++ apps/web/src/app/providers.tsx | 2 +- apps/web/src/auth.ts | 2 + apps/web/src/components/auth/auth-forms.tsx | 92 ------- apps/web/src/components/auth/auth-layout.tsx | 22 -- apps/web/src/components/input-otp.tsx | 30 +++ apps/web/src/components/logo.tsx | 20 +- .../src/components/poll/language-selector.tsx | 7 +- apps/web/src/components/user-provider.tsx | 5 +- apps/web/src/components/user.tsx | 2 +- apps/web/src/components/version-display.tsx | 3 + .../quick-create/components/relative-date.tsx | 14 + .../src/features/quick-create/constants.ts | 2 + apps/web/src/features/quick-create/index.ts | 1 + .../quick-create/lib/get-guest-polls.ts | 31 +++ .../quick-create/quick-create-button.tsx | 21 ++ .../quick-create/quick-create-widget.tsx | 137 ++++++++++ apps/web/src/i18n/server.ts | 5 +- apps/web/src/middleware.ts | 28 +- apps/web/src/style.css | 9 +- apps/web/src/trpc/client/config.ts | 26 +- apps/web/src/trpc/context.ts | 1 - apps/web/src/trpc/routers/auth.ts | 17 +- apps/web/src/trpc/routers/dashboard.ts | 4 +- apps/web/src/trpc/routers/polls.ts | 5 +- apps/web/src/trpc/routers/scheduled-events.ts | 4 +- apps/web/src/trpc/routers/user.ts | 18 +- apps/web/src/trpc/trpc.ts | 12 +- .../src/utils/locale/get-locale-from-path.ts | 8 + apps/web/tests/authentication.spec.ts | 28 +- package.json | 2 +- packages/languages/index.ts | 2 + packages/ui/src/button.tsx | 17 +- packages/ui/src/dot-pattern.tsx | 56 ++++ packages/ui/src/form.tsx | 4 +- packages/ui/src/input.tsx | 4 +- packages/ui/src/label.tsx | 2 +- packages/utils/src/sleep.ts | 3 + scripts/inject-version.js | 8 + 69 files changed, 1698 insertions(+), 709 deletions(-) rename apps/web/src/app/[locale]/{auth/login => (auth)/auth/login/components}/login-page.tsx (59%) rename apps/web/src/app/[locale]/{ => (auth)}/auth/login/page.tsx (95%) create mode 100644 apps/web/src/app/[locale]/(auth)/components/auth-page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/components/full-page-card.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/components/version-badge.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/actions.ts create mode 100644 apps/web/src/app/[locale]/(auth)/login/components/auth-errors.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/components/login-email-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/components/login-with-oidc.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/components/or-divider.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/components/sso-provider.tsx delete mode 100644 apps/web/src/app/[locale]/(auth)/login/login-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/verify/components/otp-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/login/verify/page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/register/actions.ts create mode 100644 apps/web/src/app/[locale]/(auth)/register/components/register-name-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/register/components/schema.ts create mode 100644 apps/web/src/app/[locale]/(auth)/register/loading.tsx delete mode 100644 apps/web/src/app/[locale]/(auth)/register/register-page.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/register/verify/components/otp-form.tsx create mode 100644 apps/web/src/app/[locale]/(auth)/register/verify/page.tsx create mode 100644 apps/web/src/app/[locale]/quick-create/page.tsx create mode 100644 apps/web/src/app/components/user-language-switcher.tsx delete mode 100644 apps/web/src/components/auth/auth-forms.tsx delete mode 100644 apps/web/src/components/auth/auth-layout.tsx create mode 100644 apps/web/src/components/input-otp.tsx create mode 100644 apps/web/src/components/version-display.tsx create mode 100644 apps/web/src/features/quick-create/components/relative-date.tsx create mode 100644 apps/web/src/features/quick-create/constants.ts create mode 100644 apps/web/src/features/quick-create/index.ts create mode 100644 apps/web/src/features/quick-create/lib/get-guest-polls.ts create mode 100644 apps/web/src/features/quick-create/quick-create-button.tsx create mode 100644 apps/web/src/features/quick-create/quick-create-widget.tsx create mode 100644 apps/web/src/utils/locale/get-locale-from-path.ts create mode 100644 packages/ui/src/dot-pattern.tsx create mode 100644 packages/utils/src/sleep.ts create mode 100644 scripts/inject-version.js diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 7fb1794d5f6..8656b33e7ea 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -2,7 +2,6 @@ "12h": "12-hour", "24h": "24-hour", "addTimeOption": "Add time option", - "alreadyRegistered": "Already registered? Login", "applyToAllDates": "Apply to all dates", "areYouSure": "Are you sure?", "cancel": "Cancel", @@ -39,7 +38,6 @@ "location": "Location", "locationPlaceholder": "Joe's Coffee Shop", "login": "Login", - "loginWith": "Login with {provider}", "logout": "Logout", "manage": "Manage", "mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?", @@ -75,11 +73,9 @@ "titlePlaceholder": "Monthly Meetup", "today": "Today", "userAlreadyExists": "A user with that email already exists", - "userNotFound": "A user with that email doesn't exist", "validEmail": "Please enter a valid email", "verificationCodeHelp": "Didn't get the email? Check your spam/junk.", "verificationCodePlaceholder": "Enter your 6-digit code", - "verifyYourEmail": "Verify your email", "startOfWeek": "Start of week", "weekView": "Week view", "wrongVerificationCode": "Your verification code is incorrect or has expired", @@ -174,7 +170,6 @@ "duplicateTitleLabel": "Title", "duplicateTitleDescription": "Hint: Give your new poll a unique title", "upgrade": "Upgrade", - "continueAsGuest": "Continue as Guest", "scrollLeft": "Scroll Left", "scrollRight": "Scroll Right", "shrink": "Shrink", @@ -200,7 +195,6 @@ "hideScoresLabel": "Hide scores until after a participant has voted", "continueAs": "Continue as", "pageMovedDescription": "Redirecting to {newUrl}", - "notRegistered": "Don't have an account? Register", "unlockFeatures": "Unlock all Pro features.", "pollStatusFinalized": "Finalized", "share": "Share", @@ -213,7 +207,6 @@ "inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.", "inviteLink": "Invite Link", "inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll.", - "accountNotLinkedTitle": "Your account cannot be linked to an existing user", "accountNotLinkedDescription": "A user with this email already exists. Please log in using the original method.", "or": "Or", "autoTimeZone": "Automatic Time Zone Conversion", @@ -234,7 +227,6 @@ "dangerZoneAccount": "Delete your account permanently. This action cannot be undone.", "upgradePromptTitle": "Upgrade to Pro", "upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.", - "verificationCodeSentTo": "We sent a verification code to {email}", "home": "Home", "groupPoll": "Group Poll", "groupPollDescription": "Share your availability with a group of people and find the best time to meet.", @@ -289,5 +281,30 @@ "emailChangeRequestSentDescription": "To complete the change, please check your email for a verification link.", "profileEmailAddress": "Email Address", "profileEmailAddressDescription": "Your email address is used to log in to your account", - "emailAlreadyInUse": "This email address is already associated with another account. Please use a different email address." + "emailAlreadyInUse": "This email address is already associated with another account. Please use a different email address.", + "continueWith": "Continue with {provider}", + "continueWithProvider": "Continue with {{provider}}", + "loginFooter": "Don't have an account? Sign up", + "back": "Back", + "verifyEmail": "Verify your email", + "alreadyHaveAccount": "Already have an account? Log in", + "loginDescription": "Login to your account to continue", + "userNotFound": "A user with that email doesn't exist", + "loginTitle": "Welcome", + "registerTitle": "Create Your Account", + "registerDescription": "Streamline your scheduling process and save time", + "quickActionCreate": "Quick Create", + "quickActionsDescription": "Create a group poll without signing in. Login later to link it to your account.", + "quickCreateGroupPoll": "Create Group Poll", + "quickCreate": "Quick Create", + "quickCreateRecentlyCreated": "Recently Created", + "quickCreateWhyCreateAnAccount": "Why create an account?", + "quickCreateSecurePolls": "Store polls securely in your account", + "quickCreateGetNotifications": "Get email notifications notifications", + "quickCreateManagePollsFromAnyDevice": "Manage your polls from any device", + "registerVerifyTitle": "Finish Registering", + "registerVerifyDescription": "Check your email for the verification code", + "loginVerifyTitle": "Finish Logging In", + "loginVerifyDescription": "Check your email for the verification code", + "createAccount": "Create Account" } diff --git a/apps/web/src/app/[locale]/(admin)/app-card.tsx b/apps/web/src/app/[locale]/(admin)/app-card.tsx index cad1795c63b..ad754bc54f8 100644 --- a/apps/web/src/app/[locale]/(admin)/app-card.tsx +++ b/apps/web/src/app/[locale]/(admin)/app-card.tsx @@ -40,7 +40,7 @@ export function GroupPollIcon({ "size-6 rounded": size === "xs", "size-8 rounded-md": size === "sm", "size-9 rounded-md": size === "md", - "size-10 rounded-lg": size === "lg", + "size-10 rounded-md": size === "lg", }, )} > diff --git a/apps/web/src/app/[locale]/auth/login/login-page.tsx b/apps/web/src/app/[locale]/(auth)/auth/login/components/login-page.tsx similarity index 59% rename from apps/web/src/app/[locale]/auth/login/login-page.tsx rename to apps/web/src/app/[locale]/(auth)/auth/login/components/login-page.tsx index d0d8dc3004e..4facd10ec78 100644 --- a/apps/web/src/app/[locale]/auth/login/login-page.tsx +++ b/apps/web/src/app/[locale]/(auth)/auth/login/components/login-page.tsx @@ -5,7 +5,6 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; -import { Logo } from "@/components/logo"; import { OptimizedAvatarImage } from "@/components/optimized-avatar-image"; import { Skeleton } from "@/components/skeleton"; import { Trans } from "@/components/trans"; @@ -40,42 +39,37 @@ export const LoginPage = ({ magicLink, email }: PageProps) => { const { data } = trpc.user.getByEmail.useQuery({ email }); const router = useRouter(); return ( -
-
- -
- -
-
-
- -
-
- -
-
- {data?.name ?? } -
-
- {data?.email ?? ( - - )} -
+
+
+

+ +

+
+ +
+
+ {data?.name ?? } +
+
+ {data?.email ?? }
+
+
diff --git a/apps/web/src/app/[locale]/auth/login/page.tsx b/apps/web/src/app/[locale]/(auth)/auth/login/page.tsx similarity index 95% rename from apps/web/src/app/[locale]/auth/login/page.tsx rename to apps/web/src/app/[locale]/(auth)/auth/login/page.tsx index 6c68b3d629d..3260c67b193 100644 --- a/apps/web/src/app/[locale]/auth/login/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/auth/login/page.tsx @@ -3,7 +3,7 @@ import { z } from "zod"; import { getTranslation } from "@/i18n/server"; -import { LoginPage } from "./login-page"; +import { LoginPage } from "./components/login-page"; export const dynamic = "force-dynamic"; diff --git a/apps/web/src/app/[locale]/(auth)/components/auth-page.tsx b/apps/web/src/app/[locale]/(auth)/components/auth-page.tsx new file mode 100644 index 00000000000..4c6bc700d22 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/components/auth-page.tsx @@ -0,0 +1,29 @@ +export function AuthPageContainer({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AuthPageHeader({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AuthPageTitle({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export function AuthPageDescription({ + children, +}: { + children: React.ReactNode; +}) { + return

{children}

; +} + +export function AuthPageContent({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AuthPageExternal({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/components/full-page-card.tsx b/apps/web/src/app/[locale]/(auth)/components/full-page-card.tsx new file mode 100644 index 00000000000..227bcb15816 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/components/full-page-card.tsx @@ -0,0 +1,47 @@ +export function FullPageCardContainer({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} + +export function FullPageCardHeader({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function FullPageCardTitle({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export function FullPageCardDescription({ + children, +}: { + children: React.ReactNode; +}) { + return

{children}

; +} + +export function FullPageCardContent({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function FullPageCardFooter({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/apps/web/src/app/[locale]/(auth)/components/version-badge.tsx b/apps/web/src/app/[locale]/(auth)/components/version-badge.tsx new file mode 100644 index 00000000000..b74b900eb9b --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/components/version-badge.tsx @@ -0,0 +1,5 @@ +import { Badge } from "@rallly/ui/badge"; + +export function VersionBadge() { + return v{process.env.NEXT_PUBLIC_APP_VERSION}; +} diff --git a/apps/web/src/app/[locale]/(auth)/layout.tsx b/apps/web/src/app/[locale]/(auth)/layout.tsx index 9469b2c6ee6..c2f4d42f498 100644 --- a/apps/web/src/app/[locale]/(auth)/layout.tsx +++ b/apps/web/src/app/[locale]/(auth)/layout.tsx @@ -1,7 +1,63 @@ -export default function Layout({ children }: { children: React.ReactNode }) { +import { cn } from "@rallly/ui"; +import { DotPattern } from "@rallly/ui/dot-pattern"; +import type { Metadata } from "next"; +import { redirect, RedirectType } from "next/navigation"; + +import { getServerSession } from "@/auth"; +import { Logo } from "@/components/logo"; +import { isQuickCreateEnabled } from "@/features/quick-create"; +import { QuickStartButton } from "@/features/quick-create/quick-create-button"; +import { QuickStartWidget } from "@/features/quick-create/quick-create-widget"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(); + + if (session?.user.email) { + return redirect("/", RedirectType.replace); + } + return ( -
-
{children}
+
+
+
+
+ +
+
+
{children}
+
+ {isQuickCreateEnabled ? ( +
+ +
+ ) : null} +
+ {isQuickCreateEnabled ? ( +
+
+ +
+ +
+ ) : null} +
); } + +export const metadata: Metadata = { + title: { + template: "%s - Rallly", + default: "Rallly", + }, +}; diff --git a/apps/web/src/app/[locale]/(auth)/login/actions.ts b/apps/web/src/app/[locale]/(auth)/login/actions.ts new file mode 100644 index 00000000000..01603120069 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/actions.ts @@ -0,0 +1,16 @@ +"use server"; + +import { prisma } from "@rallly/database"; +import { cookies } from "next/headers"; + +export async function setVerificationEmail(email: string) { + const count = await prisma.user.count({ + where: { + email, + }, + }); + + cookies().set("verification-email", email); + + return count > 0; +} diff --git a/apps/web/src/app/[locale]/(auth)/login/components/auth-errors.tsx b/apps/web/src/app/[locale]/(auth)/login/components/auth-errors.tsx new file mode 100644 index 00000000000..1b060f89960 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/components/auth-errors.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useSearchParams } from "next/navigation"; +import { useTranslation } from "react-i18next"; + +export function AuthErrors() { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const error = searchParams?.get("error"); + if (error === "OAuthAccountNotLinked") { + return ( +

+ {t("accountNotLinkedDescription", { + defaultValue: + "A user with this email already exists. Please log in using the original method.", + })} +

+ ); + } + + return null; +} diff --git a/apps/web/src/app/[locale]/(auth)/login/components/login-email-form.tsx b/apps/web/src/app/[locale]/(auth)/login/components/login-email-form.tsx new file mode 100644 index 00000000000..b0e4e9de238 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/components/login-email-form.tsx @@ -0,0 +1,119 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@rallly/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@rallly/ui/form"; +import { Input } from "@rallly/ui/input"; +import { useRouter, useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; +import React from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; + +import { setVerificationEmail } from "@/app/[locale]/(auth)/login/actions"; +import { Trans } from "@/components/trans"; + +function useLoginWithEmailSchema() { + const { t } = useTranslation(); + return React.useMemo(() => { + return z.object({ + identifier: z.string().email(t("validEmail")), + }); + }, [t]); +} + +type LoginWithEmailValues = z.infer>; + +export function LoginWithEmailForm() { + const router = useRouter(); + const loginWithEmailSchema = useLoginWithEmailSchema(); + const searchParams = useSearchParams(); + const form = useForm({ + defaultValues: { + identifier: "", + }, + resolver: zodResolver(loginWithEmailSchema), + }); + const { handleSubmit, formState } = form; + const { t } = useTranslation(); + + return ( +
+ { + const doesExist = await setVerificationEmail(identifier); + if (doesExist) { + await signIn("email", { + email: identifier, + callbackUrl: searchParams?.get("callbackUrl") ?? undefined, + redirect: false, + }); + // redirect to verify page with callbackUrl + router.push( + `/login/verify?callbackUrl=${encodeURIComponent( + searchParams?.get("callbackUrl") ?? "", + )}`, + ); + } else { + form.setError("identifier", { + message: t("userNotFound", { + defaultValue: "A user with that email doesn't exist", + }), + }); + } + })} + > + ( + + {t("email")} + + + + + + )} + /> +
+ +
+ + + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/components/login-with-oidc.tsx b/apps/web/src/app/[locale]/(auth)/login/components/login-with-oidc.tsx new file mode 100644 index 00000000000..c0a77129584 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/components/login-with-oidc.tsx @@ -0,0 +1,16 @@ +"use client"; +import { Button } from "@rallly/ui/button"; +import { signIn } from "next-auth/react"; + +export function LoginWithOIDC({ children }: { children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/components/or-divider.tsx b/apps/web/src/app/[locale]/(auth)/login/components/or-divider.tsx new file mode 100644 index 00000000000..de6e920b516 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/components/or-divider.tsx @@ -0,0 +1,9 @@ +export function OrDivider({ text }: { text: string }) { + return ( +
+
+
{text}
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/components/sso-provider.tsx b/apps/web/src/app/[locale]/(auth)/login/components/sso-provider.tsx new file mode 100644 index 00000000000..875ef26d133 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/components/sso-provider.tsx @@ -0,0 +1,71 @@ +"use client"; +import { Button } from "@rallly/ui/button"; +import { Icon } from "@rallly/ui/icon"; +import { UserIcon } from "lucide-react"; +import Image from "next/image"; +import { signIn } from "next-auth/react"; + +import { Trans } from "@/components/trans"; +import { useTranslation } from "@/i18n/client"; + +function SSOImage({ provider }: { provider: string }) { + if (provider === "google") { + return ( + Google + ); + } + + if (provider === "azure-ad") { + return ( + Microsoft + ); + } + + if (provider === "oidc") { + return ( + + + + ); + } + + return null; +} + +export function SSOProvider({ + providerId, + name, +}: { + providerId: string; + name: string; +}) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/loading.tsx b/apps/web/src/app/[locale]/(auth)/login/loading.tsx index c83e66c9cab..79098356f5d 100644 --- a/apps/web/src/app/[locale]/(auth)/login/loading.tsx +++ b/apps/web/src/app/[locale]/(auth)/login/loading.tsx @@ -2,7 +2,7 @@ import { Spinner } from "@/components/spinner"; export default function Loading() { return ( -
+
); diff --git a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx deleted file mode 100644 index b8785c50b86..00000000000 --- a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx +++ /dev/null @@ -1,244 +0,0 @@ -"use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { usePostHog } from "@rallly/posthog/client"; -import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert"; -import { Button } from "@rallly/ui/button"; -import { Input } from "@rallly/ui/input"; -import { useQuery } from "@tanstack/react-query"; -import { AlertTriangleIcon, UserIcon } from "lucide-react"; -import Image from "next/image"; -import { useRouter, useSearchParams } from "next/navigation"; -import { getProviders, signIn, useSession } from "next-auth/react"; -import React from "react"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { z } from "zod"; - -import { trpc } from "@/app/providers"; -import { VerifyCode, verifyCode } from "@/components/auth/auth-forms"; -import { Spinner } from "@/components/spinner"; -import { isSelfHosted } from "@/utils/constants"; - -const allowGuestAccess = !isSelfHosted; - -const loginFormSchema = z.object({ - email: z.string().email().max(255), -}); - -type LoginFormData = z.infer; - -export function LoginForm() { - const { t } = useTranslation(); - const searchParams = useSearchParams(); - - const { register, handleSubmit, getValues, formState, setError } = useForm({ - defaultValues: { email: "" }, - resolver: zodResolver(loginFormSchema), - }); - - const { data: providers } = useQuery(["providers"], getProviders, { - cacheTime: Infinity, - staleTime: Infinity, - }); - - const session = useSession(); - const queryClient = trpc.useUtils(); - const [email, setEmail] = React.useState(); - const router = useRouter(); - const callbackUrl = searchParams?.get("callbackUrl") ?? "/"; - - const error = searchParams?.get("error"); - - const posthog = usePostHog(); - - const alternativeLoginMethods = React.useMemo(() => { - const res: Array<{ login: () => void; icon: JSX.Element; name: string }> = - []; - if (providers?.oidc) { - res.push({ - login: () => { - signIn("oidc", { - callbackUrl, - }); - }, - icon: , - name: t("loginWith", { provider: providers.oidc.name }), - }); - } - - if (providers?.google) { - res.push({ - login: () => { - signIn("google", { - callbackUrl, - }); - }, - icon: ( - Google - ), - name: t("loginWith", { provider: providers.google.name }), - }); - } - - if (providers?.["azure-ad"]) { - res.push({ - login: () => { - signIn("azure-ad", { - callbackUrl, - }); - }, - icon: ( - Azure AD - ), - name: t("loginWith", { provider: "Microsoft" }), - }); - } - - if (allowGuestAccess) { - res.push({ - login: () => { - router.push(callbackUrl); - posthog?.capture("click continue as guest"); - }, - icon: , - name: t("continueAsGuest"), - }); - } - return res; - }, [callbackUrl, posthog, providers, router, t]); - - if (!providers) { - return ( -
- -
- ); - } - - const sendVerificationEmail = (email: string) => { - return signIn("email", { - redirect: false, - email, - callbackUrl, - }); - }; - - if (email) { - return ( - { - const success = await verifyCode({ - email, - token: code, - }); - - if (!success) { - throw new Error("Failed to authenticate user"); - } - - await queryClient.invalidate(); - await session.update(); - - router.push(callbackUrl); - }} - email={getValues("email")} - /> - ); - } - - return ( -
{ - const res = await sendVerificationEmail(email); - - if (res?.error) { - setError("email", { - message: t("userNotFound"), - }); - } else { - setEmail(email); - } - })} - > -
{t("login")}
-

- {t("stepSummary", { - current: 1, - total: 2, - })} -

-
- - - {formState.errors.email?.message ? ( -
- {formState.errors.email.message} -
- ) : null} -
-
- - {error === "OAuthAccountNotLinked" ? ( - - - {t("accountNotLinkedTitle", { - defaultValue: - "Your account cannot be linked to an existing user", - })} - - - {t("accountNotLinkedDescription", { - defaultValue: - "A user with this email already exists. Please log in using the original method.", - })} - - - ) : null} - {alternativeLoginMethods.length > 0 ? ( - <> -
-
- - {t("or", { defaultValue: "Or" })} - -
-
- {alternativeLoginMethods.map((method, i) => ( - - ))} -
- - ) : null} -
-
- ); -} diff --git a/apps/web/src/app/[locale]/(auth)/login/page.tsx b/apps/web/src/app/[locale]/(auth)/login/page.tsx index fba4788a5da..1ab1fe1c21e 100644 --- a/apps/web/src/app/[locale]/(auth)/login/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/login/page.tsx @@ -1,33 +1,118 @@ +import { unstable_cache } from "next/cache"; import Link from "next/link"; +import { getProviders } from "next-auth/react"; import { Trans } from "react-i18next/TransWithoutContext"; -import { LoginForm } from "@/app/[locale]/(auth)/login/login-form"; -import type { Params } from "@/app/[locale]/types"; -import { AuthCard } from "@/components/auth/auth-layout"; import { getTranslation } from "@/i18n/server"; -export default async function LoginPage({ params }: { params: Params }) { - const { t } = await getTranslation(params.locale); +import { + AuthPageContainer, + AuthPageContent, + AuthPageDescription, + AuthPageExternal, + AuthPageHeader, + AuthPageTitle, +} from "../components/auth-page"; +import { AuthErrors } from "./components/auth-errors"; +import { LoginWithEmailForm } from "./components/login-email-form"; +import { LoginWithOIDC } from "./components/login-with-oidc"; +import { OrDivider } from "./components/or-divider"; +import { SSOProviders } from "./sso-providers"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +async function getOAuthProviders() { + const providers = await getProviders(); + if (!providers) { + return []; + } + + return Object.values(providers) + .filter((provider) => provider.type === "oauth") + .map((provider) => ({ + id: provider.id, + name: provider.name, + })); +} + +// Cache the OAuth providers to avoid re-fetching them on every page load +const getCachedOAuthProviders = unstable_cache( + getOAuthProviders, + ["oauth-providers"], + { + revalidate: false, + }, +); + +export default async function LoginPage() { + const { t } = await getTranslation(); + const oAuthProviders = await getCachedOAuthProviders(); + const socialProviders = oAuthProviders.filter( + (provider) => provider.id !== "oidc", + ); + + const oidcProvider = oAuthProviders.find( + (provider) => provider.id === "oidc", + ); + return ( -
- - - -
+ + + + + + + + + + + + {oidcProvider ? ( +
+ + + +
+ ) : null} + {socialProviders.length > 0 ? ( + <> + + + + ) : null} +
+ + , + a: , }} /> -
-
+ + ); } -export async function generateMetadata({ params }: { params: Params }) { +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { const { t } = await getTranslation(params.locale); return { title: t("login"), diff --git a/apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx b/apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx new file mode 100644 index 00000000000..ec60bde16b2 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx @@ -0,0 +1,53 @@ +import { unstable_cache } from "next/cache"; +import { getProviders } from "next-auth/react"; + +import { SSOProvider } from "./components/sso-provider"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +async function getOAuthProviders() { + const providers = await getProviders(); + if (!providers) { + return []; + } + + return Object.values(providers) + .filter((provider) => provider.type === "oauth") + .map((provider) => ({ + id: provider.id, + name: provider.name, + })); +} + +// Cache the OAuth providers to avoid re-fetching them on every page load +const getCachedOAuthProviders = unstable_cache( + getOAuthProviders, + ["oauth-providers"], + { + revalidate: false, + }, +); + +export async function SSOProviders() { + const oAuthProviders = await getCachedOAuthProviders(); + const socialProviders = oAuthProviders.filter( + (provider) => provider.id !== "oidc", + ); + + if (socialProviders.length === 0) { + return null; + } + + return ( +
+ {socialProviders.map((provider) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/verify/components/otp-form.tsx b/apps/web/src/app/[locale]/(auth)/login/verify/components/otp-form.tsx new file mode 100644 index 00000000000..d0b432bd23a --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/verify/components/otp-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@rallly/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormMessage, +} from "@rallly/ui/form"; +import { useSearchParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Trans } from "@/components/trans"; +import { useTranslation } from "@/i18n/client"; + +import { InputOTP } from "../../../../../../components/input-otp"; + +const otpFormSchema = z.object({ + otp: z.string().length(6), +}); + +type OTPFormValues = z.infer; + +export function OTPForm({ email }: { email: string }) { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: { + otp: "", + }, + resolver: zodResolver(otpFormSchema), + }); + + const searchParams = useSearchParams(); + const handleSubmit = form.handleSubmit(async (data) => { + const url = `${ + window.location.origin + }/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${data.otp}`; + + const res = await fetch(url); + const resUrl = new URL(res.url); + + const hasError = !!resUrl.searchParams.get("error"); + + if (hasError) { + form.setError("otp", { + message: t("wrongVerificationCode"), + }); + } else { + window.location.href = searchParams?.get("callbackUrl") ?? "/"; + } + }); + + return ( +
+ + { + return ( + + + { + handleSubmit(); + }} + {...field} + /> + + + + + + + ); + }} + /> + + + + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/login/verify/page.tsx b/apps/web/src/app/[locale]/(auth)/login/verify/page.tsx new file mode 100644 index 00000000000..e9a9b63f9e1 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/login/verify/page.tsx @@ -0,0 +1,68 @@ +import { Button } from "@rallly/ui/button"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Trans } from "react-i18next/TransWithoutContext"; + +import { getTranslation } from "@/i18n/server"; + +import { + AuthPageContainer, + AuthPageContent, + AuthPageDescription, + AuthPageHeader, + AuthPageTitle, +} from "../../components/auth-page"; +import { OTPForm } from "./components/otp-form"; + +export default async function VerifyPage() { + const { t } = await getTranslation(); + const email = cookies().get("verification-email")?.value; + if (!email) { + return redirect("/login"); + } + return ( + + + + + + + + + + + + + + + ); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(params.locale); + return { + title: t("verifyEmail", { + ns: "app", + defaultValue: "Verify your email", + }), + }; +} diff --git a/apps/web/src/app/[locale]/(auth)/register/actions.ts b/apps/web/src/app/[locale]/(auth)/register/actions.ts new file mode 100644 index 00000000000..997e02cf36a --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { cookies } from "next/headers"; + +export async function setToken(token: string) { + cookies().set("registration-token", token); +} diff --git a/apps/web/src/app/[locale]/(auth)/register/components/register-name-form.tsx b/apps/web/src/app/[locale]/(auth)/register/components/register-name-form.tsx new file mode 100644 index 00000000000..8ffbe61ca42 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/components/register-name-form.tsx @@ -0,0 +1,125 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@rallly/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@rallly/ui/form"; +import { Input } from "@rallly/ui/input"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import type { z } from "zod"; + +import { Trans } from "@/components/trans"; +import { useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; + +import { setToken } from "../actions"; +import { registerNameFormSchema } from "./schema"; + +type RegisterNameFormValues = z.infer; + +export function RegisterNameForm() { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: { + name: "", + email: "", + }, + resolver: zodResolver(registerNameFormSchema), + }); + + const registerUser = trpc.auth.requestRegistration.useMutation(); + const router = useRouter(); + return ( +
+ { + const res = await registerUser.mutateAsync(data); + + if (res.ok) { + await setToken(res.token); + router.push("/register/verify"); + } else { + switch (res.reason) { + case "emailNotAllowed": + form.setError("email", { + message: t("emailNotAllowed"), + }); + break; + case "userAlreadyExists": + form.setError("email", { + message: t("userAlreadyExists"), + }); + break; + } + } + })} + > +
+ { + return ( + + + + + + + + + + ); + }} + /> + ( + + + + + + + + + + )} + /> +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/register/components/schema.ts b/apps/web/src/app/[locale]/(auth)/register/components/schema.ts new file mode 100644 index 00000000000..223d393ecf5 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/components/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const registerNameFormSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); + +export type RegisterNameFormValues = z.infer; diff --git a/apps/web/src/app/[locale]/(auth)/register/loading.tsx b/apps/web/src/app/[locale]/(auth)/register/loading.tsx new file mode 100644 index 00000000000..79098356f5d --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "@/components/spinner"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/[locale]/(auth)/register/page.tsx b/apps/web/src/app/[locale]/(auth)/register/page.tsx index 6219e6cb902..ca6f9a35e5c 100644 --- a/apps/web/src/app/[locale]/(auth)/register/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/register/page.tsx @@ -1,12 +1,68 @@ -import { RegisterForm } from "@/app/[locale]/(auth)/register/register-page"; -import type { Params } from "@/app/[locale]/types"; +import Link from "next/link"; +import { Trans } from "react-i18next/TransWithoutContext"; + import { getTranslation } from "@/i18n/server"; -export default async function Page() { - return ; +import { + AuthPageContainer, + AuthPageContent, + AuthPageDescription, + AuthPageExternal, + AuthPageHeader, + AuthPageTitle, +} from "../components/auth-page"; +import { RegisterNameForm } from "./components/register-name-form"; + +export default async function Register({ + params, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(params.locale); + + return ( + + + + + + + + + + + + + + , + }} + /> + + + ); } -export async function generateMetadata({ params }: { params: Params }) { +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { const { t } = await getTranslation(params.locale); return { title: t("register"), diff --git a/apps/web/src/app/[locale]/(auth)/register/register-page.tsx b/apps/web/src/app/[locale]/(auth)/register/register-page.tsx deleted file mode 100644 index 48846b8083a..00000000000 --- a/apps/web/src/app/[locale]/(auth)/register/register-page.tsx +++ /dev/null @@ -1,211 +0,0 @@ -"use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { usePostHog } from "@rallly/posthog/client"; -import { Button } from "@rallly/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@rallly/ui/form"; -import { Input } from "@rallly/ui/input"; -import { TRPCClientError } from "@trpc/client"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import { signIn } from "next-auth/react"; -import { useTranslation } from "next-i18next"; -import React from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { VerifyCode } from "@/components/auth/auth-forms"; -import { AuthCard } from "@/components/auth/auth-layout"; -import { Trans } from "@/components/trans"; -import { trpc } from "@/trpc/client"; -import { useDayjs } from "@/utils/dayjs"; - -const registerFormSchema = z.object({ - name: z.string().trim().min(1).max(100), - email: z.string().email().max(255), -}); - -type RegisterFormData = z.infer; - -export const RegisterForm = () => { - const { t } = useTranslation(); - const { timeZone } = useDayjs(); - const params = useParams<{ locale: string }>(); - const searchParams = useSearchParams(); - const form = useForm({ - defaultValues: { email: "", name: "" }, - resolver: zodResolver(registerFormSchema), - }); - - const { handleSubmit, control, getValues, setError, formState } = form; - const requestRegistration = trpc.auth.requestRegistration.useMutation(); - const authenticateRegistration = - trpc.auth.authenticateRegistration.useMutation(); - const [token, setToken] = React.useState(); - const posthog = usePostHog(); - if (token) { - return ( - - { - // get user's time zone - const locale = params?.locale ?? "en"; - const res = await authenticateRegistration.mutateAsync({ - token, - timeZone, - locale, - code, - }); - - if (!res.user) { - throw new Error("Failed to authenticate user"); - } - - - posthog?.identify(res.user.id, { - email: res.user.email, - name: res.user.name, - }); - - signIn("registration-token", { - token, - callbackUrl: searchParams?.get("callbackUrl") ?? undefined, - }); - }} - email={getValues("email")} - /> - - ); - } - - return ( -
- -
- { - try { - await requestRegistration.mutateAsync( - { - email: data.email, - name: data.name, - }, - { - onSuccess: (res) => { - if (!res.ok) { - switch (res.reason) { - case "userAlreadyExists": - setError("email", { - message: t("userAlreadyExists"), - }); - break; - case "emailNotAllowed": - setError("email", { - message: t("emailNotAllowed"), - }); - break; - } - } else { - setToken(res.token); - } - }, - }, - ); - } catch (error) { - if (error instanceof TRPCClientError) { - setError("root", { - message: error.shape.message, - }); - } - } - })} - > -
- {t("createAnAccount")} -
-

- {t("stepSummary", { - current: 1, - total: 2, - })} -

-
- ( - - {t("name")} - - - - - - )} - /> - ( - - {t("email")} - - - - - - )} - /> -
-
- -
- {formState.errors.root ? ( - - {formState.errors.root.message} - - ) : null} -
- -
- {!form.formState.isSubmitSuccessful ? ( -
- , - }} - /> -
- ) : null} -
- ); -}; diff --git a/apps/web/src/app/[locale]/(auth)/register/verify/components/otp-form.tsx b/apps/web/src/app/[locale]/(auth)/register/verify/components/otp-form.tsx new file mode 100644 index 00000000000..3ea7a2786c8 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/verify/components/otp-form.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { usePostHog } from "@rallly/posthog/client"; +import { Button } from "@rallly/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormMessage, +} from "@rallly/ui/form"; +import { useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { InputOTP } from "@/components/input-otp"; +import { Trans } from "@/components/trans"; +import { useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; +import { useDayjs } from "@/utils/dayjs"; + +const otpFormSchema = z.object({ + otp: z.string().length(6), +}); + +type OTPFormValues = z.infer; + +export function OTPForm({ token }: { token: string }) { + const { t, i18n } = useTranslation(); + const form = useForm({ + defaultValues: { + otp: "", + }, + resolver: zodResolver(otpFormSchema), + }); + + const { timeZone } = useDayjs(); + + const locale = i18n.language; + + const queryClient = trpc.useUtils(); + const posthog = usePostHog(); + const authenticateRegistration = + trpc.auth.authenticateRegistration.useMutation(); + const searchParams = useSearchParams(); + const handleSubmit = form.handleSubmit(async (data) => { + // get user's time zone + const res = await authenticateRegistration.mutateAsync({ + token, + timeZone, + locale, + code: data.otp, + }); + + if (!res.user) { + throw new Error("Failed to authenticate user"); + } + + queryClient.invalidate(); + + posthog?.identify(res.user.id, { + email: res.user.email, + name: res.user.name, + }); + + signIn("registration-token", { + token, + callbackUrl: searchParams?.get("callbackUrl") ?? undefined, + }); + }); + + return ( +
+ + { + return ( + + + { + handleSubmit(); + }} + {...field} + /> + + + + + + + ); + }} + /> + + + + ); +} diff --git a/apps/web/src/app/[locale]/(auth)/register/verify/page.tsx b/apps/web/src/app/[locale]/(auth)/register/verify/page.tsx new file mode 100644 index 00000000000..4c7f98ed928 --- /dev/null +++ b/apps/web/src/app/[locale]/(auth)/register/verify/page.tsx @@ -0,0 +1,74 @@ +import { Button } from "@rallly/ui/button"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Trans } from "react-i18next/TransWithoutContext"; + +import { getTranslation } from "@/i18n/server"; + +import { + AuthPageContainer, + AuthPageContent, + AuthPageDescription, + AuthPageHeader, + AuthPageTitle, +} from "../../components/auth-page"; +import { OTPForm } from "./components/otp-form"; + +export default async function VerifyPage({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(locale); + const token = cookies().get("registration-token")?.value; + + if (!token) { + redirect("/register"); + } + + return ( + + + + + + + + + + + + + + + ); +} + +export async function generateMetadata({ + params, +}: { + params: { locale: string }; +}) { + const { t } = await getTranslation(params.locale); + return { + title: t("verifyEmail", { + ns: "app", + defaultValue: "Verify your email", + }), + }; +} diff --git a/apps/web/src/app/[locale]/quick-create/page.tsx b/apps/web/src/app/[locale]/quick-create/page.tsx new file mode 100644 index 00000000000..4fd43a31037 --- /dev/null +++ b/apps/web/src/app/[locale]/quick-create/page.tsx @@ -0,0 +1,38 @@ +import { Button } from "@rallly/ui/button"; +import { Icon } from "@rallly/ui/icon"; +import { LogInIcon } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { QuickStartWidget } from "@/features/quick-create/quick-create-widget"; +import { isSelfHosted } from "@/utils/constants"; + +export default async function QuickCreatePage() { + if (isSelfHosted) { + // self hosted users should not see this page + notFound(); + } + return ( +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 349e57f4add..8d3dcec4e5b 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -29,7 +29,6 @@ const handler = (request: Request) => { isGuest: session.user.email === null, locale: session.user.locale ?? undefined, image: session.user.image ?? undefined, - email: session.user.email ?? undefined, getEmailClient: () => getEmailClient(session.user?.locale ?? undefined), }, diff --git a/apps/web/src/app/components/user-language-switcher.tsx b/apps/web/src/app/components/user-language-switcher.tsx new file mode 100644 index 00000000000..2aa3070ebb6 --- /dev/null +++ b/apps/web/src/app/components/user-language-switcher.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { LanguageSelect } from "@/components/poll/language-selector"; +import { usePreferences } from "@/contexts/preferences"; +import { useTranslation } from "@/i18n/client"; + +export function UserLanguageSwitcher() { + const { i18n } = useTranslation(); + const { preferences, updatePreferences } = usePreferences(); + const router = useRouter(); + return ( + { + await updatePreferences({ locale: language }); + router.refresh(); + }} + /> + ); +} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 8f99b054c46..d578c6c4fc5 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -15,7 +15,7 @@ import { ConnectedDayjsProvider } from "@/utils/dayjs"; import { PostHogPageView } from "./posthog-page-view"; export const trpc = createTRPCReact({ - unstable_overrides: { + overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index 29cadd27607..cecc86ec41d 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -159,6 +159,7 @@ if ( ) { providers.push( AzureADProvider({ + name: "Microsoft", tenantId: process.env.MICROSOFT_TENANT_ID, clientId: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET, @@ -185,6 +186,7 @@ const getAuthOptions = (...args: GetServerSessionParams) => providers: providers, pages: { signIn: "/login", + verifyRequest: "/login/verify", error: "/auth/error", }, events: { diff --git a/apps/web/src/components/auth/auth-forms.tsx b/apps/web/src/components/auth/auth-forms.tsx deleted file mode 100644 index 87a4130b444..00000000000 --- a/apps/web/src/components/auth/auth-forms.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Button } from "@rallly/ui/button"; -import { Input } from "@rallly/ui/input"; -import { Trans, useTranslation } from "next-i18next"; -import React from "react"; -import { useForm } from "react-hook-form"; - -import { requiredString } from "../../utils/form-validation"; - -export const verifyCode = async (options: { email: string; token: string }) => { - const url = `${window.location.origin - }/api/auth/callback/email?email=${encodeURIComponent(options.email)}&token=${options.token - }`; - - const res = await fetch(url); - - return !res.url.includes("auth/error"); -}; - -export const VerifyCode: React.FunctionComponent<{ - email: string; - onSubmit: (code: string) => Promise; -}> = ({ onSubmit, email }) => { - const { register, handleSubmit, setError, formState } = useForm<{ - code: string; - }>(); - const { t } = useTranslation(); - - return ( -
-
{ - try { - await onSubmit(code); - } catch { - setError("code", { - type: "not_found", - message: t("wrongVerificationCode"), - }); - } - })} - > -
-

{t("verifyYourEmail")}

-
- {t("stepSummary", { - current: 2, - total: 2, - })} -
-

- , - }} - /> -

- - {formState.errors.code?.message ? ( -

- {formState.errors.code.message} -

- ) : null} -

- {t("verificationCodeHelp")} -

-
-
- -
-
-
- ); -}; diff --git a/apps/web/src/components/auth/auth-layout.tsx b/apps/web/src/components/auth/auth-layout.tsx deleted file mode 100644 index 6922ab45edd..00000000000 --- a/apps/web/src/components/auth/auth-layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; - -import { Logo } from "@/components/logo"; - -export const AuthCard = ({ children }: { children?: React.ReactNode }) => { - return ( -
-
- -
-
{children}
-
- ); -}; - -export const AuthFooter = ({ children }: { children?: React.ReactNode }) => { - return ( -
- {children} -
- ); -}; diff --git a/apps/web/src/components/input-otp.tsx b/apps/web/src/components/input-otp.tsx new file mode 100644 index 00000000000..e701c26c089 --- /dev/null +++ b/apps/web/src/components/input-otp.tsx @@ -0,0 +1,30 @@ +import { Input } from "@rallly/ui/input"; +import React from "react"; + +const InputOTP = React.forwardRef< + HTMLInputElement, + React.ComponentProps & { onValidCode?: (code: string) => void } +>(({ onValidCode, onChange, ...rest }, ref) => { + return ( + { + onChange?.(e); + + if (e.target.value.length === 6) { + onValidCode?.(e.target.value); + } + }} + maxLength={6} + data-1p-ignore + inputMode="numeric" + autoComplete="one-time-code" + pattern="\d{6}" + /> + ); +}); + +InputOTP.displayName = "InputOTP"; + +export { InputOTP }; diff --git a/apps/web/src/components/logo.tsx b/apps/web/src/components/logo.tsx index 71e69904572..dcd0435d948 100644 --- a/apps/web/src/components/logo.tsx +++ b/apps/web/src/components/logo.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; const sizes = { sm: { - width: 120, + width: 140, height: 22, }, md: { @@ -11,14 +11,24 @@ const sizes = { }, }; -export const Logo = ({ size = "md" }: { size?: keyof typeof sizes }) => { +export const Logo = ({ + className, + size = "md", +}: { + className?: string; + size?: keyof typeof sizes; +}) => { return ( Rallly ); diff --git a/apps/web/src/components/poll/language-selector.tsx b/apps/web/src/components/poll/language-selector.tsx index 508e46f1d05..24d6a7c37d8 100644 --- a/apps/web/src/components/poll/language-selector.tsx +++ b/apps/web/src/components/poll/language-selector.tsx @@ -1,5 +1,6 @@ import languages from "@rallly/languages"; import { Button } from "@rallly/ui/button"; +import { Icon } from "@rallly/ui/icon"; import { Select, SelectContent, @@ -7,6 +8,7 @@ import { SelectTrigger, SelectValue, } from "@rallly/ui/select"; +import { GlobeIcon } from "lucide-react"; export const LanguageSelect: React.FunctionComponent<{ className?: string; @@ -16,7 +18,10 @@ export const LanguageSelect: React.FunctionComponent<{ return (