New Login Page (#1504)

This commit is contained in:
Luke Vella 2025-01-21 18:07:13 +00:00 committed by GitHub
parent 655f38203a
commit f5ab25ed1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1669 additions and 713 deletions

View file

@ -2,7 +2,6 @@
"12h": "12-hour",
"24h": "24-hour",
"addTimeOption": "Add time option",
"alreadyRegistered": "Already registered? <a>Login</a>",
"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 <a>{newUrl}</a>",
"notRegistered": "Don't have an account? <a>Register</a>",
"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 <b>{email}</b>",
"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? <a>Sign up</a>",
"back": "Back",
"verifyEmail": "Verify your email",
"alreadyHaveAccount": "Already have an account? <a>Log in</a>",
"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"
}

View file

@ -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",
},
)}
>

View file

@ -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 (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-4">
<div className="mb-6">
<Logo />
</div>
<div className="shadow-huge rounded-md bg-white p-4">
<div className="w-48 text-center">
<div className="mb-4 font-semibold">
<Trans i18nKey="continueAs" defaults="Continue as" />
</div>
<div className="flex flex-col items-center gap-2">
<OptimizedAvatarImage
src={data?.image ?? undefined}
name={data?.name ?? ""}
size="xl"
/>
<div className="text-center">
<div className="mb-1 h-6 font-medium">
{data?.name ?? <Skeleton className="inline-block h-5 w-16" />}
</div>
<div className="text-muted-foreground h-5 truncate text-sm">
{data?.email ?? (
<Skeleton className="inline-block h-full w-20" />
)}
</div>
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="w-48 space-y-8 text-center">
<h1 className="text-xl font-bold">
<Trans i18nKey="continueAs" defaults="Continue as" />
</h1>
<div className="flex flex-col items-center gap-4">
<OptimizedAvatarImage
src={data?.image ?? undefined}
name={data?.name ?? ""}
size="xl"
/>
<div>
<div className="mb-1 h-6 font-medium">
{data?.name ?? <Skeleton className="inline-block h-5 w-16" />}
</div>
<div className="text-muted-foreground h-5 truncate text-sm">
{data?.email ?? <Skeleton className="inline-block h-full w-20" />}
</div>
</div>
</div>
<div>
<Button
size="lg"
loading={magicLinkFetch.isLoading}
onClick={async () => {
await magicLinkFetch.mutateAsync();
}}
variant="primary"
className="mt-6 w-full"
className="w-full"
>
<Trans i18nKey="continue" />
<Trans i18nKey="login" defaults="Login" />
</Button>
</div>
</div>

View file

@ -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";

View file

@ -0,0 +1,29 @@
export function AuthPageContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-8 lg:space-y-10">{children}</div>;
}
export function AuthPageHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-1 text-center">{children}</div>;
}
export function AuthPageTitle({ children }: { children: React.ReactNode }) {
return <h1 className="text-2xl font-bold">{children}</h1>;
}
export function AuthPageDescription({
children,
}: {
children: React.ReactNode;
}) {
return <p className="text-muted-foreground">{children}</p>;
}
export function AuthPageContent({ children }: { children: React.ReactNode }) {
return <div className="space-y-4">{children}</div>;
}
export function AuthPageExternal({ children }: { children: React.ReactNode }) {
return (
<p className="text-muted-foreground px-4 py-3 text-center">{children}</p>
);
}

View file

@ -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 (
<div className="h-full p-3 sm:p-8">
<div className="mx-auto max-w-lg">{children}</div>
<div className="relative flex h-screen flex-col items-center justify-center bg-gray-100 p-2 lg:p-4">
<div className="z-10 flex w-full max-w-7xl flex-1 rounded-xl border bg-white shadow-sm lg:max-h-[720px] lg:p-2">
<div className="flex flex-1 flex-col gap-4 p-6 lg:p-16">
<div className="py-8">
<Logo className="mx-auto" />
</div>
<div className="flex h-full w-full flex-1 flex-col items-center justify-center">
<div className="w-full max-w-sm">{children}</div>
</div>
{isQuickCreateEnabled ? (
<div className="flex justify-center lg:hidden">
<QuickStartButton />
</div>
) : null}
</div>
{isQuickCreateEnabled ? (
<div className="relative hidden flex-1 flex-col justify-center rounded-lg border border-gray-100 bg-gray-50 lg:flex lg:p-16">
<div className="z-10 mx-auto w-full max-w-md">
<QuickStartWidget />
</div>
<DotPattern
cx={10}
cy={10}
className={cn(
"[mask-image:radial-gradient(400px_circle_at_top,white,transparent)]",
)}
/>
</div>
) : null}
</div>
</div>
);
}
export const metadata: Metadata = {
title: {
template: "%s - Rallly",
default: "Rallly",
},
};

View file

@ -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;
}

View file

@ -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 (
<p className="text-destructive text-sm">
{t("accountNotLinkedDescription", {
defaultValue:
"A user with this email already exists. Please log in using the original method.",
})}
</p>
);
}
return null;
}

View file

@ -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<ReturnType<typeof useLoginWithEmailSchema>>;
export function LoginWithEmailForm() {
const router = useRouter();
const loginWithEmailSchema = useLoginWithEmailSchema();
const searchParams = useSearchParams();
const form = useForm<LoginWithEmailValues>({
defaultValues: {
identifier: "",
},
resolver: zodResolver(loginWithEmailSchema),
});
const { handleSubmit, formState } = form;
const { t } = useTranslation();
return (
<Form {...form}>
<form
className="space-y-4"
onSubmit={handleSubmit(async ({ identifier }) => {
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",
}),
});
}
})}
>
<FormField
control={form.control}
name="identifier"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
size="lg"
type="text"
data-1p-ignore
disabled={
formState.isSubmitting || formState.isSubmitSuccessful
}
autoFocus={true}
placeholder={t("emailPlaceholder")}
autoComplete="false"
error={!!formState.errors.identifier}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
size="lg"
loading={
form.formState.isSubmitting || formState.isSubmitSuccessful
}
type="submit"
className="w-full"
variant="primary"
>
<Trans
i18nKey="continueWith"
defaults="Continue with {provider}"
values={{ provider: t("email") }}
/>
</Button>
</div>
</form>
</Form>
);
}

View file

@ -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 (
<Button
onClick={() => {
signIn("oidc");
}}
variant="link"
>
{children}
</Button>
);
}

View file

@ -0,0 +1,9 @@
export function OrDivider({ text }: { text: string }) {
return (
<div className="flex items-center gap-x-2.5">
<hr className="grow border-gray-100" />
<div className="text-muted-foreground lowercase">{text}</div>
<hr className="grow border-gray-100" />
</div>
);
}

View file

@ -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 (
<Image src="/static/google.svg" width={16} alt="Google" height={16} />
);
}
if (provider === "azure-ad") {
return (
<Image
src="/static/microsoft.svg"
width={16}
alt="Microsoft"
height={16}
/>
);
}
if (provider === "oidc") {
return (
<Icon>
<UserIcon />
</Icon>
);
}
return null;
}
export function SSOProvider({
providerId,
name,
}: {
providerId: string;
name: string;
}) {
const { t } = useTranslation();
return (
<Button
size="lg"
aria-label={t("continueWithProvider", {
provider: name,
ns: "app",
defaultValue: "Continue with {{provider}}",
})}
key={providerId}
onClick={() => {
signIn(providerId);
}}
>
<SSOImage provider={providerId} />
<span>
<Trans
i18nKey="continueWithProvider"
defaults="Continue with {{provider}}"
values={{ provider: name }}
/>
</span>
</Button>
);
}

View file

@ -2,7 +2,7 @@ import { Spinner } from "@/components/spinner";
export default function Loading() {
return (
<div className="flex h-72 items-center justify-center">
<div className="flex h-full items-center justify-center">
<Spinner className="text-muted-foreground" />
</div>
);

View file

@ -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<typeof loginFormSchema>;
export function LoginForm() {
const { t } = useTranslation();
const searchParams = useSearchParams();
const { register, handleSubmit, getValues, formState, setError } = useForm<LoginFormData>({
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<string>();
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: <UserIcon className="text-muted-foreground size-5" />,
name: t("loginWith", { provider: providers.oidc.name }),
});
}
if (providers?.google) {
res.push({
login: () => {
signIn("google", {
callbackUrl,
});
},
icon: (
<Image src="/static/google.svg" width={20} height={20} alt="Google" />
),
name: t("loginWith", { provider: providers.google.name }),
});
}
if (providers?.["azure-ad"]) {
res.push({
login: () => {
signIn("azure-ad", {
callbackUrl,
});
},
icon: (
<Image
src="/static/microsoft.svg"
width={20}
height={20}
alt="Azure AD"
/>
),
name: t("loginWith", { provider: "Microsoft" }),
});
}
if (allowGuestAccess) {
res.push({
login: () => {
router.push(callbackUrl);
posthog?.capture("click continue as guest");
},
icon: <UserIcon className="text-muted-foreground size-5" />,
name: t("continueAsGuest"),
});
}
return res;
}, [callbackUrl, posthog, providers, router, t]);
if (!providers) {
return (
<div className="flex h-72 items-center justify-center">
<Spinner className="text-muted-foreground" />
</div>
);
}
const sendVerificationEmail = (email: string) => {
return signIn("email", {
redirect: false,
email,
callbackUrl,
});
};
if (email) {
return (
<VerifyCode
onSubmit={async (code) => {
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 (
<form
onSubmit={handleSubmit(async ({ email }) => {
const res = await sendVerificationEmail(email);
if (res?.error) {
setError("email", {
message: t("userNotFound"),
});
} else {
setEmail(email);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-2.5">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<Input
className="w-full"
id="email"
size="lg"
error={!!formState.errors.email}
autoFocus={true}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email")}
/>
{formState.errors.email?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<div className="flex flex-col gap-2">
<Button
loading={formState.isSubmitting}
type="submit"
size="lg"
variant="primary"
className=""
>
{t("loginWith", {
provider: t("email"),
})}
</Button>
{error === "OAuthAccountNotLinked" ? (
<Alert icon={AlertTriangleIcon} variant="destructive">
<AlertTitle>
{t("accountNotLinkedTitle", {
defaultValue:
"Your account cannot be linked to an existing user",
})}
</AlertTitle>
<AlertDescription>
{t("accountNotLinkedDescription", {
defaultValue:
"A user with this email already exists. Please log in using the original method.",
})}
</AlertDescription>
</Alert>
) : null}
{alternativeLoginMethods.length > 0 ? (
<>
<div className="relative my-4">
<hr className="border-grey-500 absolute top-1/2 w-full border-t" />
<span className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white px-2 text-center text-xs uppercase text-gray-400">
{t("or", { defaultValue: "Or" })}
</span>
</div>
<div className="grid gap-2.5">
{alternativeLoginMethods.map((method, i) => (
<Button size="lg" key={i} onClick={method.login}>
{method.icon}
{method.name}
</Button>
))}
</div>
</>
) : null}
</div>
</form>
);
}

View file

@ -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 (
<div>
<AuthCard>
<LoginForm />
</AuthCard>
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
<AuthPageContainer>
<AuthPageHeader>
<AuthPageTitle>
<Trans t={t} ns="app" i18nKey="loginTitle" defaults="Welcome" />
</AuthPageTitle>
<AuthPageDescription>
<Trans
t={t}
ns="app"
i18nKey="loginDescription"
defaults="Login to your account to continue"
/>
</AuthPageDescription>
</AuthPageHeader>
<AuthPageContent>
<LoginWithEmailForm />
{oidcProvider ? (
<div className="text-center">
<LoginWithOIDC>
<Trans
t={t}
i18nKey="continueWithProvider"
ns="app"
defaultValue="Login with {{provider}}"
values={{ provider: oidcProvider.name }}
/>
</LoginWithOIDC>
</div>
) : null}
{socialProviders.length > 0 ? (
<>
<OrDivider text={t("or")} />
<SSOProviders />
</>
) : null}
</AuthPageContent>
<AuthErrors />
<AuthPageExternal>
<Trans
t={t}
i18nKey="notRegistered"
defaults="Don't have an account? <a>Register</a>"
i18nKey="loginFooter"
defaults="Don't have an account? <a>Sign up</a>"
components={{
a: <Link href="/register" className="text-link" />,
a: <Link className="text-link" href="/register" />,
}}
/>
</div>
</div>
</AuthPageExternal>
</AuthPageContainer>
);
}
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"),

View file

@ -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 (
<div className="grid gap-4">
{socialProviders.map((provider) => (
<SSOProvider
key={provider.id}
providerId={provider.id}
name={provider.name}
/>
))}
</div>
);
}

View file

@ -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<typeof otpFormSchema>;
export function OTPForm({ email }: { email: string }) {
const { t } = useTranslation();
const form = useForm<OTPFormValues>({
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 (
<Form {...form}>
<form className="space-y-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="otp"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<InputOTP
size="lg"
placeholder={t("verificationCodePlaceholder")}
disabled={
form.formState.isSubmitting ||
form.formState.isSubmitSuccessful
}
autoFocus={true}
onValidCode={() => {
handleSubmit();
}}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey="verificationCodeHelp" />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<Button
className="w-full"
variant="primary"
size="lg"
type="submit"
loading={
form.formState.isSubmitting || form.formState.isSubmitSuccessful
}
>
<Trans i18nKey="login" defaults="Login" />
</Button>
</form>
</Form>
);
}

View file

@ -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 (
<AuthPageContainer>
<AuthPageHeader>
<AuthPageTitle>
<Trans
t={t}
ns="app"
i18nKey="loginVerifyTitle"
defaults="Finish Logging In"
/>
</AuthPageTitle>
<AuthPageDescription>
<Trans
t={t}
ns="app"
i18nKey="loginVerifyDescription"
defaults="Check your email for the verification code"
/>
</AuthPageDescription>
</AuthPageHeader>
<AuthPageContent>
<OTPForm email={email} />
<Button size="lg" variant="link" className="w-full" asChild>
<Link href="/login">
<Trans t={t} ns="app" i18nKey="back" defaults="Back" />
</Link>
</Button>
</AuthPageContent>
</AuthPageContainer>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("verifyEmail", {
ns: "app",
defaultValue: "Verify your email",
}),
};
}

View file

@ -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,
});
}

View file

@ -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<typeof registerNameFormSchema>;
export function RegisterNameForm() {
const { t } = useTranslation();
const form = useForm<RegisterNameFormValues>({
defaultValues: {
name: "",
email: "",
},
resolver: zodResolver(registerNameFormSchema),
});
const registerUser = trpc.auth.requestRegistration.useMutation();
const router = useRouter();
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
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;
}
}
})}
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey="name" defaults="Name" />
</FormLabel>
<FormControl>
<Input
{...field}
size="lg"
data-1p-ignore
placeholder={t("namePlaceholder")}
disabled={form.formState.isSubmitting}
autoFocus={true}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" defaults="Email" />
</FormLabel>
<FormControl>
<Input
size="lg"
placeholder={t("emailPlaceholder")}
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-6">
<Button
loading={
form.formState.isSubmitting || form.formState.isSubmitSuccessful
}
className="w-full"
variant="primary"
type="submit"
size="lg"
>
<Trans i18nKey="continue" />
</Button>
</div>
</form>
</Form>
);
}

View file

@ -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<typeof registerNameFormSchema>;

View file

@ -0,0 +1,9 @@
import { Spinner } from "@/components/spinner";
export default function Loading() {
return (
<div className="flex h-full items-center justify-center">
<Spinner className="text-muted-foreground" />
</div>
);
}

View file

@ -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 <RegisterForm />;
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 (
<AuthPageContainer>
<AuthPageHeader>
<AuthPageTitle>
<Trans
t={t}
ns="app"
i18nKey="registerTitle"
defaults="Create Your Account"
/>
</AuthPageTitle>
<AuthPageDescription>
<Trans
t={t}
ns="app"
i18nKey="registerDescription"
defaults="Streamline your scheduling process and save time"
/>
</AuthPageDescription>
</AuthPageHeader>
<AuthPageContent>
<RegisterNameForm />
</AuthPageContent>
<AuthPageExternal>
<Trans
t={t}
ns="app"
i18nKey="alreadyHaveAccount"
defaults="Already have an account? <a>Log in</a>"
components={{
a: <Link className="text-link" href="/login" />,
}}
/>
</AuthPageExternal>
</AuthPageContainer>
);
}
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"),

View file

@ -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<typeof registerFormSchema>;
export const RegisterForm = () => {
const { t } = useTranslation();
const { timeZone } = useDayjs();
const params = useParams<{ locale: string }>();
const searchParams = useSearchParams();
const form = useForm<RegisterFormData>({
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<string>();
const posthog = usePostHog();
if (token) {
return (
<AuthCard>
<VerifyCode
onSubmit={async (code) => {
// 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")}
/>
</AuthCard>
);
}
return (
<div>
<AuthCard>
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
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,
});
}
}
})}
>
<div className="mb-1 text-2xl font-bold">
{t("createAnAccount")}
</div>
<p className="mb-6 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<div className="space-y-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">{t("name")}</FormLabel>
<FormControl>
<Input
{...field}
id="name"
size="lg"
autoFocus={true}
error={!!formState.errors.name}
placeholder={t("namePlaceholder")}
disabled={formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="email">{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
id="email"
size="lg"
error={!!formState.errors.email}
placeholder={t("emailPlaceholder")}
disabled={formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-6">
<Button
loading={formState.isSubmitting}
type="submit"
variant="primary"
size="lg"
>
{t("continue")}
</Button>
</div>
{formState.errors.root ? (
<FormMessage className="mt-6">
{formState.errors.root.message}
</FormMessage>
) : null}
</form>
</Form>
</AuthCard>
{!form.formState.isSubmitSuccessful ? (
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
<Trans
i18nKey="alreadyRegistered"
components={{
a: <Link href="/login" className="text-link" />,
}}
/>
</div>
) : null}
</div>
);
};

View file

@ -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<typeof otpFormSchema>;
export function OTPForm({ token }: { token: string }) {
const { t, i18n } = useTranslation();
const form = useForm<OTPFormValues>({
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 (
<Form {...form}>
<form className="space-y-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="otp"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<InputOTP
size="lg"
placeholder={t("verificationCodePlaceholder", {
ns: "app",
})}
disabled={isLoading}
autoFocus={true}
onValidCode={() => {
handleSubmit();
}}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey="verificationCodeHelp" />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<Button
className="w-full"
variant="primary"
type="submit"
size="lg"
disabled={isLoading}
>
<Trans i18nKey="createAccount" defaults="Create Account" />
</Button>
</form>
</Form>
);
}

View file

@ -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 (
<AuthPageContainer>
<AuthPageHeader>
<AuthPageTitle>
<Trans
t={t}
ns="app"
i18nKey="registerVerifyTitle"
defaults="Finish Registering"
/>
</AuthPageTitle>
<AuthPageDescription>
<Trans
t={t}
ns="app"
i18nKey="registerVerifyDescription"
defaults="Check your email for the verification code"
/>
</AuthPageDescription>
</AuthPageHeader>
<AuthPageContent>
<OTPForm token={token} />
<Button size="lg" variant="link" className="w-full" asChild>
<Link href="/register">
<Trans t={t} ns="app" i18nKey="back" defaults="Back" />
</Link>
</Button>
</AuthPageContent>
</AuthPageContainer>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("verifyEmail", {
ns: "app",
defaultValue: "Verify your email",
}),
};
}

View file

@ -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 (
<div className="flex min-h-screen p-2">
<div className="flex flex-1 flex-col gap-6 rounded-xl border bg-white p-6">
<div className="mx-auto w-full max-w-md flex-1">
<div className="space-y-8">
<div className="flex-1">
<QuickStartWidget />
</div>
</div>
</div>
<div className="flex justify-center gap-4">
<Button className="rounded-full" asChild>
<Link href="/login" className="flex items-center gap-2">
<Icon>
<LogInIcon />
</Icon>
Login
</Link>
</Button>
</div>
</div>
</div>
);
}

View file

@ -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),
},

