mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
♻️ Switch to next-auth for handling authentication (#899)
This commit is contained in:
parent
5f9e428432
commit
6fa66da681
65 changed files with 1514 additions and 1586 deletions
6
.env.test
Normal file
6
.env.test
Normal 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
|
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
@ -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
38
apps/web/declarations/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
1
apps/web/next-env.d.ts
vendored
1
apps/web/next-env.d.ts
vendored
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" }],
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
24
apps/web/src/components/login-link.tsx
Normal file
24
apps/web/src/components/login-link.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
26
apps/web/src/components/register-link.tsx
Normal file
26
apps/web/src/components/register-link.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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);
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
16
apps/web/src/contexts/locale.tsx
Normal file
16
apps/web/src/contexts/locale.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
|
||||
export const useWhoAmI = () => {
|
||||
const { data: whoAmI } = trpc.whoami.get.useQuery();
|
||||
return whoAmI;
|
||||
};
|
|
@ -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|.*\\.).*)"],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
3
apps/web/src/pages/api/auth/[...nextauth].ts
Normal file
3
apps/web/src/pages/api/auth/[...nextauth].ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { AuthApiRoute } from "@/utils/auth";
|
||||
|
||||
export default AuthApiRoute;
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
251
apps/web/src/utils/auth.ts
Normal 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;
|
||||
};
|
71
apps/web/src/utils/auth/legacy-token-provider.ts
Normal file
71
apps/web/src/utils/auth/legacy-token-provider.ts
Normal 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;
|
||||
},
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
19
apps/web/src/utils/emails.ts
Normal file
19
apps/web/src/utils/emails.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: {} };
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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" {
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
export type RegistrationTokenPayload = {
|
||||
name: string;
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type LoginTokenPayload = {
|
||||
userId: string;
|
||||
locale?: string;
|
||||
timeZone?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
|
|
|
@ -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");
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
113
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue