♻️ Switch to next-auth for handling authentication (#899)

This commit is contained in:
Luke Vella 2023-10-19 09:14:53 +01:00 committed by GitHub
parent 5f9e428432
commit 6fa66da681
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1514 additions and 1586 deletions

6
.env.test Normal file
View file

@ -0,0 +1,6 @@
PORT=3002
NEXT_PUBLIC_BASE_URL=http://localhost:3002
NEXTAUTH_URL=http://localhost:3002
SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890
DATABASE_URL=postgres://postgres:postgres@localhost:5432/rallly
SUPPORT_EMAIL=support@rallly.co

View file

@ -58,9 +58,7 @@ jobs:
- name: Set environment variables
run: |
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV
echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV
echo "SUPPORT_EMAIL=support@rallly.co" >> $GITHUB_ENV
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/rallly" >> $GITHUB_ENV
- name: Install dependencies
run: yarn install --frozen-lockfile
@ -68,7 +66,7 @@ jobs:
- name: Run db
run: |
docker pull postgres:14.2
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=db postgres:14.2
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=rallly postgres:14.2
yarn wait-on --timeout 60000 tcp:localhost:5432
- name: Deploy migrations

38
apps/web/declarations/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { TimeFormat } from "@rallly/database";
import { extend } from "lodash";
import NextAuth, { DefaultSession, DefaultUser } from "next-auth";
import { DefaultJWT, JWT } from "next-auth/jwt";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
timeZone?: string | null;
timeFormat?: TimeFormat | null;
locale?: string | null;
weekStart?: number | null;
};
}
interface User extends DefaultUser {
locale?: string | null;
timeZone?: string | null;
timeFormat?: TimeFormat | null;
weekStart?: number | null;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
locale?: string | null;
timeZone?: string | null;
timeFormat?: TimeFormat | null;
weekStart?: number | null;
}
}

View file

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -11,11 +11,12 @@
"lint:tsc": "tsc --noEmit",
"i18n:scan": "i18next-scanner --config i18next-scanner.config.js",
"prettier": "prettier --write ./src",
"test": "cross-env PORT=3001 playwright test",
"test": "playwright test",
"test:codegen": "playwright codegen http://localhost:3000",
"docker:start": "./scripts/docker-start.sh"
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.3",
"@floating-ui/react-dom-interactions": "^0.13.3",
"@headlessui/react": "^1.7.7",
"@hookform/resolvers": "^3.3.1",
@ -38,6 +39,7 @@
"class-variance-authority": "^0.6.0",
"cmdk": "^0.2.0",
"color-hash": "^2.0.2",
"cookie": "^0.5.0",
"crypto": "^1.0.1",
"dayjs": "^1.11.10",
"i18next": "^22.4.9",
@ -49,6 +51,7 @@
"lodash": "^4.17.21",
"micro": "^10.0.1",
"nanoid": "^4.0.0",
"next-auth": "^4.23.2",
"next-i18next": "^13.0.3",
"next-seo": "^5.15.0",
"php-serialize": "^4.1.1",

View file