View file

@ -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 (
<LanguageSelect
value={preferences.locale ?? i18n.language}
onChange={async (language) => {
await updatePreferences({ locale: language });
router.refresh();
}}
/>
);
}

View file

@ -15,7 +15,7 @@ import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { PostHogPageView } from "./posthog-page-view";
export const trpc = createTRPCReact<AppRouter>({
unstable_overrides: {
overrides: {
useMutation: {
async onSuccess(opts) {
await opts.originalFn();

View file

@ -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: {

View file

@ -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<void>;
}> = ({ onSubmit, email }) => {
const { register, handleSubmit, setError, formState } = useForm<{
code: string;
}>();
const { t } = useTranslation();
return (
<div>
<form
onSubmit={handleSubmit(async ({ code }) => {
try {
await onSubmit(code);
} catch {
setError("code", {
type: "not_found",
message: t("wrongVerificationCode"),
});
}
})}
>
<fieldset>
<h1 className="mb-1 text-2xl font-bold">{t("verifyYourEmail")}</h1>
<div className="mb-4 text-gray-500">
{t("stepSummary", {
current: 2,
total: 2,
})}
</div>
<p className="mb-4">
<Trans
t={t}
i18nKey="verificationCodeSentTo"
defaults="We sent a verification code to <b>{email}</b>"
values={{ email }}
components={{
b: <strong className="whitespace-nowrap" />,
}}
/>
</p>
<Input
autoFocus={true}
size="lg"
className="w-full"
placeholder={t("verificationCodePlaceholder")}
{...register("code", {
validate: requiredString,
})}
/>
{formState.errors.code?.message ? (
<p className="mb-4 mt-2 text-sm text-rose-500">
{formState.errors.code.message}
</p>
) : null}
<p className="mt-2 text-sm text-gray-500">
{t("verificationCodeHelp")}
</p>
</fieldset>
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
<Button
loading={formState.isSubmitting || formState.isSubmitSuccessful}
type="submit"
size="lg"
variant="primary"
>
{t("continue")}
</Button>
</div>
</form>
</div>
);
};

View file

@ -1,22 +0,0 @@
import React from "react";
import { Logo } from "@/components/logo";
export const AuthCard = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<Logo />
</div>
<div className="p-4 sm:p-6">{children}</div>
</div>
);
};
export const AuthFooter = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="mt-4 flex flex-col gap-2 text-sm text-gray-500">
{children}
</div>
);
};

View file

@ -0,0 +1,30 @@
import { Input } from "@rallly/ui/input";
import React from "react";
const InputOTP = React.forwardRef<
HTMLInputElement,
React.ComponentProps<typeof Input> & { onValidCode?: (code: string) => void }
>(({ onValidCode, onChange, ...rest }, ref) => {
return (
<Input
ref={ref}
{...rest}
onChange={(e) => {
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 };

View file

@ -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 (
<Image
priority={true}
className="mx"
className={className}
src="/static/logo.svg"
width={sizes[size].width}
height={sizes[size].height}
style={{
width: sizes[size].width,
height: "auto",
}}
width={0}
height={0}
alt="Rallly"
/>
);

View file

@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger asChild className={className}>
<Button>
<Button variant="ghost">
<Icon>
<GlobeIcon />
</Icon>
<SelectValue />
</Button>
</SelectTrigger>

View file

@ -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 });

View file

@ -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"

View file

@ -0,0 +1,14 @@
"use client";
import dayjs from "dayjs";
import { Trans } from "@/components/trans";
export function RelativeDate({ date }: { date: Date }) {
return (
<Trans
i18nKey="createdTime"
defaults="Created {relativeTime}"
values={{ relativeTime: dayjs(date).fromNow() }}
/>
);
}

View file

@ -0,0 +1,2 @@
export const isQuickCreateEnabled =
process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";

View file

@ -0,0 +1 @@
export { isQuickCreateEnabled } from "./constants";

View file

@ -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;
}

View file

@ -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 (
<Button className="rounded-full" asChild>
<Link href="/quick-create">
<Icon>
<ZapIcon className="size-4" />
</Icon>
<Trans t={t} ns="app" i18nKey="quickCreate" defaults="Quick Create" />
</Link>
</Button>
);
}

View file

@ -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 (
<div className="space-y-8">
<div className="space-y-6">
<div className="text-primary inline-flex items-center justify-center gap-2 rounded-md font-medium">
<ZapIcon className="size-5" />
<h2>
<Trans
t={t}
ns="app"
i18nKey="quickActionCreate"
defaults="Quick Create"
/>
</h2>
</div>
<p className="text-muted-foreground text-pretty">
<Trans
t={t}
ns="app"
i18nKey="quickActionsDescription"
defaults="Create a group poll without signing in. Login later to link it to your account."
/>
</p>
{polls.length > 0 ? (
<div className="space-y-4">
<h3 className="font-semibold">
<Trans
t={t}
ns="app"
i18nKey="quickCreateRecentlyCreated"
defaults="Recently Created"
/>
</h3>
<ul className="space-y-2">
{polls.map((poll) => (
<li key={poll.id}>
<Link
href={`/poll/${poll.id}`}
className="flex items-center gap-3 rounded-xl border bg-white p-3 hover:bg-gray-50 active:bg-gray-100"
>
<div>
<GroupPollIcon size="lg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
<Link href={`/poll/${poll.id}`}>{poll.title}</Link>
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
<RelativeDate date={poll.createdAt} />
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
) : null}
<div>
<Button asChild size="lg" className="w-full">
<Link href="/new">
<Icon size="lg">
<PlusIcon />
</Icon>
<Trans
t={t}
ns="app"
i18nKey="quickCreateGroupPoll"
defaults="Create Group Poll"
/>
</Link>
</Button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold">
<Trans
t={t}
ns="app"
i18nKey="quickCreateWhyCreateAnAccount"
defaults="Why create an account?"
/>
</h3>
</div>
<ul className="text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Icon variant="success" size="lg">
<CheckIcon />
</Icon>
<Trans
t={t}
ns="app"
i18nKey="quickCreateSecurePolls"
defaults="Store polls securely in your account"
/>
</li>
<li className="flex items-center gap-2">
<Icon variant="success" size="lg">
<CheckIcon />
</Icon>
<Trans
t={t}
ns="app"
i18nKey="quickCreateGetNotifications"
defaults="Get email notifications"
/>
</li>
<li className="flex items-center gap-2">
<Icon variant="success" size="lg">
<CheckIcon />
</Icon>
<Trans
t={t}
ns="app"
i18nKey="quickCreateManagePollsFromAnyDevice"
defaults="Manage your polls from any device"
/>
</li>
</ul>
</div>
</div>
);
}

View file

@ -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),

View file

@ -8,20 +8,40 @@ 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|register)/.test(newUrl.pathname) &&
req.nextauth.token?.email
) {
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 (
!isLoggedIn &&
!publicRoutes.some((route) => newUrl.pathname.startsWith(route))
) {
newUrl.searchParams.set("callbackUrl", newUrl.pathname);
newUrl.pathname = "/login";
return NextResponse.redirect(newUrl);
}
// Check if locale is specified in cookie
let locale = req.nextauth.token?.locale;
if (locale && supportedLocales.includes(locale)) {
@ -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) {

View file

@ -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;

View file

@ -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<AppRouter> = () => {
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",
}),

View file

@ -7,7 +7,6 @@ export type TRPCContext = {
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
image?: string;
email?: string;
};
ip?: string;
};

View file

@ -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(),
}),
)

View file

@ -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

View file

@ -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<PollStatus, number>,
);
}),
infiniteList: possiblyPublicProcedure
infiniteList: privateProcedure
.input(
z.object({
status: z.enum(["all", "live", "paused", "finalized"]),

View file

@ -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"),

View file

@ -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,
},
});

View file

@ -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();
}),
);

View file

@ -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];
}

View file

@ -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 page.getByText("Finish Registering").waitFor();
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
await codeInput.fill(code);
await page.getByRole("button", { name: "Continue", exact: true }).click();
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();
});
});

View file

@ -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");
});
});

View file

@ -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...",

View file

@ -2,4 +2,6 @@ import languages from "./languages.json";
export const supportedLngs = Object.keys(languages);
export const defaultLocale = "en";
export default languages;

View file

@ -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: {

View file

@ -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 (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-300",
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
x={x}
y={y}
>
<circle id="pattern-circle" cx={cx} cy={cy} r={cr} />
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
</svg>
);
}
export default DotPattern;

View file

@ -82,12 +82,12 @@ const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
const { formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
className={cn("text-sm font-medium", className)}
htmlFor={formItemId}
{...props}
/>

View file

@ -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",

View file

@ -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<

View file

@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -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);