mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-19 11:01:59 +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",
|
"12h": "12-hour",
|
||||||
"24h": "24-hour",
|
"24h": "24-hour",
|
||||||
"addTimeOption": "Add time option",
|
"addTimeOption": "Add time option",
|
||||||
"alreadyRegistered": "Already registered? <a>Login</a>",
|
|
||||||
"applyToAllDates": "Apply to all dates",
|
"applyToAllDates": "Apply to all dates",
|
||||||
"areYouSure": "Are you sure?",
|
"areYouSure": "Are you sure?",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
@ -39,7 +38,6 @@
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"locationPlaceholder": "Joe's Coffee Shop",
|
"locationPlaceholder": "Joe's Coffee Shop",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"loginWith": "Login with {provider}",
|
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?",
|
"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",
|
"titlePlaceholder": "Monthly Meetup",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"userAlreadyExists": "A user with that email already exists",
|
"userAlreadyExists": "A user with that email already exists",
|
||||||
"userNotFound": "A user with that email doesn't exist",
|
|
||||||
"validEmail": "Please enter a valid email",
|
"validEmail": "Please enter a valid email",
|
||||||
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
|
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
|
||||||
"verificationCodePlaceholder": "Enter your 6-digit code",
|
"verificationCodePlaceholder": "Enter your 6-digit code",
|
||||||
"verifyYourEmail": "Verify your email",
|
|
||||||
"startOfWeek": "Start of week",
|
"startOfWeek": "Start of week",
|
||||||
"weekView": "Week view",
|
"weekView": "Week view",
|
||||||
"wrongVerificationCode": "Your verification code is incorrect or has expired",
|
"wrongVerificationCode": "Your verification code is incorrect or has expired",
|
||||||
|
@ -174,7 +170,6 @@
|
||||||
"duplicateTitleLabel": "Title",
|
"duplicateTitleLabel": "Title",
|
||||||
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
|
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
|
||||||
"upgrade": "Upgrade",
|
"upgrade": "Upgrade",
|
||||||
"continueAsGuest": "Continue as Guest",
|
|
||||||
"scrollLeft": "Scroll Left",
|
"scrollLeft": "Scroll Left",
|
||||||
"scrollRight": "Scroll Right",
|
"scrollRight": "Scroll Right",
|
||||||
"shrink": "Shrink",
|
"shrink": "Shrink",
|
||||||
|
@ -200,7 +195,6 @@
|
||||||
"hideScoresLabel": "Hide scores until after a participant has voted",
|
"hideScoresLabel": "Hide scores until after a participant has voted",
|
||||||
"continueAs": "Continue as",
|
"continueAs": "Continue as",
|
||||||
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
||||||
"notRegistered": "Don't have an account? <a>Register</a>",
|
|
||||||
"unlockFeatures": "Unlock all Pro features.",
|
"unlockFeatures": "Unlock all Pro features.",
|
||||||
"pollStatusFinalized": "Finalized",
|
"pollStatusFinalized": "Finalized",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
|
@ -213,7 +207,6 @@
|
||||||
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
|
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
|
||||||
"inviteLink": "Invite Link",
|
"inviteLink": "Invite Link",
|
||||||
"inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll.",
|
"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.",
|
"accountNotLinkedDescription": "A user with this email already exists. Please log in using the original method.",
|
||||||
"or": "Or",
|
"or": "Or",
|
||||||
"autoTimeZone": "Automatic Time Zone Conversion",
|
"autoTimeZone": "Automatic Time Zone Conversion",
|
||||||
|
@ -234,7 +227,6 @@
|
||||||
"dangerZoneAccount": "Delete your account permanently. This action cannot be undone.",
|
"dangerZoneAccount": "Delete your account permanently. This action cannot be undone.",
|
||||||
"upgradePromptTitle": "Upgrade to Pro",
|
"upgradePromptTitle": "Upgrade to Pro",
|
||||||
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
|
"upgradeOverlaySubtitle3": "Unlock these feature by upgrading to a Pro plan.",
|
||||||
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
|
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"groupPoll": "Group Poll",
|
"groupPoll": "Group Poll",
|
||||||
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
|
"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.",
|
"emailChangeRequestSentDescription": "To complete the change, please check your email for a verification link.",
|
||||||
"profileEmailAddress": "Email Address",
|
"profileEmailAddress": "Email Address",
|
||||||
"profileEmailAddressDescription": "Your email address is used to log in to your account",
|
"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-6 rounded": size === "xs",
|
||||||
"size-8 rounded-md": size === "sm",
|
"size-8 rounded-md": size === "sm",
|
||||||
"size-9 rounded-md": size === "md",
|
"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 { useRouter } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
import { Logo } from "@/components/logo";
|
|
||||||
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
import { Skeleton } from "@/components/skeleton";
|
import { Skeleton } from "@/components/skeleton";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
@ -40,42 +39,37 @@ export const LoginPage = ({ magicLink, email }: PageProps) => {
|
||||||
const { data } = trpc.user.getByEmail.useQuery({ email });
|
const { data } = trpc.user.getByEmail.useQuery({ email });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-4">
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
<div className="mb-6">
|
<div className="w-48 space-y-8 text-center">
|
||||||
<Logo />
|
<h1 className="text-xl font-bold">
|
||||||
</div>
|
<Trans i18nKey="continueAs" defaults="Continue as" />
|
||||||
|
</h1>
|
||||||
<div className="shadow-huge rounded-md bg-white p-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="w-48 text-center">
|
<OptimizedAvatarImage
|
||||||
<div className="mb-4 font-semibold">
|
src={data?.image ?? undefined}
|
||||||
<Trans i18nKey="continueAs" defaults="Continue as" />
|
name={data?.name ?? ""}
|
||||||
</div>
|
size="xl"
|
||||||
<div className="flex flex-col items-center gap-2">
|
/>
|
||||||
<OptimizedAvatarImage
|
<div>
|
||||||
src={data?.image ?? undefined}
|
<div className="mb-1 h-6 font-medium">
|
||||||
name={data?.name ?? ""}
|
{data?.name ?? <Skeleton className="inline-block h-5 w-16" />}
|
||||||
size="xl"
|
</div>
|
||||||
/>
|
<div className="text-muted-foreground h-5 truncate text-sm">
|
||||||
<div className="text-center">
|
{data?.email ?? <Skeleton className="inline-block h-full w-20" />}
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
size="lg"
|
||||||
loading={magicLinkFetch.isLoading}
|
loading={magicLinkFetch.isLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await magicLinkFetch.mutateAsync();
|
await magicLinkFetch.mutateAsync();
|
||||||
}}
|
}}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="mt-6 w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Trans i18nKey="continue" />
|
<Trans i18nKey="login" defaults="Login" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
||||||
|
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
import { LoginPage } from "./login-page";
|
import { LoginPage } from "./components/login-page";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 (
|
return (
|
||||||
<div className="h-full p-3 sm:p-8">
|
<div className="relative flex h-screen flex-col items-center justify-center bg-gray-100 p-2 lg:p-4">
|
||||||
<div className="mx-auto max-w-lg">{children}</div>
|
<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>
|
</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() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-72 items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Spinner className="text-muted-foreground" />
|
<Spinner className="text-muted-foreground" />
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
|
import { getProviders } from "next-auth/react";
|
||||||
import { Trans } from "react-i18next/TransWithoutContext";
|
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";
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
export default async function LoginPage({ params }: { params: Params }) {
|
import {
|
||||||
const { t } = await getTranslation(params.locale);
|
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 (
|
return (
|
||||||
<div>
|
<AuthPageContainer>
|
||||||
<AuthCard>
|
<AuthPageHeader>
|
||||||
<LoginForm />
|
<AuthPageTitle>
|
||||||
</AuthCard>
|
<Trans t={t} ns="app" i18nKey="loginTitle" defaults="Welcome" />
|
||||||
<div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
|
</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
|
<Trans
|
||||||
t={t}
|
t={t}
|
||||||
i18nKey="notRegistered"
|
i18nKey="loginFooter"
|
||||||
defaults="Don't have an account? <a>Register</a>"
|
defaults="Don't have an account? <a>Sign up</a>"
|
||||||
components={{
|
components={{
|
||||||
a: <Link href="/register" className="text-link" />,
|
a: <Link className="text-link" href="/register" />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AuthPageExternal>
|
||||||
</div>
|
</AuthPageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }) {
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
const { t } = await getTranslation(params.locale);
|
const { t } = await getTranslation(params.locale);
|
||||||
return {
|
return {
|
||||||
title: t("login"),
|
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 Link from "next/link";
|
||||||
import type { Params } from "@/app/[locale]/types";
|
import { Trans } from "react-i18next/TransWithoutContext";
|
||||||
|
|
||||||
import { getTranslation } from "@/i18n/server";
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
|
||||||
export default async function Page() {
|
import {
|
||||||
return <RegisterForm />;
|
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);
|
const { t } = await getTranslation(params.locale);
|
||||||
return {
|
return {
|
||||||
title: t("register"),
|
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,
|
isGuest: session.user.email === null,
|
||||||
locale: session.user.locale ?? undefined,
|
locale: session.user.locale ?? undefined,
|
||||||
image: session.user.image ?? undefined,
|
image: session.user.image ?? undefined,
|
||||||
email: session.user.email ?? undefined,
|
|
||||||
getEmailClient: () =>
|
getEmailClient: () =>
|
||||||
getEmailClient(session.user?.locale ?? undefined),
|
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";
|
import { PostHogPageView } from "./posthog-page-view";
|
||||||
|
|
||||||
export const trpc = createTRPCReact<AppRouter>({
|
export const trpc = createTRPCReact<AppRouter>({
|
||||||
unstable_overrides: {
|
overrides: {
|
||||||
useMutation: {
|
useMutation: {
|
||||||
async onSuccess(opts) {
|
async onSuccess(opts) {
|
||||||
await opts.originalFn();
|
await opts.originalFn();
|
||||||
|
|
|
@ -159,6 +159,7 @@ if (
|
||||||
) {
|
) {
|
||||||
providers.push(
|
providers.push(
|
||||||
AzureADProvider({
|
AzureADProvider({
|
||||||
|
name: "Microsoft",
|
||||||
tenantId: process.env.MICROSOFT_TENANT_ID,
|
tenantId: process.env.MICROSOFT_TENANT_ID,
|
||||||
clientId: process.env.MICROSOFT_CLIENT_ID,
|
clientId: process.env.MICROSOFT_CLIENT_ID,
|
||||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
|
||||||
|
@ -185,6 +186,7 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
providers: providers,
|
providers: providers,
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
|
verifyRequest: "/login/verify",
|
||||||
error: "/auth/error",
|
error: "/auth/error",
|
||||||
},
|
},
|
||||||
events: {
|
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 = {
|
const sizes = {
|
||||||
sm: {
|
sm: {
|
||||||
width: 120,
|
width: 140,
|
||||||
height: 22,
|
height: 22,
|
||||||
},
|
},
|
||||||
md: {
|
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 (
|
return (
|
||||||
<Image
|
<Image
|
||||||
priority={true}
|
priority={true}
|
||||||
className="mx"
|
className={className}
|
||||||
src="/static/logo.svg"
|
src="/static/logo.svg"
|
||||||
width={sizes[size].width}
|
style={{
|
||||||
height={sizes[size].height}
|
width: sizes[size].width,
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
width={0}
|
||||||
|
height={0}
|
||||||
alt="Rallly"
|
alt="Rallly"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import languages from "@rallly/languages";
|
import languages from "@rallly/languages";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
@ -7,6 +8,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@rallly/ui/select";
|
} from "@rallly/ui/select";
|
||||||
|
import { GlobeIcon } from "lucide-react";
|
||||||
|
|
||||||
export const LanguageSelect: React.FunctionComponent<{
|
export const LanguageSelect: React.FunctionComponent<{
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -16,7 +18,10 @@ export const LanguageSelect: React.FunctionComponent<{
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onChange}>
|
<Select value={value} onValueChange={onChange}>
|
||||||
<SelectTrigger asChild className={className}>
|
<SelectTrigger asChild className={className}>
|
||||||
<Button>
|
<Button variant="ghost">
|
||||||
|
<Icon>
|
||||||
|
<GlobeIcon />
|
||||||
|
</Icon>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</Button>
|
</Button>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { usePostHog } from "@rallly/posthog/client";
|
import { usePostHog } from "@rallly/posthog/client";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Spinner } from "@/components/spinner";
|
import { Spinner } from "@/components/spinner";
|
||||||
|
@ -107,10 +107,9 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
|
||||||
},
|
},
|
||||||
refresh: session.update,
|
refresh: session.update,
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await fetch("/api/logout", { method: "POST" });
|
await signOut();
|
||||||
posthog?.capture("logout");
|
posthog?.capture("logout");
|
||||||
posthog?.reset();
|
posthog?.reset();
|
||||||
window.location.href = "/login";
|
|
||||||
},
|
},
|
||||||
ownsObject: (resource) => {
|
ownsObject: (resource) => {
|
||||||
return isOwner(resource, { id: user.id, isGuest });
|
return isOwner(resource, { id: user.id, isGuest });
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const UserAvatar = ({
|
||||||
"size-5 text-[10px]": size === "xs",
|
"size-5 text-[10px]": size === "xs",
|
||||||
"size-6 text-sm": size === "sm",
|
"size-6 text-sm": size === "sm",
|
||||||
"size-8 text-base": size === "md",
|
"size-8 text-base": size === "md",
|
||||||
"size-10 text-lg": size === "lg",
|
"size-12 text-lg": size === "lg",
|
||||||
},
|
},
|
||||||
!name
|
!name
|
||||||
? "bg-gray-200"
|
? "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 { defaultNS } from "@/i18n/settings";
|
||||||
|
import { getLocaleFromPath } from "@/utils/locale/get-locale-from-path";
|
||||||
|
|
||||||
import { initI18next } from "./i18n";
|
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);
|
const i18nextInstance = await initI18next(locale, defaultNS);
|
||||||
return {
|
return {
|
||||||
t: i18nextInstance.getFixedT(locale, defaultNS),
|
t: i18nextInstance.getFixedT(locale, defaultNS),
|
||||||
|
|
|
@ -8,20 +8,40 @@ import { isSelfHosted } from "@/utils/constants";
|
||||||
|
|
||||||
const supportedLocales = Object.keys(languages);
|
const supportedLocales = Object.keys(languages);
|
||||||
|
|
||||||
|
const publicRoutes = [
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/invite/",
|
||||||
|
"/new",
|
||||||
|
"/poll/",
|
||||||
|
"/quick-create",
|
||||||
|
"/auth/login",
|
||||||
|
];
|
||||||
|
|
||||||
export const middleware = withAuth(
|
export const middleware = withAuth(
|
||||||
async function middleware(req) {
|
async function middleware(req) {
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = req;
|
||||||
const newUrl = nextUrl.clone();
|
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 the user is already logged in, don't let them access the login page
|
||||||
if (
|
if (/^\/(login)/.test(newUrl.pathname) && isLoggedIn) {
|
||||||
/^\/(login|register)/.test(newUrl.pathname) &&
|
|
||||||
req.nextauth.token?.email
|
|
||||||
) {
|
|
||||||
newUrl.pathname = "/";
|
newUrl.pathname = "/";
|
||||||
return NextResponse.redirect(newUrl);
|
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
|
// Check if locale is specified in cookie
|
||||||
let locale = req.nextauth.token?.locale;
|
let locale = req.nextauth.token?.locale;
|
||||||
if (locale && supportedLocales.includes(locale)) {
|
if (locale && supportedLocales.includes(locale)) {
|
||||||
|
@ -34,7 +54,7 @@ export const middleware = withAuth(
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = NextResponse.rewrite(newUrl);
|
const res = NextResponse.rewrite(newUrl);
|
||||||
|
res.headers.set("x-pathname", newUrl.pathname);
|
||||||
const jwt = await initGuest(req, res);
|
const jwt = await initGuest(req, res);
|
||||||
|
|
||||||
if (jwt?.sub) {
|
if (jwt?.sub) {
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
html {
|
html {
|
||||||
@apply h-full font-sans text-base text-gray-700;
|
@apply h-full font-sans text-base text-gray-700;
|
||||||
}
|
}
|
||||||
body #__next {
|
body,
|
||||||
|
#__next {
|
||||||
@apply h-full;
|
@apply h-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +24,10 @@
|
||||||
@apply block text-sm;
|
@apply block text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@apply leading-normal;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
|
@ -34,7 +39,7 @@
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.text-link {
|
.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 {
|
.formField {
|
||||||
@apply mb-4;
|
@apply mb-4;
|
||||||
|
|
|
@ -1,10 +1,34 @@
|
||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import { MutationCache } from "@tanstack/react-query";
|
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 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 = {
|
export const trpcConfig = {
|
||||||
links: [
|
links: [
|
||||||
|
errorHandlingLink,
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: "/api/trpc",
|
url: "/api/trpc",
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -7,7 +7,6 @@ export type TRPCContext = {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
getEmailClient: (locale?: string) => EmailClient;
|
getEmailClient: (locale?: string) => EmailClient;
|
||||||
image?: string;
|
image?: string;
|
||||||
email?: string;
|
|
||||||
};
|
};
|
||||||
ip?: string;
|
ip?: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,11 +10,26 @@ import { publicProcedure, rateLimitMiddleware, router } from "../trpc";
|
||||||
import type { RegistrationTokenPayload } from "../types";
|
import type { RegistrationTokenPayload } from "../types";
|
||||||
|
|
||||||
export const auth = router({
|
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
|
requestRegistration: publicProcedure
|
||||||
.use(rateLimitMiddleware)
|
.use(rateLimitMiddleware)
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().nonempty().max(100),
|
name: z.string().min(1).max(100),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { prisma } from "@rallly/database";
|
import { prisma } from "@rallly/database";
|
||||||
|
|
||||||
import { possiblyPublicProcedure, router } from "../trpc";
|
import { privateProcedure, router } from "../trpc";
|
||||||
|
|
||||||
export const dashboard = router({
|
export const dashboard = router({
|
||||||
info: possiblyPublicProcedure.query(async ({ ctx }) => {
|
info: privateProcedure.query(async ({ ctx }) => {
|
||||||
const activePollCount = await prisma.poll.count({
|
const activePollCount = await prisma.poll.count({
|
||||||
where: {
|
where: {
|
||||||
...(ctx.user.isGuest
|
...(ctx.user.isGuest
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { getEmailClient } from "@/utils/emails";
|
||||||
import { getTimeZoneAbbreviation } from "../../utils/date";
|
import { getTimeZoneAbbreviation } from "../../utils/date";
|
||||||
import {
|
import {
|
||||||
possiblyPublicProcedure,
|
possiblyPublicProcedure,
|
||||||
|
privateProcedure,
|
||||||
proProcedure,
|
proProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
rateLimitMiddleware,
|
rateLimitMiddleware,
|
||||||
|
@ -41,7 +42,7 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
|
||||||
export const polls = router({
|
export const polls = router({
|
||||||
participants,
|
participants,
|
||||||
comments,
|
comments,
|
||||||
getCountByStatus: possiblyPublicProcedure.query(async ({ ctx }) => {
|
getCountByStatus: privateProcedure.query(async ({ ctx }) => {
|
||||||
const res = await prisma.poll.groupBy({
|
const res = await prisma.poll.groupBy({
|
||||||
by: ["status"],
|
by: ["status"],
|
||||||
where: {
|
where: {
|
||||||
|
@ -61,7 +62,7 @@ export const polls = router({
|
||||||
{} as Record<PollStatus, number>,
|
{} as Record<PollStatus, number>,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
infiniteList: possiblyPublicProcedure
|
infiniteList: privateProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
status: z.enum(["all", "live", "paused", "finalized"]),
|
status: z.enum(["all", "live", "paused", "finalized"]),
|
||||||
|
|
|
@ -5,14 +5,14 @@ import toArray from "dayjs/plugin/toArray";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { possiblyPublicProcedure, router } from "../trpc";
|
import { privateProcedure, router } from "../trpc";
|
||||||
|
|
||||||
dayjs.extend(toArray);
|
dayjs.extend(toArray);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
export const scheduledEvents = router({
|
export const scheduledEvents = router({
|
||||||
list: possiblyPublicProcedure
|
list: privateProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
period: z.enum(["upcoming", "past"]).default("upcoming"),
|
period: z.enum(["upcoming", "past"]).default("upcoming"),
|
||||||
|
|
|
@ -25,7 +25,7 @@ const mimeToExtension = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const user = router({
|
export const user = router({
|
||||||
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
|
getBilling: privateProcedure.query(async ({ ctx }) => {
|
||||||
return await prisma.userPaymentData.findUnique({
|
return await prisma.userPaymentData.findUnique({
|
||||||
select: {
|
select: {
|
||||||
subscriptionId: true,
|
subscriptionId: true,
|
||||||
|
@ -126,6 +126,18 @@ export const user = router({
|
||||||
.use(rateLimitMiddleware)
|
.use(rateLimitMiddleware)
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: z.string().email() }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.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
|
// check if the email is already in use
|
||||||
const existingUser = await prisma.user.count({
|
const existingUser = await prisma.user.count({
|
||||||
where: { email: input.email },
|
where: { email: input.email },
|
||||||
|
@ -141,7 +153,7 @@ export const user = router({
|
||||||
// create a verification token
|
// create a verification token
|
||||||
const token = await createToken(
|
const token = await createToken(
|
||||||
{
|
{
|
||||||
fromEmail: ctx.user.email,
|
fromEmail: currentUser.email,
|
||||||
toEmail: input.email,
|
toEmail: input.email,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -155,7 +167,7 @@ export const user = router({
|
||||||
verificationUrl: absoluteUrl(
|
verificationUrl: absoluteUrl(
|
||||||
`/api/user/verify-email-change?token=${token}`,
|
`/api/user/verify-email-change?token=${token}`,
|
||||||
),
|
),
|
||||||
fromEmail: ctx.user.email,
|
fromEmail: currentUser.email,
|
||||||
toEmail: input.email,
|
toEmail: input.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -64,22 +64,14 @@ export const proProcedure = t.procedure.use(
|
||||||
|
|
||||||
export const privateProcedure = t.procedure.use(
|
export const privateProcedure = t.procedure.use(
|
||||||
middleware(async ({ ctx, next }) => {
|
middleware(async ({ ctx, next }) => {
|
||||||
const email = ctx.user.email;
|
if (ctx.user.isGuest !== false) {
|
||||||
if (!email) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Login is required",
|
message: "Login is required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return next({
|
return next();
|
||||||
ctx: {
|
|
||||||
user: {
|
|
||||||
...ctx.user,
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
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")
|
.getByPlaceholder("jessie.smith@example.com")
|
||||||
.fill(testUserEmail);
|
.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
|
// Make sure the user doesn't exist yet and that logging in is not possible
|
||||||
await expect(
|
await expect(
|
||||||
|
@ -51,7 +51,7 @@ test.describe.serial(() => {
|
||||||
test("user registration", async ({ page }) => {
|
test("user registration", async ({ page }) => {
|
||||||
await page.goto("/register");
|
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.getByPlaceholder("Jessie Smith").fill("Test User");
|
||||||
await page
|
await page
|
||||||
|
@ -60,15 +60,15 @@ test.describe.serial(() => {
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
|
|
||||||
|
|
||||||
const code = await getCode();
|
const code = await getCode();
|
||||||
|
|
||||||
|
await page.getByText("Finish Registering").waitFor();
|
||||||
|
|
||||||
|
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
|
||||||
|
|
||||||
await codeInput.fill(code);
|
await codeInput.fill(code);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await expect(page.getByText("Test User")).toBeVisible();
|
||||||
|
|
||||||
await page.waitForURL("/");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -76,7 +76,7 @@ test.describe.serial(() => {
|
||||||
test("can't register with the same email", async ({ page }) => {
|
test("can't register with the same email", async ({ page }) => {
|
||||||
await page.goto("/register");
|
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.getByPlaceholder("Jessie Smith").fill("Test User");
|
||||||
await page
|
await page
|
||||||
|
@ -97,7 +97,7 @@ test.describe.serial(() => {
|
||||||
.getByPlaceholder("jessie.smith@example.com")
|
.getByPlaceholder("jessie.smith@example.com")
|
||||||
.fill(testUserEmail);
|
.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);
|
const html = await captureEmailHTML(testUserEmail);
|
||||||
|
|
||||||
|
@ -111,13 +111,27 @@ test.describe.serial(() => {
|
||||||
|
|
||||||
await page.goto(magicLink);
|
await page.goto(magicLink);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||||
|
|
||||||
await page.waitForURL("/");
|
|
||||||
|
|
||||||
await expect(page.getByText("Test User")).toBeVisible();
|
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 }) => {
|
test("can login with verification code", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
|
|
||||||
|
@ -125,16 +139,12 @@ test.describe.serial(() => {
|
||||||
.getByPlaceholder("jessie.smith@example.com")
|
.getByPlaceholder("jessie.smith@example.com")
|
||||||
.fill(testUserEmail);
|
.fill(testUserEmail);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
await page.getByRole("button", { name: "Continue with Email" }).click();
|
||||||
|
|
||||||
const code = await getCode();
|
const code = await getCode();
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter your 6-digit code").fill(code);
|
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();
|
await expect(page.getByText("Test User")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -145,16 +155,12 @@ test.describe.serial(() => {
|
||||||
.getByPlaceholder("jessie.smith@example.com")
|
.getByPlaceholder("jessie.smith@example.com")
|
||||||
.fill("Test@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();
|
const code = await getCode();
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter your 6-digit code").fill(code);
|
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();
|
await expect(page.getByText("Test User")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -32,6 +32,6 @@ test.describe.serial(() => {
|
||||||
|
|
||||||
deletePollDialog.getByRole("button", { name: "delete" }).click();
|
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",
|
"dev:landing": "dotenv -c development turbo dev --filter=@rallly/landing",
|
||||||
"start": "turbo run start --filter=@rallly/web",
|
"start": "turbo run start --filter=@rallly/web",
|
||||||
"build": "dotenv -c -- turbo run build --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:landing": "turbo run build --filter=@rallly/landing",
|
||||||
"build:test": "turbo build:test",
|
"build:test": "turbo build:test",
|
||||||
"docs:dev": "turbo dev --filter=@rallly/docs...",
|
"docs:dev": "turbo dev --filter=@rallly/docs...",
|
||||||
|
|
|
@ -2,4 +2,6 @@ import languages from "./languages.json";
|
||||||
|
|
||||||
export const supportedLngs = Object.keys(languages);
|
export const supportedLngs = Object.keys(languages);
|
||||||
|
|
||||||
|
export const defaultLocale = "en";
|
||||||
|
|
||||||
export default languages;
|
export default languages;
|
||||||
|
|
|
@ -10,28 +10,27 @@ import { cn } from "./lib/utils";
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
"inline-flex border font-medium disabled:pointer-events-none select-none disabled:opacity-50 items-center justify-center whitespace-nowrap border",
|
"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-visible:ring-2 focus-visible:ring-ring",
|
||||||
"focus:shadow-none",
|
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary:
|
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:
|
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:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"focus:ring-offset-1 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
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",
|
link: "underline-offset-4 border-transparent hover:underline text-primary",
|
||||||
},
|
},
|
||||||
size: {
|
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",
|
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: {
|
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.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { error, formItemId } = useFormField();
|
const { formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(error && "text-destructive", className)}
|
className={cn("text-sm font-medium", className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -13,7 +13,7 @@ export type InputProps = Omit<
|
||||||
|
|
||||||
const inputVariants = cva(
|
const inputVariants = cva(
|
||||||
cn(
|
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",
|
"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: {
|
size: {
|
||||||
sm: "h-7 text-xs px-1",
|
sm: "h-7 text-xs px-1",
|
||||||
md: "h-9 text-sm px-2",
|
md: "h-9 text-sm px-2",
|
||||||
lg: "h-12 text-lg px-3",
|
lg: "h-12 text-base px-3",
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-primary-400 focus-visible:border-primary-400",
|
default: "border-primary-400 focus-visible:border-primary-400",
|
||||||
|
|
|
@ -8,7 +8,7 @@ import * as React from "react";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
const labelVariants = cva(
|
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<
|
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
Add a link
Reference in a new issue