mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 09:46:39 +02:00
✨ New Login Page (#1504)
This commit is contained in:
parent
655f38203a
commit
f5ab25ed1f
67 changed files with 1669 additions and 713 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -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>
|
|
@ -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";
|
||||
|
29
apps/web/src/app/[locale]/(auth)/components/auth-page.tsx
Normal file
29
apps/web/src/app/[locale]/(auth)/components/auth-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
21
apps/web/src/app/[locale]/(auth)/login/actions.ts
Normal file
21
apps/web/src/app/[locale]/(auth)/login/actions.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"),
|
||||
|
|
53
apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx
Normal file
53
apps/web/src/app/[locale]/(auth)/login/sso-providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
68
apps/web/src/app/[locale]/(auth)/login/verify/page.tsx
Normal file
68
apps/web/src/app/[locale]/(auth)/login/verify/page.tsx
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
12
apps/web/src/app/[locale]/(auth)/register/actions.ts
Normal file
12
apps/web/src/app/[locale]/(auth)/register/actions.ts
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
9
apps/web/src/app/[locale]/(auth)/register/loading.tsx
Normal file
9
apps/web/src/app/[locale]/(auth)/register/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
74
apps/web/src/app/[locale]/(auth)/register/verify/page.tsx
Normal file
74
apps/web/src/app/[locale]/(auth)/register/verify/page.tsx
Normal 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",
|
||||
}),
|
||||
};
|
||||
}
|
38
apps/web/src/app/[locale]/quick-create/page.tsx
Normal file
38
apps/web/src/app/[locale]/quick-create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
|
|
22
apps/web/src/app/components/user-language-switcher.tsx
Normal file
22
apps/web/src/app/components/user-language-switcher.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
30
apps/web/src/components/input-otp.tsx
Normal file
30
apps/web/src/components/input-otp.tsx
Normal 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 };
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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() }}
|
||||
/>
|
||||
);
|
||||
}
|
2
apps/web/src/features/quick-create/constants.ts
Normal file
2
apps/web/src/features/quick-create/constants.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const isQuickCreateEnabled =
|
||||
process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
|
1
apps/web/src/features/quick-create/index.ts
Normal file
1
apps/web/src/features/quick-create/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { isQuickCreateEnabled } from "./constants";
|
31
apps/web/src/features/quick-create/lib/get-guest-polls.ts
Normal file
31
apps/web/src/features/quick-create/lib/get-guest-polls.ts
Normal 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;
|
||||
}
|
21
apps/web/src/features/quick-create/quick-create-button.tsx
Normal file
21
apps/web/src/features/quick-create/quick-create-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
137
apps/web/src/features/quick-create/quick-create-widget.tsx
Normal file
137
apps/web/src/features/quick-create/quick-create-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
@ -7,7 +7,6 @@ export type TRPCContext = {
|
|||
locale?: string;
|
||||
getEmailClient: (locale?: string) => EmailClient;
|
||||
image?: string;
|
||||
email?: string;
|
||||
};
|
||||
ip?: string;
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]),
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
8
apps/web/src/utils/locale/get-locale-from-path.ts
Normal file
8
apps/web/src/utils/locale/get-locale-from-path.ts
Normal 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];
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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...",
|
||||
|
|
|
@ -2,4 +2,6 @@ import languages from "./languages.json";
|
|||
|
||||
export const supportedLngs = Object.keys(languages);
|
||||
|
||||
export const defaultLocale = "en";
|
||||
|
||||
export default languages;
|
||||
|
|
|
@ -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: {
|
||||
|
|
56
packages/ui/src/dot-pattern.tsx
Normal file
56
packages/ui/src/dot-pattern.tsx
Normal 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;
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<
|
||||
|
|
3
packages/utils/src/sleep.ts
Normal file
3
packages/utils/src/sleep.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
8
scripts/inject-version.js
Normal file
8
scripts/inject-version.js
Normal 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);
|
Loading…
Add table
Reference in a new issue