diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json
index 7fb1794d5f6..19f91107957 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": "Secure access through your account",
+ "quickCreateGetNotifications": "Get email 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 ?? }
+
+
{
await magicLinkFetch.mutateAsync();
}}
variant="primary"
- className="mt-6 w-full"
+ className="w-full"
>
-
+
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)/layout.tsx b/apps/web/src/app/[locale]/(auth)/layout.tsx
index 9469b2c6ee6..594e85f5d18 100644
--- a/apps/web/src/app/[locale]/(auth)/layout.tsx
+++ b/apps/web/src/app/[locale]/(auth)/layout.tsx
@@ -1,7 +1,55 @@
-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 { 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;
+}) {
return (
-
-
{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..b7b7281d254
--- /dev/null
+++ b/apps/web/src/app/[locale]/(auth)/login/actions.ts
@@ -0,0 +1,21 @@
+"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, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 15 * 60,
+ });
+
+ 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 (
+
+
+ );
+}
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 (
+ {
+ signIn("oidc");
+ }}
+ variant="link"
+ >
+ {children}
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+ }
+
+ if (provider === "azure-ad") {
+ return (
+
+ );
+ }
+
+ if (provider === "oidc") {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
+
+export function SSOProvider({
+ providerId,
+ name,
+}: {
+ providerId: string;
+ name: string;
+}) {
+ const { t } = useTranslation();
+ return (
+ {
+ signIn(providerId);
+ }}
+ >
+
+
+
+
+
+ );
+}
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: (
-
- ),
- name: t("loginWith", { provider: providers.google.name }),
- });
- }
-
- if (providers?.["azure-ad"]) {
- res.push({
- login: () => {
- signIn("azure-ad", {
- callbackUrl,
- });
- },
- icon: (
-
- ),
- 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 (
-
- );
-}
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 (
+
+
+ );
+}
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..7de1e61ccdb
--- /dev/null
+++ b/apps/web/src/app/[locale]/(auth)/register/actions.ts
@@ -0,0 +1,12 @@
+"use server";
+
+import { cookies } from "next/headers";
+
+export async function setToken(token: string) {
+ cookies().set("registration-token", token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 15 * 60,
+ });
+}
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 (
+
+
+ );
+}
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 (
-
-
-
-
-
- {!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..a82371f1e23
--- /dev/null
+++ b/apps/web/src/app/[locale]/(auth)/register/verify/components/otp-form.tsx
@@ -0,0 +1,123 @@
+"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") ?? "/",
+ });
+ });
+
+ const isLoading =
+ form.formState.isSubmitting ||
+ form.formState.isSubmitSuccessful ||
+ authenticateRegistration.isLoading;
+
+ return (
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+ Login
+
+
+
+
+
+ );
+}
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 (
-
- );
-};
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 (
-
- );
-};
-
-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 (
);
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 (
-
+
+
+
+
diff --git a/apps/web/src/components/user-provider.tsx b/apps/web/src/components/user-provider.tsx
index 4f2b73f4f08..ff8b3b88d46 100644
--- a/apps/web/src/components/user-provider.tsx
+++ b/apps/web/src/components/user-provider.tsx
@@ -1,7 +1,7 @@
"use client";
import { usePostHog } from "@rallly/posthog/client";
import type { Session } from "next-auth";
-import { useSession } from "next-auth/react";
+import { signOut, useSession } from "next-auth/react";
import React from "react";
import { Spinner } from "@/components/spinner";
@@ -107,10 +107,9 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
},
refresh: session.update,
logout: async () => {
- await fetch("/api/logout", { method: "POST" });
+ await signOut();
posthog?.capture("logout");
posthog?.reset();
- window.location.href = "/login";
},
ownsObject: (resource) => {
return isOwner(resource, { id: user.id, isGuest });
diff --git a/apps/web/src/components/user.tsx b/apps/web/src/components/user.tsx
index 65b8b3cf595..da9df5afdbd 100644
--- a/apps/web/src/components/user.tsx
+++ b/apps/web/src/components/user.tsx
@@ -25,7 +25,7 @@ export const UserAvatar = ({
"size-5 text-[10px]": size === "xs",
"size-6 text-sm": size === "sm",
"size-8 text-base": size === "md",
- "size-10 text-lg": size === "lg",
+ "size-12 text-lg": size === "lg",
},
!name
? "bg-gray-200"
diff --git a/apps/web/src/features/quick-create/components/relative-date.tsx b/apps/web/src/features/quick-create/components/relative-date.tsx
new file mode 100644
index 00000000000..7a6c5584670
--- /dev/null
+++ b/apps/web/src/features/quick-create/components/relative-date.tsx
@@ -0,0 +1,14 @@
+"use client";
+import dayjs from "dayjs";
+
+import { Trans } from "@/components/trans";
+
+export function RelativeDate({ date }: { date: Date }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/quick-create/constants.ts b/apps/web/src/features/quick-create/constants.ts
new file mode 100644
index 00000000000..a197239c752
--- /dev/null
+++ b/apps/web/src/features/quick-create/constants.ts
@@ -0,0 +1,2 @@
+export const isQuickCreateEnabled =
+ process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
diff --git a/apps/web/src/features/quick-create/index.ts b/apps/web/src/features/quick-create/index.ts
new file mode 100644
index 00000000000..40c985db8d2
--- /dev/null
+++ b/apps/web/src/features/quick-create/index.ts
@@ -0,0 +1 @@
+export { isQuickCreateEnabled } from "./constants";
diff --git a/apps/web/src/features/quick-create/lib/get-guest-polls.ts b/apps/web/src/features/quick-create/lib/get-guest-polls.ts
new file mode 100644
index 00000000000..a08d2f95b17
--- /dev/null
+++ b/apps/web/src/features/quick-create/lib/get-guest-polls.ts
@@ -0,0 +1,31 @@
+import { prisma } from "@rallly/database";
+
+import { getServerSession } from "@/auth";
+
+export async function getGuestPolls() {
+ const session = await getServerSession();
+ const user = session?.user;
+ const guestId = !user?.email ? user?.id : null;
+
+ if (!guestId) {
+ return [];
+ }
+
+ const recentlyCreatedPolls = await prisma.poll.findMany({
+ where: {
+ guestId,
+ deleted: false,
+ },
+ select: {
+ id: true,
+ title: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ take: 3,
+ });
+
+ return recentlyCreatedPolls;
+}
diff --git a/apps/web/src/features/quick-create/quick-create-button.tsx b/apps/web/src/features/quick-create/quick-create-button.tsx
new file mode 100644
index 00000000000..bf27f55c986
--- /dev/null
+++ b/apps/web/src/features/quick-create/quick-create-button.tsx
@@ -0,0 +1,21 @@
+import { Button } from "@rallly/ui/button";
+import { Icon } from "@rallly/ui/icon";
+import { ZapIcon } from "lucide-react";
+import Link from "next/link";
+import { Trans } from "react-i18next/TransWithoutContext";
+
+import { getTranslation } from "@/i18n/server";
+
+export async function QuickStartButton() {
+ const { t } = await getTranslation();
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/quick-create/quick-create-widget.tsx b/apps/web/src/features/quick-create/quick-create-widget.tsx
new file mode 100644
index 00000000000..67e23eecbd9
--- /dev/null
+++ b/apps/web/src/features/quick-create/quick-create-widget.tsx
@@ -0,0 +1,137 @@
+import { Button } from "@rallly/ui/button";
+import { Icon } from "@rallly/ui/icon";
+import { CheckIcon, PlusIcon, ZapIcon } from "lucide-react";
+import Link from "next/link";
+import { Trans } from "react-i18next/TransWithoutContext";
+
+import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
+import { getGuestPolls } from "@/features/quick-create/lib/get-guest-polls";
+import { getTranslation } from "@/i18n/server";
+
+import { RelativeDate } from "./components/relative-date";
+
+export async function QuickStartWidget() {
+ const polls = await getGuestPolls();
+ const { t } = await getTranslation();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {polls.length > 0 ? (
+
+
+
+
+
+ {polls.map((poll) => (
+
+
+
+
+
+
+
+ {poll.title}
+
+
+
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/i18n/server.ts b/apps/web/src/i18n/server.ts
index 107991df87a..8834f719d15 100644
--- a/apps/web/src/i18n/server.ts
+++ b/apps/web/src/i18n/server.ts
@@ -1,8 +1,11 @@
import { defaultNS } from "@/i18n/settings";
+import { getLocaleFromPath } from "@/utils/locale/get-locale-from-path";
import { initI18next } from "./i18n";
-export async function getTranslation(locale: string) {
+export async function getTranslation(localeOverride?: string) {
+ const localeFromPath = getLocaleFromPath();
+ const locale = localeOverride || localeFromPath;
const i18nextInstance = await initI18next(locale, defaultNS);
return {
t: i18nextInstance.getFixedT(locale, defaultNS),
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index 9c2af3fe929..a458d58b5da 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -8,17 +8,37 @@ import { isSelfHosted } from "@/utils/constants";
const supportedLocales = Object.keys(languages);
+const publicRoutes = [
+ "/login",
+ "/register",
+ "/invite/",
+ "/new",
+ "/poll/",
+ "/quick-create",
+ "/auth/login",
+];
+
export const middleware = withAuth(
async function middleware(req) {
const { nextUrl } = req;
const newUrl = nextUrl.clone();
+ const isLoggedIn = req.nextauth.token?.email;
+ // set x-pathname header to the pathname
+
// if the user is already logged in, don't let them access the login page
+ if (/^\/(login)/.test(newUrl.pathname) && isLoggedIn) {
+ newUrl.pathname = "/";
+ return NextResponse.redirect(newUrl);
+ }
+
+ // if the user is not logged in and the page is not public, redirect to login
if (
- /^\/(login|register)/.test(newUrl.pathname) &&
- req.nextauth.token?.email
+ !isLoggedIn &&
+ !publicRoutes.some((route) => newUrl.pathname.startsWith(route))
) {
- newUrl.pathname = "/";
+ newUrl.searchParams.set("callbackUrl", newUrl.pathname);
+ newUrl.pathname = "/login";
return NextResponse.redirect(newUrl);
}
@@ -34,7 +54,7 @@ export const middleware = withAuth(
}
const res = NextResponse.rewrite(newUrl);
-
+ res.headers.set("x-pathname", newUrl.pathname);
const jwt = await initGuest(req, res);
if (jwt?.sub) {
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index c74802afec7..50d11bdfc18 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -15,7 +15,8 @@
html {
@apply h-full font-sans text-base text-gray-700;
}
- body #__next {
+ body,
+ #__next {
@apply h-full;
}
@@ -23,6 +24,10 @@
@apply block text-sm;
}
+ p {
+ @apply leading-normal;
+ }
+
a,
button,
input,
@@ -34,7 +39,7 @@
@layer components {
.text-link {
- @apply rounded-md underline outline-none hover:text-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
+ @apply text-primary rounded-md font-medium outline-none hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
}
.formField {
@apply mb-4;
diff --git a/apps/web/src/trpc/client/config.ts b/apps/web/src/trpc/client/config.ts
index 80b90e17be6..932082f8f31 100644
--- a/apps/web/src/trpc/client/config.ts
+++ b/apps/web/src/trpc/client/config.ts
@@ -1,10 +1,34 @@
import * as Sentry from "@sentry/browser";
import { MutationCache } from "@tanstack/react-query";
-import { httpBatchLink } from "@trpc/client";
+import { type TRPCLink, httpBatchLink, TRPCClientError } from "@trpc/client";
+import { observable } from "@trpc/server/observable";
import superjson from "superjson";
+import type { AppRouter } from "../routers";
+
+const errorHandlingLink: TRPCLink = () => {
+ return ({ next, op }) => {
+ return observable((observer) => {
+ const unsubscribe = next(op).subscribe({
+ next: (result) => observer.next(result),
+ error: (error) => {
+ if (
+ error instanceof TRPCClientError &&
+ error.data?.code === "UNAUTHORIZED"
+ ) {
+ window.location.href = "/login";
+ }
+ observer.error(error);
+ },
+ });
+ return unsubscribe;
+ });
+ };
+};
+
export const trpcConfig = {
links: [
+ errorHandlingLink,
httpBatchLink({
url: "/api/trpc",
}),
diff --git a/apps/web/src/trpc/context.ts b/apps/web/src/trpc/context.ts
index e8eea39ca44..39daec76d2e 100644
--- a/apps/web/src/trpc/context.ts
+++ b/apps/web/src/trpc/context.ts
@@ -7,7 +7,6 @@ export type TRPCContext = {
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
image?: string;
- email?: string;
};
ip?: string;
};
diff --git a/apps/web/src/trpc/routers/auth.ts b/apps/web/src/trpc/routers/auth.ts
index f119f7f13ea..1ff86b430e6 100644
--- a/apps/web/src/trpc/routers/auth.ts
+++ b/apps/web/src/trpc/routers/auth.ts
@@ -10,11 +10,26 @@ import { publicProcedure, rateLimitMiddleware, router } from "../trpc";
import type { RegistrationTokenPayload } from "../types";
export const auth = router({
+ getUserInfo: publicProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const count = await prisma.user.count({
+ where: {
+ email: input.email,
+ },
+ });
+
+ return { isRegistered: count > 0 };
+ }),
requestRegistration: publicProcedure
.use(rateLimitMiddleware)
.input(
z.object({
- name: z.string().nonempty().max(100),
+ name: z.string().min(1).max(100),
email: z.string().email(),
}),
)
diff --git a/apps/web/src/trpc/routers/dashboard.ts b/apps/web/src/trpc/routers/dashboard.ts
index 99c72d4be92..8b098416045 100644
--- a/apps/web/src/trpc/routers/dashboard.ts
+++ b/apps/web/src/trpc/routers/dashboard.ts
@@ -1,9 +1,9 @@
import { prisma } from "@rallly/database";
-import { possiblyPublicProcedure, router } from "../trpc";
+import { privateProcedure, router } from "../trpc";
export const dashboard = router({
- info: possiblyPublicProcedure.query(async ({ ctx }) => {
+ info: privateProcedure.query(async ({ ctx }) => {
const activePollCount = await prisma.poll.count({
where: {
...(ctx.user.isGuest
diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts
index 02ef84bd2be..6bca31efdc8 100644
--- a/apps/web/src/trpc/routers/polls.ts
+++ b/apps/web/src/trpc/routers/polls.ts
@@ -13,6 +13,7 @@ import { getEmailClient } from "@/utils/emails";
import { getTimeZoneAbbreviation } from "../../utils/date";
import {
possiblyPublicProcedure,
+ privateProcedure,
proProcedure,
publicProcedure,
rateLimitMiddleware,
@@ -41,7 +42,7 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
export const polls = router({
participants,
comments,
- getCountByStatus: possiblyPublicProcedure.query(async ({ ctx }) => {
+ getCountByStatus: privateProcedure.query(async ({ ctx }) => {
const res = await prisma.poll.groupBy({
by: ["status"],
where: {
@@ -61,7 +62,7 @@ export const polls = router({
{} as Record,
);
}),
- infiniteList: possiblyPublicProcedure
+ infiniteList: privateProcedure
.input(
z.object({
status: z.enum(["all", "live", "paused", "finalized"]),
diff --git a/apps/web/src/trpc/routers/scheduled-events.ts b/apps/web/src/trpc/routers/scheduled-events.ts
index 6e25a2a4463..7d77dbb05b2 100644
--- a/apps/web/src/trpc/routers/scheduled-events.ts
+++ b/apps/web/src/trpc/routers/scheduled-events.ts
@@ -5,14 +5,14 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
-import { possiblyPublicProcedure, router } from "../trpc";
+import { privateProcedure, router } from "../trpc";
dayjs.extend(toArray);
dayjs.extend(timezone);
dayjs.extend(utc);
export const scheduledEvents = router({
- list: possiblyPublicProcedure
+ list: privateProcedure
.input(
z.object({
period: z.enum(["upcoming", "past"]).default("upcoming"),
diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts
index 6c0c50d4fbb..dbc6ebb4135 100644
--- a/apps/web/src/trpc/routers/user.ts
+++ b/apps/web/src/trpc/routers/user.ts
@@ -25,7 +25,7 @@ const mimeToExtension = {
} as const;
export const user = router({
- getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
+ getBilling: privateProcedure.query(async ({ ctx }) => {
return await prisma.userPaymentData.findUnique({
select: {
subscriptionId: true,
@@ -126,6 +126,18 @@ export const user = router({
.use(rateLimitMiddleware)
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
+ const currentUser = await prisma.user.findUnique({
+ where: { id: ctx.user.id },
+ select: { email: true },
+ });
+
+ if (!currentUser) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "User not found",
+ });
+ }
+
// check if the email is already in use
const existingUser = await prisma.user.count({
where: { email: input.email },
@@ -141,7 +153,7 @@ export const user = router({
// create a verification token
const token = await createToken(
{
- fromEmail: ctx.user.email,
+ fromEmail: currentUser.email,
toEmail: input.email,
},
{
@@ -155,7 +167,7 @@ export const user = router({
verificationUrl: absoluteUrl(
`/api/user/verify-email-change?token=${token}`,
),
- fromEmail: ctx.user.email,
+ fromEmail: currentUser.email,
toEmail: input.email,
},
});
diff --git a/apps/web/src/trpc/trpc.ts b/apps/web/src/trpc/trpc.ts
index 3099430bf44..3be7a483a0e 100644
--- a/apps/web/src/trpc/trpc.ts
+++ b/apps/web/src/trpc/trpc.ts
@@ -64,22 +64,14 @@ export const proProcedure = t.procedure.use(
export const privateProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
- const email = ctx.user.email;
- if (!email) {
+ if (ctx.user.isGuest !== false) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
});
}
- return next({
- ctx: {
- user: {
- ...ctx.user,
- email,
- },
- },
- });
+ return next();
}),
);
diff --git a/apps/web/src/utils/locale/get-locale-from-path.ts b/apps/web/src/utils/locale/get-locale-from-path.ts
new file mode 100644
index 00000000000..7074cda937a
--- /dev/null
+++ b/apps/web/src/utils/locale/get-locale-from-path.ts
@@ -0,0 +1,8 @@
+import { defaultLocale } from "@rallly/languages";
+import { headers } from "next/headers";
+
+export function getLocaleFromPath() {
+ const headersList = headers();
+ const pathname = headersList.get("x-pathname") || defaultLocale;
+ return pathname.split("/")[1];
+}
diff --git a/apps/web/tests/authentication.spec.ts b/apps/web/tests/authentication.spec.ts
index 43c874df5d4..8176af0c9c8 100644
--- a/apps/web/tests/authentication.spec.ts
+++ b/apps/web/tests/authentication.spec.ts
@@ -40,7 +40,7 @@ test.describe.serial(() => {
.getByPlaceholder("jessie.smith@example.com")
.fill(testUserEmail);
- await page.getByRole("button", { name: "Login with Email" }).click();
+ await page.getByRole("button", { name: "Continue with Email" }).click();
// Make sure the user doesn't exist yet and that logging in is not possible
await expect(
@@ -51,7 +51,7 @@ test.describe.serial(() => {
test("user registration", async ({ page }) => {
await page.goto("/register");
- await page.getByText("Create an account").waitFor();
+ await page.getByText("Create Your Account").waitFor();
await page.getByPlaceholder("Jessie Smith").fill("Test User");
await page
@@ -60,15 +60,15 @@ test.describe.serial(() => {
await page.getByRole("button", { name: "Continue", exact: true }).click();
- const codeInput = page.getByPlaceholder("Enter your 6-digit code");
-
const code = await getCode();
- await codeInput.fill(code);
+ await page.getByText("Finish Registering").waitFor();
- await page.getByRole("button", { name: "Continue", exact: true }).click();
+ const codeInput = page.getByPlaceholder("Enter your 6-digit code");
+
+ await codeInput.fill(code);
- await page.waitForURL("/");
+ await expect(page.getByText("Test User")).toBeVisible();
});
});
@@ -76,7 +76,7 @@ test.describe.serial(() => {
test("can't register with the same email", async ({ page }) => {
await page.goto("/register");
- await page.getByText("Create an account").waitFor();
+ await page.getByText("Create Your Account").waitFor();
await page.getByPlaceholder("Jessie Smith").fill("Test User");
await page
@@ -97,7 +97,7 @@ test.describe.serial(() => {
.getByPlaceholder("jessie.smith@example.com")
.fill(testUserEmail);
- await page.getByRole("button", { name: "Login with Email" }).click();
+ await page.getByRole("button", { name: "Continue with Email" }).click();
const html = await captureEmailHTML(testUserEmail);
@@ -111,13 +111,27 @@ test.describe.serial(() => {
await page.goto(magicLink);
- await page.getByRole("button", { name: "Continue", exact: true }).click();
-
- await page.waitForURL("/");
+ await page.getByRole("button", { name: "Login", exact: true }).click();
await expect(page.getByText("Test User")).toBeVisible();
});
+ test("shows error for invalid verification code", async ({ page }) => {
+ await page.goto("/login");
+
+ await page
+ .getByPlaceholder("jessie.smith@example.com")
+ .fill(testUserEmail);
+
+ await page.getByRole("button", { name: "Continue with Email" }).click();
+
+ await page.getByPlaceholder("Enter your 6-digit code").fill("000000");
+
+ await expect(
+ page.getByText("Your verification code is incorrect or has expired"),
+ ).toBeVisible();
+ });
+
test("can login with verification code", async ({ page }) => {
await page.goto("/login");
@@ -125,16 +139,12 @@ test.describe.serial(() => {
.getByPlaceholder("jessie.smith@example.com")
.fill(testUserEmail);
- await page.getByRole("button", { name: "Login with Email" }).click();
+ await page.getByRole("button", { name: "Continue with Email" }).click();
const code = await getCode();
await page.getByPlaceholder("Enter your 6-digit code").fill(code);
- await page.getByRole("button", { name: "Continue", exact: true }).click();
-
- await page.waitForURL("/");
-
await expect(page.getByText("Test User")).toBeVisible();
});
@@ -145,16 +155,12 @@ test.describe.serial(() => {
.getByPlaceholder("jessie.smith@example.com")
.fill("Test@example.com");
- await page.getByRole("button", { name: "Login with Email" }).click();
+ await page.getByRole("button", { name: "Continue with Email" }).click();
const code = await getCode();
await page.getByPlaceholder("Enter your 6-digit code").fill(code);
- await page.getByRole("button", { name: "Continue", exact: true }).click();
-
- await page.waitForURL("/");
-
await expect(page.getByText("Test User")).toBeVisible();
});
});
diff --git a/apps/web/tests/create-delete-poll.spec.ts b/apps/web/tests/create-delete-poll.spec.ts
index e4cb110cdae..e3ed1b6e5e9 100644
--- a/apps/web/tests/create-delete-poll.spec.ts
+++ b/apps/web/tests/create-delete-poll.spec.ts
@@ -32,6 +32,6 @@ test.describe.serial(() => {
deletePollDialog.getByRole("button", { name: "delete" }).click();
- await expect(page).toHaveURL("/polls");
+ await expect(page).toHaveURL("/login?callbackUrl=%2Fpolls");
});
});
diff --git a/package.json b/package.json
index bde69d1e703..17b998f51c7 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"dev:landing": "dotenv -c development turbo dev --filter=@rallly/landing",
"start": "turbo run start --filter=@rallly/web",
"build": "dotenv -c -- turbo run build --filter=@rallly/web",
- "build:web": "turbo run build --filter=@rallly/web",
+ "build:web": "NEXT_PUBLIC_APP_VERSION=$(node scripts/inject-version.js) turbo run build --filter=@rallly/web",
"build:landing": "turbo run build --filter=@rallly/landing",
"build:test": "turbo build:test",
"docs:dev": "turbo dev --filter=@rallly/docs...",
diff --git a/packages/languages/index.ts b/packages/languages/index.ts
index 83160415fe7..008fa340515 100644
--- a/packages/languages/index.ts
+++ b/packages/languages/index.ts
@@ -2,4 +2,6 @@ import languages from "./languages.json";
export const supportedLngs = Object.keys(languages);
+export const defaultLocale = "en";
+
export default languages;
diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx
index 4e897ba7cbc..821faa6323c 100644
--- a/packages/ui/src/button.tsx
+++ b/packages/ui/src/button.tsx
@@ -10,28 +10,27 @@ import { cn } from "./lib/utils";
const buttonVariants = cva(
cn(
"inline-flex border font-medium disabled:pointer-events-none select-none disabled:opacity-50 items-center justify-center whitespace-nowrap border",
- "focus-visible:ring-offset-input-background",
- "focus:shadow-none",
+ "focus:shadow-none focus-visible:ring-2 focus-visible:ring-ring",
),
{
variants: {
variant: {
primary:
- "border-primary-700 bg-primary disabled:bg-gray-400 disabled:border-transparent text-primary-foreground shadow-sm focus:bg-primary-500",
+ "focus:ring-offset-1 border-primary-700 bg-primary hover:bg-primary-700 disabled:bg-gray-400 active:bg-primary-800 disabled:border-transparent text-primary-foreground shadow-sm",
destructive:
- "bg-destructive shadow-sm text-destructive-foreground focus-visible:ring-offset-1 active:bg-destructive border-destructive hover:bg-destructive/90",
+ "focus:ring-offset-1 bg-destructive shadow-sm text-destructive-foreground active:bg-destructive border-destructive hover:bg-destructive/90",
default:
- "ring-1 ring-inset ring-white/25 data-[state=open]:bg-gray-100 focus:border-gray-300 focus:bg-gray-200 hover:bg-gray-100 bg-gray-50",
+ "focus:ring-offset-1 hover:bg-gray-100 bg-gray-50 active:bg-gray-200",
secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ "focus:ring-offset-1 bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
- "border-transparent bg-transparent text-gray-800 hover:bg-gray-100 focus:bg-gray-200",
+ "border-transparent bg-transparent data-[state=open]:bg-gray-500/20 text-gray-800 hover:bg-gray-500/10 active:bg-gray-500/20",
link: "underline-offset-4 border-transparent hover:underline text-primary",
},
size: {
- default: "h-9 px-2.5 pr-3 gap-x-2 text-sm rounded-md",
+ default: "h-9 pl-2.5 pr-3 gap-x-2 text-sm rounded-md",
sm: "h-7 text-sm px-1.5 gap-x-1.5 rounded-md",
- lg: "h-11 text-base gap-x-3 px-4 rounded-md",
+ lg: "h-12 text-base gap-x-3 px-4 rounded-md",
},
},
defaultVariants: {
diff --git a/packages/ui/src/dot-pattern.tsx b/packages/ui/src/dot-pattern.tsx
new file mode 100644
index 00000000000..f50da7ed538
--- /dev/null
+++ b/packages/ui/src/dot-pattern.tsx
@@ -0,0 +1,56 @@
+import { useId } from "react";
+
+import { cn } from "./lib/utils";
+
+interface DotPatternProps {
+ width?: number;
+ height?: number;
+ x?: number;
+ y?: number;
+ cx?: number;
+ cy?: number;
+ cr?: number;
+ className?: string;
+ [key: string]: unknown;
+}
+export function DotPattern({
+ width = 16,
+ height = 16,
+ x = 0,
+ y = 0,
+ cx = 1,
+ cy = 1,
+ cr = 1,
+ className,
+ ...props
+}: DotPatternProps) {
+ const id = useId();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default DotPattern;
diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx
index 0a61356431b..d7cea8bcd95 100644
--- a/packages/ui/src/form.tsx
+++ b/packages/ui/src/form.tsx
@@ -82,12 +82,12 @@ const FormLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => {
- const { error, formItemId } = useFormField();
+ const { formItemId } = useFormField();
return (
diff --git a/packages/ui/src/input.tsx b/packages/ui/src/input.tsx
index a88fd9c3839..ac7f9d75e73 100644
--- a/packages/ui/src/input.tsx
+++ b/packages/ui/src/input.tsx
@@ -13,7 +13,7 @@ export type InputProps = Omit<
const inputVariants = cva(
cn(
- "w-full focus-visible:border-primary-400 focus-visible:ring-offset-1 focus-visible:outline-none focus-visible:ring-primary-200 focus-visible:ring-1",
+ "w-full focus-visible:border-gray-300 focus:ring-ring focus:ring-2",
"border-input placeholder:text-muted-foreground h-9 rounded-md border bg-white file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
),
{
@@ -21,7 +21,7 @@ const inputVariants = cva(
size: {
sm: "h-7 text-xs px-1",
md: "h-9 text-sm px-2",
- lg: "h-12 text-lg px-3",
+ lg: "h-12 text-base px-3",
},
variant: {
default: "border-primary-400 focus-visible:border-primary-400",
diff --git a/packages/ui/src/label.tsx b/packages/ui/src/label.tsx
index 494e76a86fa..cfe26a41176 100644
--- a/packages/ui/src/label.tsx
+++ b/packages/ui/src/label.tsx
@@ -8,7 +8,7 @@ import * as React from "react";
import { cn } from "./lib/utils";
const labelVariants = cva(
- "text-sm text-muted-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+ "text-sm text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
diff --git a/packages/utils/src/sleep.ts b/packages/utils/src/sleep.ts
new file mode 100644
index 00000000000..0d7f188e179
--- /dev/null
+++ b/packages/utils/src/sleep.ts
@@ -0,0 +1,3 @@
+export function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/scripts/inject-version.js b/scripts/inject-version.js
new file mode 100644
index 00000000000..8c064f27fe2
--- /dev/null
+++ b/scripts/inject-version.js
@@ -0,0 +1,8 @@
+const { execSync } = require("child_process");
+const packageJson = require("../package.json");
+
+const version = packageJson.version;
+const gitHash = execSync("git rev-parse --short HEAD").toString().trim();
+const versionWithHash = `${version}-${gitHash}`;
+
+console.log(versionWithHash);