@ -4,7 +4,7 @@ import path from "path";
const ci = process.env.CI === "true";
dotenv.config({ path: path.resolve(__dirname, "../../", ".env") });
dotenv.config({ path: path.resolve(__dirname, "../../", ".env.test") });
// Use process.env.PORT by default and fallback to port 3000
const PORT = process.env.PORT || 3000;
@ -12,8 +12,6 @@ const PORT = process.env.PORT || 3000;
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
const baseURL = `http://localhost:${PORT}`;
process.env.NEXT_PUBLIC_BASE_URL = baseURL;
// Reference: https://playwright.dev/docs/test-configuration
const config: PlaywrightTestConfig = {
// Artifacts folder where screenshots, videos, and traces are stored.
@ -31,6 +29,9 @@ const config: PlaywrightTestConfig = {
timeout: 120 * 1000,
reuseExistingServer: !ci,
},
expect: {
timeout: 10000, // 10 seconds
},
reporter: [
[ci ? "github" : "list"],
["html", { open: !ci ? "on-failure" : "never" }],

View file

@ -1,6 +1,8 @@
import { trpc } from "@rallly/backend";
import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { useRouter } from "next/router";
import { signIn, useSession } from "next-auth/react";
import { Trans, useTranslation } from "next-i18next";
import React from "react";
import { useForm } from "react-hook-form";
@ -13,32 +15,22 @@ import { TextInput } from "../text-input";
export const useDefaultEmail = createGlobalState("");
const VerifyCode: React.FunctionComponent<{
const verifyCode = async (options: { email: string; token: string }) => {
const res = await fetch(
"/api/auth/callback/email?" + new URLSearchParams(options),
);
return res.status === 200;
};
export const VerifyCode: React.FunctionComponent<{
email: string;
onSubmit: (code: string) => Promise<void>;
onResend: () => Promise<void>;
onChange: () => void;
}> = ({ onChange, onSubmit, email, onResend }) => {
}> = ({ onChange, onSubmit, email }) => {
const { register, handleSubmit, setError, formState } = useForm<{
code: string;
}>();
const { t } = useTranslation();
const [resendStatus, setResendStatus] = React.useState<
"ok" | "busy" | "disabled"
>("ok");
const handleResend = async () => {
setResendStatus("busy");
try {
await onResend();
setResendStatus("disabled");
setTimeout(() => {
setResendStatus("ok");
}, 1000 * 30);
} catch {
setResendStatus("ok");
}
};
return (
<div>
@ -110,184 +102,18 @@ const VerifyCode: React.FunctionComponent<{
>
{t("continue")}
</Button>
<Button
size="lg"
onClick={handleResend}
loading={resendStatus === "busy"}
disabled={resendStatus === "disabled"}
>
{t("resendVerificationCode")}
</Button>
</div>
</form>
</div>
);
};
type RegisterFormData = {
name: string;
email: string;
};
export const RegisterForm: React.FunctionComponent<{
onClickLogin?: React.MouseEventHandler;
onRegistered?: () => void;
}> = ({ onClickLogin, onRegistered }) => {
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
const { t } = useTranslation();
const { register, handleSubmit, getValues, setError, formState } =
useForm<RegisterFormData>({
defaultValues: { email: defaultEmail },
});
const queryClient = trpc.useContext();
const requestRegistration = trpc.auth.requestRegistration.useMutation();
const authenticateRegistration =
trpc.auth.authenticateRegistration.useMutation();
const [token, setToken] = React.useState<string>();
const posthog = usePostHog();
if (token) {
return (
<VerifyCode
onSubmit={async (code) => {
const res = await authenticateRegistration.mutateAsync({
token,
code,
});
if (!res.user) {
throw new Error("Failed to authenticate user");
}
queryClient.invalidate();
onRegistered?.();
posthog?.identify(res.user.id, {
email: res.user.email,
name: res.user.name,
});
posthog?.capture("register");
}}
onResend={async () => {
const values = getValues();
await requestRegistration.mutateAsync({
email: values.email,
name: values.name,
});
}}
onChange={() => setToken(undefined)}
email={getValues("email")}
/>
);
}
return (
<form
onSubmit={handleSubmit(async (data) => {
const res = await requestRegistration.mutateAsync({
email: data.email,
name: data.name,
});
if (!res.ok) {
switch (res.reason) {
case "userAlreadyExists":
setError("email", {
message: t("userAlreadyExists"),
});
break;
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
}
} else {
setToken(res.token);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("createAnAccount")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<TextInput
id="name"
className="w-full"
proportions="lg"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset className="mb-4">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<TextInput
className="w-full"
id="email"
proportions="lg"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<Button
loading={formState.isSubmitting}
type="submit"
variant="primary"
size="lg"
>
{t("continue")}
</Button>
<div className="mt-4 border-t pt-4 text-gray-500 sm:text-base">
<Trans
t={t}
i18nKey="alreadyRegistered"
components={{
a: (
<Link
href="/login"
className="text-link"
onClick={(e) => {
setDefaultEmail(getValues("email"));
onClickLogin?.(e);
}}
/>
),
}}
/>
</div>
</form>
);
};
export const LoginForm: React.FunctionComponent<{
onClickRegister?: (
e: React.MouseEvent<HTMLAnchorElement>,
email: string,
) => void;
onAuthenticated?: () => void;
}> = ({ onAuthenticated, onClickRegister }) => {
}> = ({ onClickRegister }) => {
const { t } = useTranslation();
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
@ -297,58 +123,44 @@ export const LoginForm: React.FunctionComponent<{
defaultValues: { email: defaultEmail },
});
const requestLogin = trpc.auth.requestLogin.useMutation();
const authenticateLogin = trpc.auth.authenticateLogin.useMutation();
const session = useSession();
const queryClient = trpc.useContext();
const [token, setToken] = React.useState<string>();
const [email, setEmail] = React.useState<string>();
const posthog = usePostHog();
const router = useRouter();
const callbackUrl = (router.query.callbackUrl as string) ?? "/";
if (token) {
const sendVerificationEmail = (email: string) => {
return signIn("email", {
redirect: false,
email,
callbackUrl,
});
};
if (email) {
return (
<VerifyCode
onSubmit={async (code) => {
const res = await authenticateLogin.mutateAsync({
code,
token,
const success = await verifyCode({
email,
token: code,
});
if (!res.user) {
if (!success) {
throw new Error("Failed to authenticate user");
} else {
onAuthenticated?.();
queryClient.invalidate();
posthog?.identify(res.user.id, {
email: res.user.email,
name: res.user.name,
});
posthog?.capture("login");
}
}}
onResend={async () => {
const values = getValues();
const res = await requestLogin.mutateAsync({
email: values.email,
});
if (res.ok) {
setToken(res.token);
} else {
switch (res.reason) {
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
break;
case "userNotFound":
setError("email", {
message: t("userNotFound"),
});
break;
const s = await session.update();
if (s?.user) {
posthog?.identify(s.user.id, {
email: s.user.email,
name: s.user.name,
});
}
posthog?.capture("login");
router.push(callbackUrl);
}
}}
onChange={() => setToken(undefined)}
onChange={() => setEmail(undefined)}
email={getValues("email")}
/>
);
@ -356,26 +168,15 @@ export const LoginForm: React.FunctionComponent<{
return (
<form
onSubmit={handleSubmit(async (data) => {
const res = await requestLogin.mutateAsync({
email: data.email,
});
onSubmit={handleSubmit(async ({ email }) => {
const res = await sendVerificationEmail(email);
if (res.ok) {
setToken(res.token);
if (res?.error) {
setError("email", {
message: t("userNotFound"),
});
} else {
switch (res.reason) {
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
break;
case "userNotFound":
setError("email", {
message: t("userNotFound"),
});
break;
}
setEmail(email);
}
})}
>

View file

@ -1,4 +1,3 @@
import { trpc } from "@rallly/backend";
import { GlobeIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import {
@ -19,36 +18,12 @@ import soft from "timezone-soft";
import { TimeFormatPicker } from "@/components/time-format-picker";
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans";
import { usePreferences } from "@/contexts/preferences";
import { useDayjs } from "@/utils/dayjs";
export const TimePreferences = () => {
const { timeZone, timeFormat } = useDayjs();
const queryClient = trpc.useContext();
const { data } = trpc.userPreferences.get.useQuery();
const updatePreferences = trpc.userPreferences.update.useMutation({
onMutate: (newPreferences) => {
queryClient.userPreferences.get.setData(undefined, (oldPreferences) => {
if (!oldPreferences) {
return null;
}
return {
...oldPreferences,
timeFormat: newPreferences.timeFormat ?? oldPreferences?.timeFormat,
timeZone: newPreferences.timeZone ?? oldPreferences?.timeZone ?? null,
weekStart: newPreferences.weekStart ?? oldPreferences?.weekStart,
};
});
},
onSuccess: () => {
queryClient.userPreferences.get.invalidate();
},
});
if (data === undefined) {
return null;
}
const { preferences, updatePreferences } = usePreferences();
const { timeFormat, timeZone } = useDayjs();
return (
<div className="grid gap-4">
@ -57,11 +32,9 @@ export const TimePreferences = () => {
<Trans i18nKey="timeZone" />
</Label>
<TimeZoneSelect
value={timeZone}
value={preferences.timeZone ?? timeZone}
onValueChange={(newTimeZone) => {
updatePreferences.mutate({
timeZone: newTimeZone,
});
updatePreferences({ timeZone: newTimeZone });
}}
/>
</div>
@ -70,11 +43,9 @@ export const TimePreferences = () => {
<Trans i18nKey="timeFormat" />
</Label>
<TimeFormatPicker
value={timeFormat}
value={preferences.timeFormat ?? timeFormat}
onChange={(newTimeFormat) => {
updatePreferences.mutate({
timeFormat: newTimeFormat,
});
updatePreferences({ timeFormat: newTimeFormat });
}}
/>
</div>

View file

@ -1,4 +1,3 @@
import { trpc } from "@rallly/backend";
import { HelpCircleIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
@ -7,6 +6,7 @@ import Script from "next/script";
import React from "react";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { isFeedbackEnabled } from "@/utils/constants";
const FeaturebaseScript = () => (
@ -63,7 +63,7 @@ export const FeaturebaseChangelog = ({ className }: { className?: string }) => {
};
export const FeaturebaseIdentify = () => {
const { data: user } = trpc.whoami.get.useQuery();
const { user } = useUser();
React.useEffect(() => {
if (user?.isGuest !== false || !isFeedbackEnabled) return;

View file

@ -1,14 +0,0 @@
import React from "react";
import { UserProvider } from "@/components/user-provider";
import { NextPageWithLayout } from "../../types";
export const NewPollLayout = ({ children }: React.PropsWithChildren) => {
return <UserProvider>{children}</UserProvider>;
};
export const getNewPolLayout: NextPageWithLayout["getLayout"] =
function getLayout(page) {
return <NewPollLayout>{page}</NewPollLayout>;
};

View file

@ -31,6 +31,7 @@ import {
TopBar,
TopBarTitle,
} from "@/components/layouts/standard-layout/top-bar";
import { LoginLink } from "@/components/login-link";
import {
PageDialog,
PageDialogDescription,
@ -157,7 +158,7 @@ const AdminControls = () => {
const router = useRouter();
return (
<TopBar>
<div className="flex flex-col items-start justify-between gap-y-2 gap-x-4 sm:flex-row">
<div className="flex flex-col items-start justify-between gap-x-4 gap-y-2 sm:flex-row">
<div className="flex min-w-0 gap-4">
{router.asPath !== pollLink ? (
<Button asChild>
@ -219,10 +220,10 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
<PageDialogFooter>
{user.isGuest ? (
<Button asChild variant="primary" size="lg">
<Link href="/login">
<LoginLink>
<LogInIcon className="-ml-1 h-5 w-5" />
<Trans i18nKey="login" defaults="Login" />
</Link>
</LoginLink>
</Button>
) : (
<Button asChild variant="primary" size="lg">
@ -271,7 +272,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
if (!poll.data || !watchers.data || !participants.data) {
return (
<div>
<TopBar className="flex flex-col items-start justify-between gap-y-2 gap-x-4 sm:flex-row">
<TopBar className="flex flex-col items-start justify-between gap-x-4 gap-y-2 sm:flex-row">
<Skeleton className="my-2 h-5 w-48" />
<div className="flex gap-x-2">
<Skeleton className="h-9 w-24" />

View file

@ -20,7 +20,7 @@ import { IfCloudHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { IconComponent, NextPageWithLayout } from "../../types";
import { useUser } from "../user-provider";
import { IfAuthenticated, useUser } from "../user-provider";
const MenuItem = (props: {
icon: IconComponent;
@ -79,9 +79,11 @@ export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
</div>
<Plan />
</div>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
<IfAuthenticated>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
</IfAuthenticated>
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" defaults="Preferences" />
</MenuItem>

View file

@ -15,6 +15,7 @@ import {
FeaturebaseIdentify,
} from "@/components/featurebase";
import FeedbackButton from "@/components/feedback";
import { LoginLink } from "@/components/login-link";
import { Logo } from "@/components/logo";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
@ -22,7 +23,7 @@ import { UserDropdown } from "@/components/user-dropdown";
import { IfCloudHosted } from "@/contexts/environment";
import { IfFreeUser } from "@/contexts/plan";
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
import { DayjsProvider } from "@/utils/dayjs";
import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { IconComponent, NextPageWithLayout } from "../../types";
import ModalProvider from "../modal/modal-provider";
@ -153,9 +154,9 @@ const MainNav = () => {
asChild
className="hidden sm:flex"
>
<Link href="/login">
<LoginLink>
<Trans i18nKey="login" defaults="Login" />
</Link>
</LoginLink>
</Button>
</IfGuest>
<IfCloudHosted>
@ -179,10 +180,9 @@ export const StandardLayout: React.FunctionComponent<{
hideNav?: boolean;
}> = ({ children, hideNav, ...rest }) => {
const key = hideNav ? "no-nav" : "nav";
return (
<UserProvider>
<DayjsProvider>
<ConnectedDayjsProvider>
<Toaster />
<ModalProvider>
<div className="flex min-h-screen flex-col" {...rest}>
@ -222,7 +222,7 @@ export const StandardLayout: React.FunctionComponent<{
</>
) : null}
</ModalProvider>
</DayjsProvider>
</ConnectedDayjsProvider>
</UserProvider>
);
};

View file

@ -0,0 +1,24 @@
import Link, { LinkProps } from "next/link";
import { useRouter } from "next/router";
import React from "react";
export const LoginLink = React.forwardRef<
HTMLAnchorElement,
React.PropsWithChildren<Omit<LinkProps, "href"> & { className?: string }>
>(function LoginLink({ children, ...props }, ref) {
const router = useRouter();
return (
<Link
ref={ref}
{...props}
href="/login"
onClick={async (e) => {
e.preventDefault();
props.onClick?.(e);
router.push("/login?callbackUrl=" + encodeURIComponent(router.asPath));
}}
>
{children}
</Link>
);
});

View file

@ -8,9 +8,7 @@ export const PageDialog = (
<Container className="flex h-[calc(75vh)] items-center justify-center">
<div className="text-center">
{props.icon ? (
<p className="text-primary text-base font-semibold">
<props.icon className="inline-block h-14 w-14" />
</p>
<props.icon className="text-primary inline-block h-14 w-14" />
) : null}
{props.children}
</div>
@ -19,16 +17,16 @@ export const PageDialog = (
};
export const PageDialogContent = (props: React.PropsWithChildren) => {
return <div className="mt-4 mb-6">{props.children}</div>;
return <div className="mb-6 mt-4">{props.children}</div>;
};
export const PageDialogHeader = (props: React.PropsWithChildren) => {
return <div className="mt-4 mb-6 space-y-2">{props.children}</div>;
return <div className="mb-6 mt-4 space-y-2">{props.children}</div>;
};
export const PageDialogFooter = (props: React.PropsWithChildren) => {
return (
<div className="mt-6 flex flex-col items-center justify-center gap-y-4 gap-x-4 sm:flex-row">
<div className="mt-6 flex flex-col items-center justify-center gap-x-4 gap-y-4 sm:flex-row">
{props.children}
</div>
);

View file

@ -2,7 +2,7 @@ import { trpc } from "@rallly/backend";
import { BellOffIcon, BellRingIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { useRouter } from "next/router";
import { signIn } from "next-auth/react";
import { useTranslation } from "next-i18next";
import * as React from "react";
@ -31,7 +31,6 @@ const NotificationsToggle: React.FunctionComponent = () => {
const posthog = usePostHog();
const router = useRouter();
const watch = trpc.polls.watch.useMutation({
onSuccess: () => {
// TODO (Luke Vella) [2023-04-08]: We should have a separate query for getting watchers
@ -70,8 +69,7 @@ const NotificationsToggle: React.FunctionComponent = () => {
className="flex items-center gap-2 px-2.5"
onClick={async () => {
if (user.isGuest) {
// TODO (Luke Vella) [2023-06-06]: Open Login Modal
router.push("/login");
signIn();
return;
}
// toggle

View file

@ -0,0 +1,26 @@
import Link, { LinkProps } from "next/link";
import { useRouter } from "next/router";
import React from "react";
export const RegisterLink = React.forwardRef<
HTMLAnchorElement,
React.PropsWithChildren<Omit<LinkProps, "href"> & { className?: string }>
>(function RegisterLink({ children, ...props }, ref) {
const router = useRouter();
return (
<Link
ref={ref}
{...props}
href="/register"
onClick={async (e) => {
e.preventDefault();
props.onClick?.(e);
router.push(
"/register?callbackUrl=" + encodeURIComponent(router.asPath),
);
}}
>
{children}
</Link>
);
});

View file

@ -1,56 +0,0 @@
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { useForm } from "react-hook-form";
import { TextInput } from "@/components/text-input";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
export const ChangeEmailForm = () => {
const { user } = useUser();
const form = useForm<{
email: string;
}>({
defaultValues: {
email: user.isGuest ? "" : user.email,
},
});
if (user.isGuest) {
return null;
}
return (
<Form {...form}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey="email" />
</FormLabel>
<FormControl>
<TextInput {...field} disabled={true} />
</FormControl>
</FormItem>
)}
/>
{/* <div className="mt-6 flex">
<Button
type="primary"
disabled={!form.formState.isDirty}
htmlType="submit"
>
<Trans i18nKey="save" />
</Button>
</div> */}
</Form>
);
};

View file

@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { trpc } from "@rallly/backend";
import { Button } from "@rallly/ui/button";
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
import {
@ -16,6 +15,7 @@ import { z } from "zod";
import { TimeFormatPicker } from "@/components/time-format-picker";
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans";
import { usePreferences } from "@/contexts/preferences";
import { useDayjs } from "@/utils/dayjs";
const formSchema = z.object({
@ -27,42 +27,25 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
const DateTimePreferencesForm = () => {
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
const { timeFormat, weekStart, timeZone, locale } = useDayjs();
const { preferences, updatePreferences } = usePreferences();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
timeFormat: preferences.timeFormat ?? timeFormat,
weekStart: preferences.weekStart ?? weekStart,
timeZone: preferences.timeZone ?? timeZone,
},
});
const { handleSubmit, formState } = form;
const queryClient = trpc.useContext();
const update = trpc.userPreferences.update.useMutation({
onSuccess: () => {
queryClient.userPreferences.get.invalidate();
},
});
const deleteUserPreferences = trpc.userPreferences.delete.useMutation({
onSuccess: () => {
queryClient.userPreferences.get.invalidate();
},
});
const {
timeFormat: localeTimeFormat,
weekStart: localeWeekStart,
timeZone,
locale,
} = useDayjs();
if (userPreferences === undefined) {
return null;
}
return (
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
await update.mutateAsync(data);
updatePreferences(data);
form.reset(data);
})}
>
@ -70,7 +53,6 @@ const DateTimePreferencesForm = () => {
<FormField
control={form.control}
name="timeZone"
defaultValue={userPreferences?.timeZone ?? timeZone}
render={({ field }) => {
return (
<FormItem>
@ -88,7 +70,6 @@ const DateTimePreferencesForm = () => {
<FormField
control={form.control}
name="timeFormat"
defaultValue={userPreferences?.timeFormat ?? localeTimeFormat}
render={({ field }) => {
return (
<FormItem>
@ -106,7 +87,6 @@ const DateTimePreferencesForm = () => {
<FormField
control={form.control}
name="weekStart"
defaultValue={userPreferences?.weekStart ?? localeWeekStart}
render={({ field }) => {
return (
<FormItem>
@ -144,12 +124,17 @@ const DateTimePreferencesForm = () => {
>
<Trans i18nKey="save" />
</Button>
{userPreferences !== null ? (
{preferences.timeFormat || preferences.weekStart ? (
<Button
loading={deleteUserPreferences.isLoading}
onClick={async () => {
await deleteUserPreferences.mutateAsync();
form.reset(locale);
updatePreferences({
weekStart: null,
timeFormat: null,
});
form.reset({
weekStart: locale.weekStart,
timeFormat: locale.timeFormat,
});
}}
>
<Trans

View file

@ -1,15 +1,17 @@
import { trpc } from "@rallly/backend";
import { ArrowUpRight } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
import Link from "next/link";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { LanguageSelect } from "@/components/poll/language-selector";
import { Trans } from "@/components/trans";
import { updateLanguage } from "@/contexts/preferences";
import { useUser } from "@/components/user-provider";
const formSchema = z.object({
language: z.string(),
@ -19,6 +21,7 @@ type FormData = z.infer<typeof formSchema>;
export const LanguagePreference = () => {
const { i18n } = useTranslation();
const { user } = useUser();
const router = useRouter();
const form = useForm<FormData>({
defaultValues: {
@ -26,11 +29,17 @@ export const LanguagePreference = () => {
},
});
const updatePreferences = trpc.user.updatePreferences.useMutation();
const session = useSession();
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
updateLanguage(data.language);
if (!user.isGuest) {
await updatePreferences.mutateAsync({ locale: data.language });
}
await session.update({ locale: data.language });
router.reload();
})}
>

View file

@ -1,4 +1,3 @@
import { trpc } from "@rallly/backend";
import {
Form,
FormControl,
@ -15,7 +14,7 @@ import { UserAvatar } from "@/components/user";
import { useUser } from "@/components/user-provider";
export const ProfileSettings = () => {
const { user } = useUser();
const { user, refresh } = useUser();
const form = useForm<{
name: string;
@ -23,7 +22,7 @@ export const ProfileSettings = () => {
}>({
defaultValues: {
name: user.isGuest ? "" : user.name,
email: user.isGuest ? "" : user.email,
email: user.email ?? "",
},
});
@ -31,18 +30,12 @@ export const ProfileSettings = () => {
const watchName = watch("name");
const queryClient = trpc.useContext();
const changeName = trpc.user.changeName.useMutation({
onSuccess: () => {
queryClient.whoami.invalidate();
},
});
return (
<div className="grid gap-y-4">
<Form {...form}>
<form
onSubmit={handleSubmit(async (data) => {
await changeName.mutateAsync({ name: data.name });
await refresh({ name: data.name });
reset(data);
})}
>

View file

@ -13,6 +13,7 @@ import { CommandList } from "cmdk";
import dayjs from "dayjs";
import { useTranslation } from "next-i18next";
import React from "react";
import spacetime from "spacetime";
import { Trans } from "@/components/trans";
@ -78,10 +79,68 @@ export const TimeZoneCommand = ({ onSelect, value }: TimeZoneCommandProps) => {
);
};
const findFuzzyTz = (zone: string) => {
let currentTime = spacetime.now("GMT");
try {
currentTime = spacetime.now(zone);
} catch (err) {
return;
}
const currentOffset = dayjs().tz(zone).utcOffset();
return options
.filter((tz) => {
const offset = dayjs().tz(tz.value).utcOffset();
return offset === currentOffset;
})
.map((tz) => {
let score = 0;
if (
currentTime.timezones[tz.value.toLowerCase()] &&
!!currentTime.timezones[tz.value.toLowerCase()].dst ===
currentTime.timezone().hasDst
) {
if (
tz.value
.toLowerCase()
.indexOf(
currentTime.tz.substring(currentTime.tz.indexOf("/") + 1),
) !== -1
) {
score += 8;
}
if (
tz.label
.toLowerCase()
.indexOf(
currentTime.tz.substring(currentTime.tz.indexOf("/") + 1),
) !== -1
) {
score += 4;
}
if (
tz.value
.toLowerCase()
.indexOf(currentTime.tz.substring(0, currentTime.tz.indexOf("/")))
) {
score += 2;
}
score += 1;
} else if (tz.value === "GMT") {
score += 1;
}
return { tz, score };
})
.sort((a, b) => b.score - a.score)
.map(({ tz }) => tz)[0];
};
export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
({ value, onValueChange, disabled }, ref) => {
const [open, setOpen] = React.useState(false);
const popoverContentId = "timeZoneSelect__popoverContent";
const fuzzyValue = value ? findFuzzyTz(value) : undefined;
return (
<Popover modal={false} open={open} onOpenChange={setOpen}>
@ -97,8 +156,8 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
>
<GlobeIcon className="h-4 w-4" />
<span className="grow truncate text-left">
{value ? (
options.find((option) => option.value === value)?.label
{fuzzyValue ? (
fuzzyValue.label
) : (
<Trans
i18nKey="timeZoneSelect__defaultValue"

View file

@ -22,7 +22,10 @@ import {
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { LoginLink } from "@/components/login-link";
import { RegisterLink } from "@/components/register-link";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user";
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
@ -60,12 +63,17 @@ export const UserDropdown = () => {
<Trans i18nKey="polls" defaults="Polls" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<Link href="/settings/profile" className="flex items-center gap-x-2">
<UserIcon className="h-4 w-4" />
<Trans i18nKey="profile" defaults="Profile" />
</Link>
</DropdownMenuItem>
<IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link
href="/settings/profile"
className="flex items-center gap-x-2"
>
<UserIcon className="h-4 w-4" />
<Trans i18nKey="profile" defaults="Profile" />
</Link>
</DropdownMenuItem>
</IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link
href="/settings/preferences"
@ -124,30 +132,36 @@ export const UserDropdown = () => {
<DropdownMenuSeparator />
<IfGuest>
<DropdownMenuItem asChild={true}>
<Link href="/login" className="flex items-center gap-x-2">
<LoginLink className="flex items-center gap-x-2">
<LogInIcon className="h-4 w-4" />
<Trans i18nKey="login" defaults="login" />
</Link>
</LoginLink>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<Link href="/register" className="flex items-center gap-x-2">
<RegisterLink className="flex items-center gap-x-2">
<UserPlusIcon className="h-4 w-4" />
<Trans i18nKey="createAnAccount" defaults="Register" />
</Link>
</RegisterLink>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<Link href="/logout" className="flex items-center gap-x-2">
<RefreshCcwIcon className="h-4 w-4" />
<Trans i18nKey="forgetMe" />
</Link>
<DropdownMenuItem
className="flex items-center gap-x-2"
onSelect={() =>
signOut({
redirect: false,
})
}
>
<RefreshCcwIcon className="h-4 w-4" />
<Trans i18nKey="forgetMe" />
</DropdownMenuItem>
</IfGuest>
<IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link href="/logout" className="flex items-center gap-x-2">
<LogOutIcon className="h-4 w-4" />
<Trans i18nKey="logout" />
</Link>
<DropdownMenuItem
className="flex items-center gap-x-2"
onSelect={() => signOut()}
>
<LogOutIcon className="h-4 w-4" />
<Trans i18nKey="logout" />
</DropdownMenuItem>
</IfAuthenticated>
</DropdownMenuContent>

View file

@ -1,16 +1,30 @@
import { trpc, UserSession } from "@rallly/backend";
import { trpc } from "@rallly/backend";
import Cookies from "js-cookie";
import { Session } from "next-auth";
import { signIn, useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import React from "react";
import { z } from "zod";
import { PostHogProvider } from "@/contexts/posthog";
import { useWhoAmI } from "@/contexts/whoami";
import { PreferencesProvider } from "@/contexts/preferences";
import { isSelfHosted } from "@/utils/constants";
import { useRequiredContext } from "./use-required-context";
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().nullable(),
isGuest: z.boolean(),
timeZone: z.string().nullish(),
timeFormat: z.enum(["hours12", "hours24"]).nullish(),
weekStart: z.number().min(0).max(6).nullish(),
});
export const UserContext = React.createContext<{
user: UserSession & { name: string };
refresh: () => void;
user: z.infer<typeof userSchema>;
refresh: (data?: Record<string, unknown>) => Promise<Session | null>;
ownsObject: (obj: { userId: string | null }) => boolean;
} | null>(null);
@ -46,66 +60,67 @@ export const IfGuest = (props: { children?: React.ReactNode }) => {
};
export const UserProvider = (props: { children?: React.ReactNode }) => {
const session = useSession();
const user = session.data?.user;
const { t } = useTranslation();
const queryClient = trpc.useContext();
const user = useWhoAmI();
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
React.useEffect(() => {
if (session.status === "unauthenticated") {
// Begin: Legacy token migration
const legacyToken = Cookies.get("legacy-token");
// It's important to remove the token from the cookies,
// otherwise when the user signs out.
if (legacyToken) {
Cookies.remove("legacy-token");
signIn("legacy-token", {
token: legacyToken,
});
return;
}
// End: Legacy token migration
signIn("guest");
}
}, [session.status]);
// TODO (Luke Vella) [2023-09-19]: Remove this when we have a better way to query for an active subscription
trpc.user.subscription.useQuery(undefined, {
enabled: !isSelfHosted,
});
const name = user
? user.isGuest === false
? user.name
: user.id.substring(0, 10)
: t("guest");
if (!user || userPreferences === undefined) {
if (!user || !session.data) {
return null;
}
return (
<UserContext.Provider
value={{
user: { ...user, name },
refresh: () => {
return queryClient.whoami.invalidate();
user: {
id: user.id as string,
name: user.name ?? t("guest"),
email: user.email || null,
isGuest: user.email === null,
},
refresh: session.update,
ownsObject: ({ userId }) => {
return userId ? [user.id].includes(userId) : false;
},
}}
>
<PostHogProvider>{props.children}</PostHogProvider>
<PreferencesProvider
initialValue={{
locale: user.locale ?? undefined,
timeZone: user.timeZone ?? undefined,
timeFormat: user.timeFormat ?? undefined,
weekStart: user.weekStart ?? undefined,
}}
onUpdate={async (newPreferences) => {
await session.update(newPreferences);
}}
>
<PostHogProvider>{props.children}</PostHogProvider>
</PreferencesProvider>
</UserContext.Provider>
);
};
type ParticipantOrComment = {
userId: string | null;
};
// eslint-disable-next-line @typescript-eslint/ban-types
export const withSession = <P extends {} = {}>(
component: React.ComponentType<P>,
) => {
const ComposedComponent: React.FunctionComponent<P> = (props: P) => {
const Component = component;
return (
<UserProvider>
<Component {...props} />
</UserProvider>
);
};
ComposedComponent.displayName = `withUser(${component.displayName})`;
return ComposedComponent;
};
/**
* @deprecated Stop using this function. All object
*/
export const isUnclaimed = (obj: ParticipantOrComment) => !obj.userId;

View file

@ -0,0 +1,16 @@
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
export const useLocale = () => {
const { locale, reload } = useRouter();
const session = useSession();
return {
locale: locale ?? "en",
updateLocale: (locale: string) => {
session.update({ locale });
reload();
},
};
};

View file

@ -1,45 +1,50 @@
import { trpc } from "@rallly/backend";
import Cookies from "js-cookie";
import { useTranslation } from "next-i18next";
import { TimeFormat } from "@rallly/database";
import React from "react";
import { useSetState } from "react-use";
import { getBrowserTimeZone } from "@/utils/date-time-utils";
import { useDayjs } from "@/utils/dayjs";
export const useSystemPreferences = () => {
const { i18n } = useTranslation();
const { timeFormat: localeTimeFormat, weekStart: localeTimeFormatWeekStart } =
useDayjs();
return {
language: i18n.language, // this should be the value detected in
timeFormat: localeTimeFormat,
weekStart: localeTimeFormatWeekStart,
timeZone: getBrowserTimeZone(),
} as const;
type Preferences = {
timeZone?: string | null;
locale?: string | null;
timeFormat?: TimeFormat | null;
weekStart?: number | null;
};
export const updateLanguage = (language: string) => {
Cookies.set("NEXT_LOCALE", language, {
expires: 30,
});
type PreferencesContextValue = {
preferences: Preferences;
updatePreferences: (preferences: Partial<Preferences>) => void;
};
export const useUserPreferences = () => {
const { data, isFetched } = trpc.userPreferences.get.useQuery(undefined, {
staleTime: Infinity,
cacheTime: Infinity,
});
const PreferencesContext = React.createContext<PreferencesContextValue>({
preferences: {},
updatePreferences: () => {},
});
const sytemPreferences = useSystemPreferences();
export const PreferencesProvider = ({
children,
initialValue,
onUpdate,
}: {
children?: React.ReactNode;
initialValue: Partial<Preferences>;
onUpdate?: (preferences: Partial<Preferences>) => void;
}) => {
const [preferences, setPreferences] = useSetState<Preferences>(initialValue);
// We decide the defaults by detecting the user's preferred locale from their browser
// by looking at the accept-language header.
if (isFetched) {
return {
automatic: data === null,
timeFormat: data?.timeFormat ?? sytemPreferences.timeFormat,
weekStart: data?.weekStart ?? sytemPreferences.weekStart,
timeZone: data?.timeZone ?? sytemPreferences.timeZone,
} as const;
}
return (
<PreferencesContext.Provider
value={{
preferences,
updatePreferences: (newPreferences) => {
setPreferences(newPreferences);
onUpdate?.(newPreferences);
},
}}
>
{children}
</PreferencesContext.Provider>
);
};
export const usePreferences = () => {
return React.useContext(PreferencesContext);
};

View file

@ -1,6 +0,0 @@
import { trpc } from "@rallly/backend";
export const useWhoAmI = () => {
const { data: whoAmI } = trpc.whoami.get.useQuery();
return whoAmI;
};

View file

@ -1,76 +1,70 @@
import { getSession } from "@rallly/backend/next/edge";
import languages from "@rallly/languages";
import languageParser from "accept-language-parser";
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import withAuth from "next-auth/middleware";
const supportedLocales = Object.keys(languages);
// these paths are always public
const publicPaths = ["/login", "/register", "/invite", "/auth"];
// these paths always require authentication
const protectedPaths = ["/settings/profile"];
export default withAuth(
function middleware(req) {
const { headers, nextUrl } = req;
const newUrl = nextUrl.clone();
const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => {
const session = await getSession(req, res);
const isGuest = session.user?.isGuest !== false;
// if the user is already logged in, don't let them access the login page
if (
/^\/(login|register)/.test(newUrl.pathname) &&
req.nextauth.token?.email
) {
newUrl.pathname = "/";
return NextResponse.redirect(newUrl);
}
if (!isGuest) {
// already logged in
return false;
}
// Check if locale is specified in cookie
const preferredLocale = req.nextauth.token?.locale;
if (preferredLocale && supportedLocales.includes(preferredLocale)) {
newUrl.pathname = `/${preferredLocale}${newUrl.pathname}`;
} else {
// Check if locale is specified in header
const acceptLanguageHeader = headers.get("accept-language");
// TODO (Luke Vella) [2023-09-11]: We should handle this on the client-side
if (process.env.NEXT_PUBLIC_SELF_HOSTED === "true") {
// when self-hosting, only public paths don't require login
return !publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath),
);
} else {
// when using the hosted version, only protected paths require login
return protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
}
};
if (acceptLanguageHeader) {
const locale = languageParser.pick(
supportedLocales,
acceptLanguageHeader,
);
export async function middleware(req: NextRequest) {
const { headers, cookies, nextUrl } = req;
const newUrl = nextUrl.clone();
const res = NextResponse.next();
const isLoginRequired = await checkLoginRequirements(req, res);
if (isLoginRequired) {
newUrl.pathname = "/login";
newUrl.searchParams.set("redirect", req.nextUrl.pathname);
return NextResponse.redirect(newUrl);
}
// Check if locale is specified in cookie
const localeCookie = cookies.get("NEXT_LOCALE");
const preferredLocale = localeCookie && localeCookie.value;
if (preferredLocale && supportedLocales.includes(preferredLocale)) {
newUrl.pathname = `/${preferredLocale}${newUrl.pathname}`;
return NextResponse.rewrite(newUrl);
} else {
// Check if locale is specified in header
const acceptLanguageHeader = headers.get("accept-language");
if (acceptLanguageHeader) {
const locale = languageParser.pick(
supportedLocales,
acceptLanguageHeader,
);
if (locale) {
newUrl.pathname = `/${locale}${newUrl.pathname}`;
return NextResponse.rewrite(newUrl);
if (locale) {
newUrl.pathname = `/${locale}${newUrl.pathname}`;
}
}
}
}
return res;
}
const res = NextResponse.rewrite(newUrl);
/**
* We moved from a bespoke session implementation to next-auth.
* This middleware looks for the old session cookie and moves it to
* a temporary cookie accessible to the client which will exchange it
* for a new session token with the legacy-token provider.
*/
const legacyToken = req.cookies.get("rallly-session");
if (legacyToken) {
res.cookies.set({
name: "legacy-token",
value: legacyToken.value,
});
res.cookies.delete("rallly-session");
}
return res;
},
{
secret: process.env.SECRET_PASSWORD,
callbacks: {
authorized: () => true, // needs to be true to allow access to all pages
},
},
);
export const config = {
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],

View file

@ -5,7 +5,7 @@ import React from "react";
import ErrorPage from "@/components/error-page";
import { getStandardLayout } from "@/components/layouts/standard-layout";
import { NextPageWithLayout } from "@/types";
import { withPageTranslations } from "@/utils/with-page-translations";
import { getStaticTranslations } from "@/utils/with-page-translations";
const Custom404: NextPageWithLayout = () => {
const { t } = useTranslation();
@ -20,6 +20,6 @@ const Custom404: NextPageWithLayout = () => {
Custom404.getLayout = getStandardLayout;
export const getStaticProps = withPageTranslations();
export const getStaticProps = getStaticTranslations;
export default Custom404;

View file

@ -2,7 +2,7 @@ import "react-big-calendar/lib/css/react-big-calendar.css";
import "tailwindcss/tailwind.css";
import "../style.css";
import { trpc, UserSession } from "@rallly/backend/next/trpc/client";
import { trpc } from "@rallly/backend/next/trpc/client";
import { TooltipProvider } from "@rallly/ui/tooltip";
import { domMax, LazyMotion } from "framer-motion";
import { NextPage } from "next";
@ -10,9 +10,9 @@ import { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import Script from "next/script";
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo";
import React from "react";
import Maintenance from "@/components/maintenance";
@ -25,12 +25,8 @@ const inter = Inter({
display: "swap",
});
type PageProps = {
user?: UserSession;
};
type AppPropsWithLayout = AppProps<PageProps> & {
Component: NextPageWithLayout<PageProps>;
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
@ -41,54 +37,56 @@ const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
const getLayout = Component.getLayout ?? ((page) => page);
return (
<LazyMotion features={domMax}>
<DefaultSeo
openGraph={{
siteName: "Rallly",
type: "website",
url: absoluteUrl(),
images: [
{
url: absoluteUrl("/og-image-1200.png"),
width: 1200,
height: 630,
alt: "Rallly | Schedule group meetings",
type: "image/png",
},
],
}}
facebook={{
appId: "920386682263077",
}}
/>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
/>
</Head>
{process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID ? (
<Script
src="https://cdn.paddle.com/paddle/paddle.js"
onLoad={() => {
if (process.env.NEXT_PUBLIC_PADDLE_SANDBOX === "true") {
window.Paddle.Environment.set("sandbox");
}
window.Paddle.Setup({
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
});
<SessionProvider>
<LazyMotion features={domMax}>
<DefaultSeo
openGraph={{
siteName: "Rallly",
type: "website",
url: absoluteUrl(),
images: [
{
url: absoluteUrl("/og-image-1200.png"),
width: 1200,
height: 630,
alt: "Rallly | Schedule group meetings",
type: "image/png",
},
],
}}
facebook={{
appId: "920386682263077",
}}
/>
) : null}
<style jsx global>{`
html {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
<TooltipProvider delayDuration={200}>
{getLayout(<Component {...pageProps} />)}
</TooltipProvider>
</LazyMotion>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
/>
</Head>
{process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID ? (
<Script
src="https://cdn.paddle.com/paddle/paddle.js"
onLoad={() => {
if (process.env.NEXT_PUBLIC_PADDLE_SANDBOX === "true") {
window.Paddle.Environment.set("sandbox");
}
window.Paddle.Setup({
vendor: Number(process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID),
});
}}
/>
) : null}
<style jsx global>{`
html {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
<TooltipProvider delayDuration={200}>
{getLayout(<Component {...pageProps} />)}
</TooltipProvider>
</LazyMotion>
</SessionProvider>
);
};

View file

@ -1,137 +1,39 @@
import { trpc } from "@rallly/backend";
import {
composeGetServerSideProps,
withSessionSsr,
} from "@rallly/backend/next";
import { prisma } from "@rallly/database";
import { ArrowRightIcon, ShieldCloseIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { GetStaticPaths, GetStaticProps } from "next";
import { getStandardLayout } from "@/components/layouts/standard-layout";
import {
PageDialog,
PageDialogContent,
PageDialogDescription,
PageDialogFooter,
PageDialogHeader,
PageDialogTitle,
} from "@/components/page-dialog";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar, UserAvatar } from "@/components/user";
import { useUser } from "@/components/user-provider";
import { NextPageWithLayout } from "@/types";
import { withPageTranslations } from "@/utils/with-page-translations";
const Page: NextPageWithLayout<{ userId: string; pollId: string }> = ({
pollId,
userId,
}) => {
const { user } = useUser();
const router = useRouter();
const transfer = trpc.polls.transfer.useMutation({
onSuccess: () => {
router.replace(`/poll/${pollId}`);
},
});
return (
<PageDialog icon={ShieldCloseIcon}>
<PageDialogHeader>
<PageDialogTitle>
<Trans i18nKey="differentOwner" defaults="Different Owner" />
</PageDialogTitle>
<PageDialogDescription>
<Trans
i18nKey="differentOwnerDescription"
defaults="This poll was created by a different user. Would you like to transfer ownership to the current user?"
/>
</PageDialogDescription>
</PageDialogHeader>
<PageDialogContent>
<div className="flex items-center justify-center gap-3">
<div className="flex items-center gap-x-2.5">
<UserAvatar />
<div>{userId.slice(0, 10)}</div>
</div>
<ArrowRightIcon className="text-muted-foreground h-4 w-4" />
<div className="flex items-center gap-x-2.5">
<CurrentUserAvatar />
<div>{user.name}</div>
</div>
</div>
</PageDialogContent>
<PageDialogFooter>
<Button
size="lg"
loading={transfer.isLoading}
variant="primary"
onClick={() => {
transfer.mutate({ pollId });
}}
>
<Trans
i18nKey="yesTransfer"
defaults="Yes, transfer to current user"
/>
</Button>
<Button asChild size="lg">
<Link href="/polls">
<Trans i18nKey="noTransfer" defaults="No, take me home" />
</Link>
</Button>
</PageDialogFooter>
</PageDialog>
);
const Page = () => {
return null;
};
Page.getLayout = getStandardLayout;
export default Page;
export const getServerSideProps: GetServerSideProps = withSessionSsr(
composeGetServerSideProps(async (ctx) => {
const res = await prisma.poll.findUnique({
where: {
adminUrlId: ctx.params?.urlId as string,
},
select: {
id: true,
userId: true,
user: {
select: {
id: true,
name: true,
},
},
},
});
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: true,
};
};
if (!res) {
return {
notFound: true,
};
}
export const getStaticProps: GetStaticProps = async (ctx) => {
// We get these props to be able to render the og:image
const poll = await prisma.poll.findUnique({
where: {
adminUrlId: ctx.params?.urlId as string,
},
select: {
id: true,
},
});
// if the poll was created by a registered user or the current user is the creator
if (res.user || ctx.req.session.user?.id === res.userId) {
// redirect to the poll page
return {
redirect: {
destination: `/poll/${res.id}`,
permanent: false,
},
};
}
if (!poll) {
return { props: {}, notFound: 404 };
}
// otherwise allow the current user to take ownership of the poll
return {
props: {
userId: res.userId,
pollId: res.id,
},
};
}, withPageTranslations()),
);
return {
props: {},
redirect: {
destination: `/poll/${poll.id}`,
permanent: true,
},
};
};

View file

@ -0,0 +1,3 @@
import { AuthApiRoute } from "@/utils/auth";
export default AuthApiRoute;

View file

@ -1,9 +1,35 @@
import { trpcNextApiHandler } from "@rallly/backend/next/trpc/server";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession, isEmailBlocked } from "@/utils/auth";
import { isSelfHosted } from "@/utils/constants";
import { emailClient } from "@/utils/emails";
export const config = {
api: {
externalResolver: true,
},
};
// export API handler
export default trpcNextApiHandler;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res);
if (!session) {
res.status(401).json({ error: "Unauthorized" });
return;
}
return trpcNextApiHandler({
user: {
isGuest: session.user.email === null,
id: session.user.id,
},
emailClient,
isSelfHosted,
isEmailBlocked,
})(req, res);
}

View file

@ -1,182 +1,59 @@
import { DisableNotificationsPayload } from "@rallly/backend";
import {
composeGetServerSideProps,
withSessionSsr,
} from "@rallly/backend/next";
import { decryptToken } from "@rallly/backend/session";
import { prisma } from "@rallly/database";
import { BellIcon } from "@rallly/icons";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import React from "react";
import { useMount } from "react-use";
import { GetServerSideProps } from "next";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { Spinner } from "@/components/spinner";
import { NextPageWithLayout } from "@/types";
import { usePostHog } from "@/utils/posthog";
import { withPageTranslations } from "@/utils/with-page-translations";
import { getServerSession } from "@/utils/auth";
const Redirect = (props: React.PropsWithChildren<{ redirect: string }>) => {
const router = useRouter();
const [enabled, setEnabled] = React.useState(false);
const { t } = useTranslation();
useMount(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
setTimeout(() => {
router.replace(props.redirect);
}, 3000);
});
return (
<div>
<div className="flex h-8 items-center justify-center gap-4">
{enabled ? (
<BellIcon
className={clsx("animate-popIn h-5", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-gray-800">{props.children}</div>
<div className="text-sm text-gray-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={props.redirect} />,
}}
/>
</div>
</div>
);
const Page = () => {
return null;
};
type Data = { title: string; adminUrlId: string; pollId: string };
export default Page;
type PageProps =
| {
error: "pollNotFound" | "invalidToken";
data: undefined;
}
| { error: undefined; data: Data };
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const token = ctx.query.token as string;
const session = await getServerSession(ctx.req, ctx.res);
const Page: NextPageWithLayout<PageProps> = (props) => {
const { t } = useTranslation();
const posthog = usePostHog();
useMount(() => {
if (!props.error) {
posthog?.capture("turned notifications off", {
pollId: props.data.pollId,
// where the event was triggered from
source: "email",
});
}
});
return (
<AuthLayout title={t("loading")}>
{props.error !== undefined ? (
<div>{props.error}</div>
) : (
<Redirect redirect={`/admin/${props.data.adminUrlId}`}>
<Trans
t={t}
i18nKey="notificationsDisabled"
values={{ title: props.data.title }}
components={{ b: <strong /> }}
/>
</Redirect>
)}
</AuthLayout>
);
};
Page.getLayout = (page) => {
return <StandardLayout hideNav={true}>{page}</StandardLayout>;
};
export const getServerSideProps = composeGetServerSideProps(
withPageTranslations(),
withSessionSsr(async (ctx) => {
const token = ctx.query.token as string;
if (!session || session.user.email === null) {
return {
props: {},
redirect: {
destination:
"/login?callbackUrl=" + encodeURIComponent(ctx.req.url ?? "/"),
},
};
}
if (session && token) {
const payload = await decryptToken<DisableNotificationsPayload>(token);
if (!payload) {
return {
props: {
errorCode: "invalidToken",
},
};
}
const watcher = await prisma.watcher.findUnique({
select: {
id: true,
poll: {
select: {
adminUrlId: true,
title: true,
},
},
},
where: {
id: payload.watcherId,
},
});
if (watcher) {
await prisma.watcher.delete({
if (payload) {
const watcher = await prisma.watcher.findFirst({
where: {
id: watcher.id,
userId: session.user.id,
pollId: payload.pollId,
},
});
return {
props: {
data: {
title: watcher.poll.title,
adminUrlId: watcher.poll.adminUrlId,
if (watcher) {
await prisma.watcher.delete({
where: {
id: watcher.id,
},
},
};
} else {
const poll = await prisma.poll.findFirst({
where: { id: payload.pollId },
select: { adminUrlId: true, title: true, id: true },
});
if (!poll) {
return {
props: {
errorCode: "pollNotFound",
},
};
});
}
return {
props: {
data: {
adminUrlId: poll.adminUrlId,
title: poll.title,
pollId: poll.id,
},
props: {},
redirect: {
destination: `/poll/${payload.pollId}`,
},
};
}
}),
);
}
export default Page;
return {
props: {},
notFound: true,
};
};

View file

@ -1,84 +1,31 @@
import { trpc } from "@rallly/backend";
import { CheckCircleIcon } from "@rallly/icons";
import clsx from "clsx";
import { InfoIcon } from "@rallly/icons";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import { useMount } from "react-use";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { Spinner } from "@/components/spinner";
import { NextPageWithLayout } from "@/types";
import { usePostHog } from "@/utils/posthog";
import { getStaticTranslations } from "@/utils/with-page-translations";
const defaultRedirectPath = "/polls";
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const router = useRouter();
const { token } = router.query;
const posthog = usePostHog();
const queryClient = trpc.useContext();
const authenticate = trpc.whoami.authenticate.useMutation();
useMount(() => {
authenticate.mutate(
{ token: token as string },
{
onSuccess: (user) => {
posthog?.identify(user.id, {
name: user.name,
email: user.email,
});
queryClient.invalidate();
setTimeout(() => {
router.replace(defaultRedirectPath);
}, 1000);
},
},
);
});
import {
PageDialog,
PageDialogDescription,
PageDialogFooter,
PageDialogHeader,
PageDialogTitle,
} from "@/components/page-dialog";
const Page = () => {
return (
<AuthLayout title={t("login")}>
{authenticate.isLoading ? (
<div className="flex items-center gap-4">
<Spinner />
<Trans i18nKey="loading" />
</div>
) : authenticate.isSuccess ? (
<div className="space-y-2">
<div className="flex h-10 items-center justify-center gap-4">
<CheckCircleIcon className={clsx("h-8 text-green-500")} />
</div>
<div className="text-gray-800">{t("loginSuccessful")}</div>
<div className="text-sm text-gray-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={defaultRedirectPath} />,
}}
/>
</div>
</div>
) : (
<div>
<Trans i18nKey="expiredOrInvalidLink" />
</div>
)}
</AuthLayout>
<PageDialog icon={InfoIcon}>
<PageDialogHeader>
<PageDialogTitle>Please login again</PageDialogTitle>
<PageDialogDescription>
This login was initiated with an older version of Rallly. Please login
again to continue. Sorry for the inconvinience.
</PageDialogDescription>
</PageDialogHeader>
<PageDialogFooter>
<Link href="/login" className="text-link">
Login
</Link>
</PageDialogFooter>
</PageDialog>
);
};
Page.getLayout = (page) => {
return <StandardLayout hideNav={true}>{page}</StandardLayout>;
};
export default Page;
export const getStaticProps = getStaticTranslations;

View file

@ -14,11 +14,12 @@ import React from "react";
import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { UserProvider, useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { DayjsProvider } from "@/utils/dayjs";
import { ConnectedDayjsProvider } from "@/utils/dayjs";
import { getStaticTranslations } from "@/utils/with-page-translations";
import Error404 from "../404";
@ -66,22 +67,24 @@ const GoToApp = () => {
const poll = usePoll();
const { user } = useUser();
if (poll.user?.id !== user.id) {
return null;
}
return (
<>
<div className="flex items-center gap-2">
<Button asChild>
<div className="flex items-center justify-between gap-2 p-3">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="h-4 w-4" />
<Trans i18nKey="manage" />
</Link>
</Button>
</div>
<hr />
</>
<div>
<UserDropdown />
</div>
</div>
);
};
@ -116,11 +119,11 @@ const Page = ({ id, title, user }: PageProps) => {
}}
/>
<UserProvider>
<DayjsProvider>
<ConnectedDayjsProvider>
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>
<div className="">
<div>
<svg
className="absolute inset-x-0 top-0 -z-10 hidden h-[64rem] w-full stroke-gray-300/75 [mask-image:radial-gradient(800px_800px_at_center,white,transparent)] sm:block"
aria-hidden="true"
@ -144,8 +147,8 @@ const Page = ({ id, title, user }: PageProps) => {
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
<div className="mx-auto max-w-4xl space-y-4 p-3 sm:py-8">
<GoToApp />
<GoToApp />
<div className="mx-auto max-w-4xl space-y-4 px-3 sm:py-8">
<Poll />
<div className="mt-4 space-y-4 text-center text-gray-500">
<div className="py-8">
@ -169,7 +172,7 @@ const Page = ({ id, title, user }: PageProps) => {
</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
</DayjsProvider>
</ConnectedDayjsProvider>
</UserProvider>
</>
);

View file

@ -1,40 +1,15 @@
import { Loader2Icon } from "@rallly/icons";
import Head from "next/head";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import React from "react";
import { LoginForm } from "@/components/auth/auth-forms";
import { AuthLayout } from "@/components/auth/auth-layout";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { PageDialog } from "@/components/page-dialog";
import { useWhoAmI } from "@/contexts/whoami";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "../utils/with-page-translations";
const Redirect = () => {
const router = useRouter();
const [redirect] = React.useState(router.query.redirect as string);
React.useEffect(() => {
router.replace(redirect ?? "/");
}, [router, redirect]);
return (
<PageDialog>
<Loader2Icon className="h-10 w-10 animate-spin text-gray-400" />
</PageDialog>
);
};
const Page: NextPageWithLayout<{ referer: string | null }> = () => {
const { t } = useTranslation();
const whoami = useWhoAmI();
if (whoami?.isGuest === false) {
return <Redirect />;
}
return (
<>

View file

@ -1,18 +1,18 @@
import { withSessionSsr } from "@rallly/backend/next";
import { NextPage } from "next";
import { signOut } from "next-auth/react";
import React from "react";
const Page: NextPage = () => {
import { StandardLayout } from "@/components/layouts/standard-layout";
import { NextPageWithLayout } from "@/types";
const Page: NextPageWithLayout = () => {
React.useEffect(() => {
signOut({ callbackUrl: "/login" });
});
return null;
};
export const getServerSideProps = withSessionSsr(async (ctx) => {
ctx.req.session.destroy();
return {
redirect: {
destination: ctx.req.headers.referer ?? "/polls",
permanent: false,
},
};
});
Page.getLayout = (page) => {
return <StandardLayout hideNav={true}>{page}</StandardLayout>;
};
export default Page;

View file

@ -1,36 +1,39 @@
/**
* This page is used to redirect older links to the new invite page
*/
import { prisma } from "@rallly/database";
import { GetServerSideProps } from "next";
import { GetStaticPaths, GetStaticProps } from "next";
const Page = () => {
return null;
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const participantUrlId = ctx.query.urlId as string;
const token = ctx.query.token as string;
export default Page;
const res = await prisma.poll.findUnique({
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: true,
};
};
export const getStaticProps: GetStaticProps = async (ctx) => {
// We get these props to be able to render the og:image
const poll = await prisma.poll.findUnique({
where: {
participantUrlId: participantUrlId,
participantUrlId: ctx.params?.urlId as string,
},
select: {
id: true,
},
});
if (!res) {
return { notFound: true };
if (!poll) {
return { props: {}, notFound: 404 };
}
return {
props: {},
redirect: {
destination: `/invite/${res.id}${token ? `?token=${token}` : ""}`,
destination: `/poll/${poll.id}`,
permanent: true,
},
};
};
export default Page;

View file

@ -1,11 +1,12 @@
import { InfoIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import Link from "next/link";
import { Trans } from "next-i18next";
import { getPollLayout } from "@/components/layouts/poll-layout";
import { LoginLink } from "@/components/login-link";
import { Poll } from "@/components/poll";
import { RegisterLink } from "@/components/register-link";
import { useUser } from "@/components/user-provider";
import { usePoll } from "@/contexts/poll";
import { NextPageWithLayout } from "@/types";
@ -35,16 +36,11 @@ const GuestPollAlert = () => {
i18nKey="guestPollAlertDescription"
defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
components={[
<Link
<RegisterLink
className="hover:text-primary underline"
key="register"
href="/register"
/>,
<Link
className="hover:text-primary underline"
key="login"
href="/login"
/>,
<LoginLink className="hover:text-primary underline" key="login" />,
]}
/>
</AlertDescription>

View file

@ -1,28 +1,196 @@
import { trpc } from "@rallly/backend";
import { Button } from "@rallly/ui/button";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { signIn } from "next-auth/react";
import { Trans, useTranslation } from "next-i18next";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { useForm } from "react-hook-form";
import { useDefaultEmail, VerifyCode } from "@/components/auth/auth-forms";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { TextInput } from "@/components/text-input";
import { NextPageWithLayout } from "@/types";
import { useDayjs } from "@/utils/dayjs";
import { requiredString, validEmail } from "@/utils/form-validation";
import { RegisterForm } from "../components/auth/auth-forms";
import { AuthLayout } from "../components/auth/auth-layout";
import { getStaticTranslations } from "../utils/with-page-translations";
type RegisterFormData = {
name: string;
email: string;
};
export const RegisterForm: React.FunctionComponent<{
onClickLogin?: React.MouseEventHandler;
}> = ({ onClickLogin }) => {
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
const { t } = useTranslation();
const { timeZone } = useDayjs();
const router = useRouter();
const { register, handleSubmit, getValues, setError, formState } =
useForm<RegisterFormData>({
defaultValues: { email: defaultEmail },
});
const queryClient = trpc.useContext();
const requestRegistration = trpc.auth.requestRegistration.useMutation();
const authenticateRegistration =
trpc.auth.authenticateRegistration.useMutation();
const [token, setToken] = React.useState<string>();
const posthog = usePostHog();
if (token) {
return (
<VerifyCode
onSubmit={async (code) => {
// get user's time zone
const locale = router.locale;
const res = await authenticateRegistration.mutateAsync({
token,
timeZone,
locale,
code,
});
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,
timeZone,
locale,
});
posthog?.capture("register");
signIn("registration-token", {
token,
callbackUrl: router.query.callbackUrl as string,
});
}}
onChange={() => setToken(undefined)}
email={getValues("email")}
/>
);
}
return (
<form
onSubmit={handleSubmit(async (data) => {
const res = await requestRegistration.mutateAsync({
email: data.email,
name: data.name,
});
if (!res.ok) {
switch (res.reason) {
case "userAlreadyExists":
setError("email", {
message: t("userAlreadyExists"),
});
break;
case "emailNotAllowed":
setError("email", {
message: t("emailNotAllowed"),
});
}
} else {
setToken(res.token);
}
})}
>
<div className="mb-1 text-2xl font-bold">{t("createAnAccount")}</div>
<p className="mb-4 text-gray-500">
{t("stepSummary", {
current: 1,
total: 2,
})}
</p>
<fieldset className="mb-4">
<label htmlFor="name" className="mb-1 text-gray-500">
{t("name")}
</label>
<TextInput
id="name"
className="w-full"
proportions="lg"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
<fieldset className="mb-4">
<label htmlFor="email" className="mb-1 text-gray-500">
{t("email")}
</label>
<TextInput
className="w-full"
id="email"
proportions="lg"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", { validate: validEmail })}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset>
<Button
loading={formState.isSubmitting}
type="submit"
variant="primary"
size="lg"
>
{t("continue")}
</Button>
<div className="mt-4 border-t pt-4 text-gray-500 sm:text-base">
<Trans
t={t}
i18nKey="alreadyRegistered"
components={{
a: (
<Link
href="/login"
className="text-link"
onClick={(e) => {
setDefaultEmail(getValues("email"));
onClickLogin?.(e);
}}
/>
),
}}
/>
</div>
</form>
);
};
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<AuthLayout>
<Head>
<title>{t("register")}</title>
</Head>
<RegisterForm
onRegistered={() => {
router.replace("/");
}}
/>
<RegisterForm />
</AuthLayout>
);
};

251
apps/web/src/utils/auth.ts Normal file
View file

@ -0,0 +1,251 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { RegistrationTokenPayload } from "@rallly/backend";
import { decryptToken } from "@rallly/backend/session";
import { generateOtp, randomid } from "@rallly/backend/utils/nanoid";
import { prisma } from "@rallly/database";
import cookie from "cookie";
import { IronSession, unsealData } from "iron-session";
import {
GetServerSidePropsContext,
NextApiRequest,
NextApiResponse,
} from "next";
import { NextAuthOptions, RequestInternal } from "next-auth";
import NextAuth, {
getServerSession as getServerSessionWithOptions,
} from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import { LegacyTokenProvider } from "@/utils/auth/legacy-token-provider";
import { emailClient } from "@/utils/emails";
const authOptions = {
adapter: PrismaAdapter(prisma),
secret: process.env.SECRET_PASSWORD,
session: {
strategy: "jwt",
},
providers: [
LegacyTokenProvider,
// When a user registers, we don't want to go through the email verification process
// so this providers allows us exchange the registration token for a session token
CredentialsProvider({
id: "registration-token",
name: "Registration Token",
credentials: {
token: {
label: "Token",
type: "text",
},
},
async authorize(credentials) {
if (credentials?.token) {
const payload = await decryptToken<RegistrationTokenPayload>(
credentials.token,
);
if (payload) {
const user = await prisma.user.findUnique({
where: {
email: payload.email,
},
select: {
id: true,
email: true,
name: true,
locale: true,
timeFormat: true,
timeZone: true,
},
});
if (user) {
return user;
}
}
}
return null;
},
}),
CredentialsProvider({
id: "guest",
name: "Guest",
credentials: {},
async authorize() {
return {
id: `user-${randomid()}`,
email: null,
};
},
}),
EmailProvider({
server: "",
from: process.env.NOREPLY_EMAIL,
generateVerificationToken() {
return generateOtp();
},
async sendVerificationRequest({ identifier: email, token, url }) {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
name: true,
},
});
if (user) {
await emailClient.sendTemplate("LoginEmail", {
to: email,
subject: `${token} is your 6-digit code`,
props: {
name: user.name,
magicLink: url,
code: token,
},
});
}
},
}),
],
pages: {
signIn: "/login",
signOut: "/logout",
},
callbacks: {
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
async signIn({ user }) {
if (user.email) {
const userExists =
(await prisma.user.count({
where: {
email: user.email,
},
})) > 0;
if (userExists) {
if (isEmailBlocked(user.email)) {
return false;
}
return true;
} else {
return false;
}
}
return true;
},
async jwt({ token, user, trigger, session }) {
if (trigger === "update" && session) {
if (token.email) {
// For registered users we want to save the preferences to the database
try {
await prisma.user.update({
where: {
id: token.sub,
},
data: {
locale: session.locale,
timeFormat: session.timeFormat,
timeZone: session.timeZone,
weekStart: session.weekStart,
name: session.name,
},
});
} catch (e) {
console.error("Failed to update user preferences", session);
}
}
token = { ...token, ...session };
}
if (trigger === "signIn" && user) {
token.locale = user.locale;
token.timeFormat = user.timeFormat;
token.timeZone = user.timeZone;
token.weekStart = user.weekStart;
}
return token;
},
async session({ session, token }) {
session.user.id = token.sub as string;
session.user.name = token.name;
session.user.timeFormat = token.timeFormat;
session.user.timeZone = token.timeZone;
session.user.locale = token.locale;
session.user.weekStart = token.weekStart;
return session;
},
},
} satisfies NextAuthOptions;
export function getServerSession(
...args:
| [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]]
| [NextApiRequest, NextApiResponse]
| []
) {
return getServerSessionWithOptions(...args, authOptions);
}
export async function AuthApiRoute(req: NextApiRequest, res: NextApiResponse) {
return NextAuth(req, res, authOptions);
}
export const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};
export const legacySessionConfig = {
password: process.env.SECRET_PASSWORD ?? "",
cookieName: "rallly-session",
cookieOptions: {
secure: process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false,
},
ttl: 60 * 60 * 24 * 30, // 30 days
};
export const getUserFromLegacySession = async (
req: Pick<RequestInternal, "headers">,
) => {
const parsedCookie = cookie.parse(req.headers?.cookie);
if (parsedCookie[legacySessionConfig.cookieName]) {
try {
const session = await unsealData<IronSession>(
parsedCookie[legacySessionConfig.cookieName],
{
password: process.env.SECRET_PASSWORD,
},
);
if (session.user) {
return session.user;
}
} catch (e) {
return null;
}
}
return null;
};

View file

@ -0,0 +1,71 @@
import { decryptToken } from "@rallly/backend/session";
import { prisma, TimeFormat } from "@rallly/database";
import CredentialsProvider from "next-auth/providers/credentials";
/**
* This provider allows us to login with a token from an older session created with
* iron-session.
*
* We should keep this provider available for at least 30 days in production to allow returning
* users to keep their existing sessions.
*
* @deprecated
*/
export const LegacyTokenProvider = CredentialsProvider({
id: "legacy-token",
name: "Legacy Token",
credentials: {
token: {
label: "Token",
type: "text",
},
},
async authorize(credentials) {
if (credentials?.token) {
const session = await decryptToken<{
user: {
id: string;
isGuest: boolean;
preferences?: {
weekStart?: number;
timeZone?: string;
timeFormat?: TimeFormat;
};
};
}>(credentials.token);
if (session?.user) {
if (session.user.isGuest) {
return {
id: session.user.id,
email: null,
weekStart: session.user.preferences?.weekStart,
timeZone: session.user.preferences?.timeZone,
timeFormat: session.user.preferences?.timeFormat,
};
} else {
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
email: true,
name: true,
},
});
if (user) {
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
}
}
return null;
},
});

View file

@ -1,4 +1,3 @@
import { trpc } from "@rallly/backend";
import { TimeFormat } from "@rallly/database";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@ -12,10 +11,10 @@ import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import updateLocale from "dayjs/plugin/updateLocale";
import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import * as React from "react";
import { useAsync } from "react-use";
import { usePreferences } from "@/contexts/preferences";
import { getBrowserTimeZone } from "@/utils/date-time-utils";
import { useRequiredContext } from "../components/use-required-context";
@ -166,55 +165,65 @@ const DayjsContext = React.createContext<{
locale: {
weekStart: number;
timeFormat: TimeFormat;
timeZone: string;
};
weekStart: number;
timeZone: string;
timeFormat: TimeFormat;
weekStart: number;
} | null>(null);
DayjsContext.displayName = "DayjsContext";
export const useDayjs = () => {
return useRequiredContext(DayjsContext);
};
export const DayjsProvider: React.FunctionComponent<{
children?: React.ReactNode;
}> = ({ children }) => {
const router = useRouter();
const localeConfig = dayjsLocales[router.locale ?? "en"];
const { data } = trpc.userPreferences.get.useQuery();
const state = useAsync(async () => {
const locale = await localeConfig.import();
const localeTimeFormat = localeConfig.timeFormat;
const timeFormat = data?.timeFormat ?? localeConfig.timeFormat;
dayjs.locale("custom", {
...locale,
weekStart: data?.weekStart ?? locale.weekStart,
formats:
localeTimeFormat === data?.timeFormat
? locale.formats
: {
...locale.formats,
LT: timeFormat === "hours12" ? "h:mm A" : "HH:mm",
},
});
}, [localeConfig, data]);
const locale = {
timeZone: getBrowserTimeZone(),
weekStart: localeConfig.weekStart,
timeFormat: localeConfig.timeFormat,
config?: {
locale?: string;
timeZone?: string;
localeOverrides?: {
weekStart?: number;
timeFormat?: TimeFormat;
};
};
}> = ({ config, children }) => {
const l = config?.locale ?? "en";
const state = useAsync(async () => {
return await dayjsLocales[l].import();
}, [l]);
const preferredTimeZone = data?.timeZone ?? locale.timeZone;
if (state.loading) {
if (!state.value) {
// wait for locale to load before rendering
return null;
}
const dayjsLocale = state.value;
const localeConfig = dayjsLocales[l];
const localeTimeFormat = localeConfig.timeFormat;
if (config?.localeOverrides) {
const timeFormat =
config.localeOverrides.timeFormat ?? localeConfig.timeFormat;
const weekStart =
config.localeOverrides.weekStart ?? localeConfig.weekStart;
dayjs.locale("custom", {
...dayjsLocale,
weekStart,
formats:
localeTimeFormat === config.localeOverrides?.timeFormat
? dayjsLocale.formats
: {
...dayjsLocale.formats,
LT: timeFormat === "hours12" ? "h:mm A" : "HH:mm",
},
});
} else {
dayjs.locale(dayjsLocale);
}
const preferredTimeZone = config?.timeZone ?? getBrowserTimeZone();
return (
<DayjsContext.Provider
value={{
@ -224,13 +233,35 @@ export const DayjsProvider: React.FunctionComponent<{
: dayjs(date).tz(preferredTimeZone);
},
dayjs,
locale,
locale: localeConfig, // locale defaults
timeZone: preferredTimeZone,
weekStart: dayjs.localeData().firstDayOfWeek() === 0 ? 0 : 1,
timeFormat: data?.timeFormat ?? localeConfig.timeFormat,
timeFormat:
config?.localeOverrides?.timeFormat ?? localeConfig.timeFormat,
weekStart: config?.localeOverrides?.weekStart ?? localeConfig.weekStart,
}}
>
{children}
</DayjsContext.Provider>
);
};
export const ConnectedDayjsProvider = ({
children,
}: React.PropsWithChildren) => {
const { preferences } = usePreferences();
return (
<DayjsProvider
config={{
locale: preferences.locale ?? undefined,
timeZone: preferences.timeZone ?? undefined,
localeOverrides: {
weekStart: preferences.weekStart ?? undefined,
timeFormat: preferences.timeFormat ?? undefined,
},
}}
>
{children}
</DayjsProvider>
);
};

View file

@ -0,0 +1,19 @@
import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
const env = process.env["NODE" + "_ENV"];
export const emailClient = new EmailClient({
openPreviews: env === "development",
useTestServer: env === "test",
provider: {
name: (process.env.EMAIL_PROVIDER as SupportedEmailProviders) ?? "smtp",
},
mail: {
from: {
name: "Rallly",
address:
(process.env.NOREPLY_EMAIL as string) ||
(process.env.SUPPORT_EMAIL as string),
},
},
});

View file

@ -1,16 +0,0 @@
import { trpc } from "@rallly/backend";
import { useRouter } from "next/router";
import React from "react";
export const usePollByAdmin = () => {
const router = useRouter();
const [adminUrlId] = React.useState(router.query.urlId as string);
const pollQuery = trpc.polls.getByAdminUrlId.useQuery({ urlId: adminUrlId });
if (!pollQuery.data) {
throw new Error("Poll not found");
}
return pollQuery;
};

View file

@ -1,22 +1,6 @@
import {
GetServerSideProps,
GetServerSidePropsContext,
GetStaticProps,
} from "next";
import { GetStaticProps } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
export const withPageTranslations = (ns?: string[]): GetServerSideProps => {
return async (ctx: GetServerSidePropsContext) => {
const locale = ctx.locale ?? "en";
const translations = await serverSideTranslations(locale, ns);
return {
props: {
...translations,
},
};
};
};
export const getStaticTranslations: GetStaticProps = async (ctx) => {
const locale = ctx.locale ?? "en";
return {

View file

@ -68,8 +68,6 @@ test.describe.serial(() => {
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
codeInput.waitFor({ state: "visible" });
const code = await getCode();
await codeInput.type(code);
@ -127,8 +125,6 @@ test.describe.serial(() => {
await page.goto(magicLink);
page.getByText("Click here").click();
await page.waitForURL("/polls");
});

View file

@ -74,6 +74,8 @@ test.describe(() => {
test("should be able to edit submission", async ({ page: newPage }) => {
await newPage.goto(editSubmissionUrl);
await expect(newPage.getByTestId("participant-menu")).toBeVisible();
await expect(newPage.getByTestId("participant-menu")).toBeVisible({
timeout: 10000,
});
});
});

View file

@ -1,15 +1,9 @@
import type { IncomingMessage, ServerResponse } from "http";
import { getIronSession } from "iron-session";
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import {
GetServerSideProps,
GetServerSidePropsContext,
NextApiHandler,
} from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiHandler } from "next";
import { sessionConfig } from "../session-config";
import { createSSGHelperFromContext } from "../trpc/context";
import { composeGetServerSideProps } from "./utils";
export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, sessionConfig);
@ -21,60 +15,3 @@ export const getSession = async (
) => {
return getIronSession(req, res, sessionConfig);
};
export function withSessionSsr(
handler: GetServerSideProps | GetServerSideProps[],
options?: {
onPrefetch?: (
ssg: Awaited<ReturnType<typeof createSSGHelperFromContext>>,
ctx: GetServerSidePropsContext,
) => Promise<void>;
},
): GetServerSideProps {
const composedHandler = Array.isArray(handler)
? composeGetServerSideProps(...handler)
: handler;
return withIronSessionSsr(async (ctx) => {
const ssg = await createSSGHelperFromContext(ctx);
await ssg.whoami.get.prefetch(); // always prefetch user
if (options?.onPrefetch) {
try {
await options.onPrefetch(ssg, ctx);
} catch {
return {
notFound: true,
};
}
}
const res = await composedHandler(ctx);
if ("props" in res) {
return {
...res,
props: {
...res.props,
trpcState: ssg.dehydrate(),
},
};
}
return res;
}, sessionConfig);
}
/**
* Require user to be logged in
* @returns
*/
export const withAuth: GetServerSideProps = async (ctx) => {
if (!ctx.req.session.user || ctx.req.session.user.isGuest) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
return { props: {} };
};

View file

@ -1,12 +1,20 @@
import { EmailClient } from "@rallly/emails";
import * as trpcNext from "@trpc/server/adapters/next";
import { createContext } from "../../trpc/context";
import { appRouter } from "../../trpc/routers";
import { withSessionRoute } from "../session";
export const trpcNextApiHandler = withSessionRoute(
trpcNext.createNextApiHandler({
export interface TRPCContext {
user: { id: string; isGuest: boolean };
emailClient: EmailClient;
isSelfHosted: boolean;
isEmailBlocked?: (email: string) => boolean;
}
export const trpcNextApiHandler = (context: TRPCContext) => {
return trpcNext.createNextApiHandler({
router: appRouter,
createContext,
}),
);
createContext: async () => {
return context;
},
});
};

View file

@ -1,4 +1,3 @@
import { TimeFormat } from "@rallly/database";
import { sealData, unsealData } from "iron-session";
import { sessionConfig } from "./session-config";
@ -6,11 +5,6 @@ import { sessionConfig } from "./session-config";
type UserSessionData = {
id: string;
isGuest: boolean;
preferences?: {
timeZone?: string;
weekStart?: number;
timeFormat?: TimeFormat;
};
};
declare module "iron-session" {

View file

@ -1,62 +0,0 @@
import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
import { createProxySSGHelpers } from "@trpc/react-query/ssg";
import * as trpc from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import { GetServerSidePropsContext } from "next";
import superjson from "superjson";
import { randomid } from "../utils/nanoid";
import { appRouter } from "./routers";
// Avoid use NODE_ENV directly because it will be replaced when using the dev server for e2e tests
const env = process.env["NODE" + "_ENV"];
export async function createContext(
opts: trpcNext.CreateNextContextOptions | GetServerSidePropsContext,
) {
let user = opts.req.session.user;
if (!user) {
user = {
id: `user-${randomid()}`,
isGuest: true,
};
opts.req.session.user = user;
await opts.req.session.save();
}
const emailClient = new EmailClient({
openPreviews: env === "development",
useTestServer: env === "test",
provider: {
name: (process.env.EMAIL_PROVIDER as SupportedEmailProviders) ?? "smtp",
},
mail: {
from: {
name: "Rallly",
address:
(process.env.NOREPLY_EMAIL as string) ||
(process.env.SUPPORT_EMAIL as string),
},
},
});
return {
user,
session: opts.req.session,
req: opts.req,
res: opts.res,
isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true",
emailClient,
};
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
export const createSSGHelperFromContext = async (
ctx: GetServerSidePropsContext,
) =>
createProxySSGHelpers({
router: appRouter,
ctx: await createContext(ctx),
transformer: superjson,
});

View file

@ -1,12 +1,10 @@
import { prisma } from "@rallly/database";
import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createToken, decryptToken } from "../../session";
import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
import { RegistrationTokenPayload } from "../types";
// assigns participants and comments created by guests to a user
// we could have multiple guests because a login might be triggered from one device
@ -46,27 +44,8 @@ const mergeGuestsIntoUser = async (userId: string, guestIds: string[]) => {
});
};
const isEmailBlocked = (email: string) => {
if (process.env.ALLOWED_EMAILS) {
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
const isAllowed = allowedEmails.some((allowedEmail) => {
const regex = new RegExp(
`^${allowedEmail
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
.replaceAll(/[*]/g, ".*")}$`,
);
return regex.test(email);
});
if (!isAllowed) {
return true;
}
}
return false;
};
export const auth = router({
// @deprecated
requestRegistration: publicProcedure
.input(
z.object({
@ -82,7 +61,7 @@ export const auth = router({
| { ok: true; token: string }
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
> => {
if (isEmailBlocked(input.email)) {
if (ctx.isEmailBlocked?.(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
@ -124,6 +103,8 @@ export const auth = router({
z.object({
token: z.string(),
code: z.string(),
timeZone: z.string().optional(),
locale: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@ -143,115 +124,17 @@ export const auth = router({
data: {
name,
email,
timeZone: input.timeZone,
locale: input.locale,
},
});
if (ctx.session.user?.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
if (ctx.user.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.user.id]);
}
ctx.session.user = {
isGuest: false,
id: user.id,
};
await ctx.session.save();
return { ok: true, user };
}),
requestLogin: publicProcedure
.input(
z.object({
email: z.string(),
}),
)
.mutation(
async ({
input,
ctx,
}): Promise<
| { ok: true; token: string }
| { ok: false; reason: "emailNotAllowed" | "userNotFound" }
> => {
if (isEmailBlocked(input.email)) {
return { ok: false, reason: "emailNotAllowed" };
}
const user = await prisma.user.findUnique({
where: {
email: input.email,
},
});
if (!user) {
return { ok: false, reason: "userNotFound" };
}
const code = generateOtp();
const token = await createToken<LoginTokenPayload>({
userId: user.id,
code,
});
await ctx.emailClient.sendTemplate("LoginEmail", {
to: input.email,
subject: `${code} is your 6-digit code`,
props: {
name: user.name,
code,
magicLink: absoluteUrl(`/auth/login?token=${token}`),
},
});
return { ok: true, token };
},
),
authenticateLogin: publicProcedure
.input(
z.object({
token: z.string(),
code: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { userId, code } = payload;
if (input.code !== code) {
return { user: null };
}
const user = await prisma.user.findUnique({
select: { id: true, name: true, email: true },
where: { id: userId },
});
if (!user) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The user doesn't exist anymore",
});
}
if (ctx.session.user?.isGuest) {
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
}
ctx.session.user = {
isGuest: false,
id: user.id,
};
await ctx.session.save();
return { user };
}),
getUserPermission: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {

View file

@ -3,11 +3,9 @@ import { auth } from "./auth";
import { polls } from "./polls";
import { user } from "./user";
import { userPreferences } from "./user-preferences";
import { whoami } from "./whoami";
export const appRouter = mergeRouters(
router({
whoami,
auth,
polls,
user,

View file

@ -121,9 +121,7 @@ export const polls = router({
},
});
const pollLink = ctx.user.isGuest
? absoluteUrl(`/admin/${adminToken}`)
: absoluteUrl(`/poll/${pollId}`);
const pollLink = absoluteUrl(`/poll/${pollId}`);
const participantLink = shortUrl(`/invite/${pollId}`);
@ -288,7 +286,7 @@ export const polls = router({
});
}
const res = await prisma.watcher.findFirst({
const watcher = await prisma.watcher.findFirst({
where: {
pollId: input.pollId,
userId: ctx.user.id,
@ -298,18 +296,13 @@ export const polls = router({
},
});
if (!res) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Not watching this poll",
if (watcher) {
await prisma.watcher.delete({
where: {
id: watcher.id,
},
});
}
await prisma.watcher.delete({
where: {
id: res.id,
},
});
}),
getByAdminUrlId: possiblyPublicProcedure
.input(
@ -321,28 +314,6 @@ export const polls = router({
const res = await prisma.poll.findUnique({
select: {
id: true,
timeZone: true,
title: true,
location: true,
description: true,
createdAt: true,
adminUrlId: true,
participantUrlId: true,
closed: true,
legacy: true,
demo: true,
options: {
orderBy: {
start: "asc",
},
},
user: true,
deleted: true,
watchers: {
select: {
userId: true,
},
},
},
where: {
adminUrlId: input.urlId,

View file

@ -6,13 +6,7 @@ import { publicProcedure, router } from "../trpc";
export const userPreferences = router({
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return ctx.user.preferences
? {
timeZone: ctx.user.preferences.timeZone ?? null,
timeFormat: ctx.user.preferences.timeFormat ?? null,
weekStart: ctx.user.preferences.weekStart ?? null,
}
: null;
return null;
} else {
return await prisma.userPreferences.findUnique({
where: {
@ -48,21 +42,11 @@ export const userPreferences = router({
...input,
},
});
} else {
ctx.session.user = {
...ctx.user,
preferences: { ...ctx.user.preferences, ...input },
};
await ctx.session.save();
}
}),
delete: publicProcedure.mutation(async ({ ctx }) => {
if (ctx.user.isGuest) {
ctx.session.user = {
...ctx.user,
preferences: undefined,
};
await ctx.session.save();
// delete guest preferences
} else {
await prisma.userPreferences.delete({
where: {

View file

@ -48,4 +48,23 @@ export const user = router({
},
});
}),
updatePreferences: privateProcedure
.input(
z.object({
locale: z.string().optional(),
timeZone: z.string().optional(),
weekStart: z.number().min(0).max(6).optional(),
timeFormat: z.enum(["hours12", "hours24"]).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest === false) {
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: input,
});
}
}),
});

View file

@ -1,64 +0,0 @@
import { prisma } from "@rallly/database";
import { TRPCError } from "@trpc/server";
import z from "zod";
import { decryptToken } from "../../session";
import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload } from "../types";
export const whoami = router({
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return { isGuest: true as const, id: ctx.user.id };
}
const user = await prisma.user.findUnique({
select: {
id: true,
name: true,
email: true,
},
where: { id: ctx.user.id },
});
if (user === null) {
ctx.session.destroy();
throw new Error("User not found");
}
return { isGuest: false as const, ...user };
}),
destroy: publicProcedure.mutation(async ({ ctx }) => {
ctx.session.destroy();
}),
authenticate: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
// token is invalid or expired
throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid token" });
}
const user = await prisma.user.findFirst({
select: {
id: true,
name: true,
email: true,
},
where: { id: payload.userId },
});
if (!user) {
// user does not exist
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
ctx.session.user = { id: user.id, isGuest: false };
await ctx.session.save();
return user;
}),
});

View file

@ -1,10 +1,10 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { TRPCContext } from "../next/trpc/server";
import { getSubscriptionStatus } from "../utils/auth";
import { Context } from "./context";
const t = initTRPC.context<Context>().create({
const t = initTRPC.context<TRPCContext>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;

View file

@ -1,11 +1,8 @@
export type RegistrationTokenPayload = {
name: string;
email: string;
code: string;
};
export type LoginTokenPayload = {
userId: string;
locale?: string;
timeZone?: string;
code: string;
};

View file

@ -0,0 +1,28 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "email_verified" TIMESTAMP(3),
ADD COLUMN "locale" TEXT,
ADD COLUMN "time_format" "time_format",
ADD COLUMN "time_zone" TEXT,
ADD COLUMN "week_start" INTEGER;
-- Copy user preferences from old table
UPDATE "users" u
SET
"time_zone" = up."time_zone",
"week_start" = up."week_start",
"time_format" = up."time_format"
FROM "user_preferences" up
WHERE u.id = up."user_id";
-- CreateTable
CREATE TABLE "verification_tokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");

View file

@ -17,18 +17,24 @@ enum TimeFormat {
}
model User {
id String @id @default(cuid())
id String @id @default(cuid())
name String
email String @unique() @db.Citext
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
comments Comment[]
polls Poll[]
watcher Watcher[]
events Event[]
customerId String? @map("customer_id")
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
subscriptionId String? @unique @map("subscription_id")
email String @unique() @db.Citext
emailVerified DateTime? @map("email_verified")
timeZone String? @map("time_zone")
weekStart Int? @map("week_start")
timeFormat TimeFormat? @map("time_format")
locale String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
customerId String? @map("customer_id")
subscriptionId String? @unique @map("subscription_id")
comments Comment[]
polls Poll[]
watcher Watcher[]
events Event[]
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@@map("users")
}
@ -70,6 +76,7 @@ model Subscription {
@@map("subscriptions")
}
// @deprecated
model UserPreferences {
userId String @id @map("user_id")
timeZone String? @map("time_zone")
@ -219,3 +226,12 @@ model Comment {
@@index([pollId], type: Hash)
@@map("comments")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}

View file

@ -1,4 +1,4 @@
#!/bin/sh
set -e
prisma migrate deploy --schema=./prisma/schema.prisma
node apps/web/server.js
NEXTAUTH_URL=$NEXT_PUBLIC_BASE_URL node apps/web/server.js

113
yarn.lock
View file

@ -20,6 +20,25 @@
"@jridgewell/gen-mapping" "^0.1.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@auth/core@0.16.1":
version "0.16.1"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.16.1.tgz#208005d8b7eeb87847988109f15ff4e0250c9828"
integrity sha512-V+YifnjpyOadiiTbxfYDV2xYWo8xpKNtwYVskAEKUSwMvE0FlSlP+10QGBpf0axS/AJFOO61IR6GncFF/IOrHQ==
dependencies:
"@panva/hkdf" "^1.0.4"
cookie "0.5.0"
jose "^4.11.1"
oauth4webapi "^2.0.6"
preact "10.11.3"
preact-render-to-string "5.2.3"
"@auth/prisma-adapter@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz#d447d69fbe8b47f0e261e0742fa9fb46bbbc8535"
integrity sha512-AMwQbO7OiBYRCA6VNfv9CpcpiRh0BP4EKhPdtO+pom9Uhuor2ioE4IqvhUfJyBkSjAP2Gt9WbKqr9kzL9LrtIg==
dependencies:
"@auth/core" "0.16.1"
"@aws-crypto/ie11-detection@^3.0.0":
version "3.0.0"
resolved "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz"
@ -2399,6 +2418,11 @@
dependencies:
"@octokit/openapi-types" "^16.0.0"
"@panva/hkdf@^1.0.2", "@panva/hkdf@^1.0.4":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.1.1.tgz#ab9cd8755d1976e72fc77a00f7655a64efe6cd5d"
integrity sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==
"@peculiar/asn1-schema@^2.1.6", "@peculiar/asn1-schema@^2.3.0":
version "2.3.3"
resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz"
@ -5142,16 +5166,16 @@ convert-source-map@^1.5.0, convert-source-map@^1.7.0:
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
cookie@0.5.0, cookie@^0.5.0:
version "0.5.0"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@^0.4.1:
version "0.4.2"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
cookie@^0.5.0:
version "0.5.0"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
copy-anything@^3.0.2:
version "3.0.3"
resolved "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.3.tgz"
@ -7649,6 +7673,11 @@ joi@^17.6.0:
"@sideway/formula" "^3.0.1"
"@sideway/pinpoint" "^2.0.0"
jose@^4.11.1, jose@^4.11.4, jose@^4.15.1:
version "4.15.2"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.2.tgz#61f97383f0b433d45da26d35094155a30a672d92"
integrity sha512-IY73F228OXRl9ar3jJagh7Vnuhj/GzBunPiZP13K0lOl7Am9SoWW3kEzq3MCllJMTtZqHTiDXQvoRd4U95aU6A==
js-beautify@^1.6.12:
version "1.14.7"
resolved "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.7.tgz"
@ -8502,6 +8531,21 @@ natural-compare@^1.4.0:
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next-auth@^4.23.2:
version "4.23.2"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.23.2.tgz#6a93ec8bb59890dd43ed149a367852c7d12d0f7c"
integrity sha512-VRmInu0r/yZNFQheDFeOKtiugu3bt90Po3owAQDnFQ3YLQFmUKgFjcE2+3L0ny5jsJpBXaKbm7j7W2QTc6Ye2A==
dependencies:
"@babel/runtime" "^7.20.13"
"@panva/hkdf" "^1.0.2"
cookie "^0.5.0"
jose "^4.11.4"
oauth "^0.9.15"
openid-client "^5.4.0"
preact "^10.6.3"
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
next-i18next@^13.0.3:
version "13.1.6"
resolved "https://registry.npmjs.org/next-i18next/-/next-i18next-13.1.6.tgz"
@ -8651,11 +8695,26 @@ nth-check@^2.0.1:
dependencies:
boolbase "^1.0.0"
oauth4webapi@^2.0.6:
version "2.3.0"
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-2.3.0.tgz#d01aeb83b60dbe3ff9ef1c6ec4a39e29c7be7ff6"
integrity sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==
oauth@^0.9.15:
version "0.9.15"
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-hash@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
@ -8774,6 +8833,11 @@ object.values@^1.1.6:
define-properties "^1.1.4"
es-abstract "^1.20.4"
oidc-token-hash@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
@ -8810,6 +8874,16 @@ opener@^1.5.2:
resolved "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
openid-client@^5.4.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.6.0.tgz#3cc084b9a93a5e810a7f309d23812204d08286d9"
integrity sha512-uFTkN/iqgKvSnmpVAS/T6SNThukRMBcmymTQ71Ngus1F60tdtKVap7zCrleocY+fogPtpmoxi5Q1YdrgYuTlkA==
dependencies:
jose "^4.15.1"
lru-cache "^6.0.0"
object-hash "^2.2.0"
oidc-token-hash "^5.0.3"
optionator@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
@ -9127,6 +9201,30 @@ posthog-js@^1.57.2:
fflate "^0.4.1"
rrweb-snapshot "^1.1.14"
preact-render-to-string@5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4"
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==
dependencies:
pretty-format "^3.8.0"
preact-render-to-string@^5.1.19:
version "5.2.6"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz#0ff0c86cd118d30affb825193f18e92bd59d0604"
integrity sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==
dependencies:
pretty-format "^3.8.0"
preact@10.11.3:
version "10.11.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
preact@^10.6.3:
version "10.18.1"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.18.1.tgz#3b84bb305f0b05f4ad5784b981d15fcec4e105da"
integrity sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
@ -9147,6 +9245,11 @@ pretty-bytes@^5.6.0:
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
pretty-format@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
pretty@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz"