mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-06 21:56:03 +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
|
- name: Set environment variables
|
||||||
run: |
|
run: |
|
||||||
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV
|
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/rallly" >> $GITHUB_ENV
|
||||||
echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV
|
|
||||||
echo "SUPPORT_EMAIL=support@rallly.co" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --frozen-lockfile
|
run: yarn install --frozen-lockfile
|
||||||
|
@ -68,7 +66,7 @@ jobs:
|
||||||
- name: Run db
|
- name: Run db
|
||||||
run: |
|
run: |
|
||||||
docker pull postgres:14.2
|
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
|
yarn wait-on --timeout 60000 tcp:localhost:5432
|
||||||
|
|
||||||
- name: Deploy migrations
|
- 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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|
|
@ -11,11 +11,12 @@
|
||||||
"lint:tsc": "tsc --noEmit",
|
"lint:tsc": "tsc --noEmit",
|
||||||
"i18n:scan": "i18next-scanner --config i18next-scanner.config.js",
|
"i18n:scan": "i18next-scanner --config i18next-scanner.config.js",
|
||||||
"prettier": "prettier --write ./src",
|
"prettier": "prettier --write ./src",
|
||||||
"test": "cross-env PORT=3001 playwright test",
|
"test": "playwright test",
|
||||||
"test:codegen": "playwright codegen http://localhost:3000",
|
"test:codegen": "playwright codegen http://localhost:3000",
|
||||||
"docker:start": "./scripts/docker-start.sh"
|
"docker:start": "./scripts/docker-start.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^1.0.3",
|
||||||
"@floating-ui/react-dom-interactions": "^0.13.3",
|
"@floating-ui/react-dom-interactions": "^0.13.3",
|
||||||
"@headlessui/react": "^1.7.7",
|
"@headlessui/react": "^1.7.7",
|
||||||
"@hookform/resolvers": "^3.3.1",
|
"@hookform/resolvers": "^3.3.1",
|
||||||
|
@ -38,6 +39,7 @@
|
||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"color-hash": "^2.0.2",
|
"color-hash": "^2.0.2",
|
||||||
|
"cookie": "^0.5.0",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"i18next": "^22.4.9",
|
"i18next": "^22.4.9",
|
||||||
|
@ -49,6 +51,7 @@
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
|
"next-auth": "^4.23.2",
|
||||||
"next-i18next": "^13.0.3",
|
"next-i18next": "^13.0.3",
|
||||||
"next-seo": "^5.15.0",
|
"next-seo": "^5.15.0",
|
||||||
"php-serialize": "^4.1.1",
|
"php-serialize": "^4.1.1",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import path from "path";
|
||||||
|
|
||||||
const ci = process.env.CI === "true";
|
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
|
// Use process.env.PORT by default and fallback to port 3000
|
||||||
const PORT = process.env.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
|
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
|
||||||
const baseURL = `http://localhost:${PORT}`;
|
const baseURL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
process.env.NEXT_PUBLIC_BASE_URL = baseURL;
|
|
||||||
|
|
||||||
// Reference: https://playwright.dev/docs/test-configuration
|
// Reference: https://playwright.dev/docs/test-configuration
|
||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
// Artifacts folder where screenshots, videos, and traces are stored.
|
// Artifacts folder where screenshots, videos, and traces are stored.
|
||||||
|
@ -31,6 +29,9 @@ const config: PlaywrightTestConfig = {
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
reuseExistingServer: !ci,
|
reuseExistingServer: !ci,
|
||||||
},
|
},
|
||||||
|
expect: {
|
||||||
|
timeout: 10000, // 10 seconds
|
||||||
|
},
|
||||||
reporter: [
|
reporter: [
|
||||||
[ci ? "github" : "list"],
|
[ci ? "github" : "list"],
|
||||||
["html", { open: !ci ? "on-failure" : "never" }],
|
["html", { open: !ci ? "on-failure" : "never" }],
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { trpc } from "@rallly/backend";
|
import { trpc } from "@rallly/backend";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -13,32 +15,22 @@ import { TextInput } from "../text-input";
|
||||||
|
|
||||||
export const useDefaultEmail = createGlobalState("");
|
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;
|
email: string;
|
||||||
onSubmit: (code: string) => Promise<void>;
|
onSubmit: (code: string) => Promise<void>;
|
||||||
onResend: () => Promise<void>;
|
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
}> = ({ onChange, onSubmit, email, onResend }) => {
|
}> = ({ onChange, onSubmit, email }) => {
|
||||||
const { register, handleSubmit, setError, formState } = useForm<{
|
const { register, handleSubmit, setError, formState } = useForm<{
|
||||||
code: string;
|
code: string;
|
||||||
}>();
|
}>();
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -110,184 +102,18 @@ const VerifyCode: React.FunctionComponent<{
|
||||||
>
|
>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={handleResend}
|
|
||||||
loading={resendStatus === "busy"}
|
|
||||||
disabled={resendStatus === "disabled"}
|
|
||||||
>
|
|
||||||
{t("resendVerificationCode")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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<{
|
export const LoginForm: React.FunctionComponent<{
|
||||||
onClickRegister?: (
|
onClickRegister?: (
|
||||||
e: React.MouseEvent<HTMLAnchorElement>,
|
e: React.MouseEvent<HTMLAnchorElement>,
|
||||||
email: string,
|
email: string,
|
||||||
) => void;
|
) => void;
|
||||||
onAuthenticated?: () => void;
|
}> = ({ onClickRegister }) => {
|
||||||
}> = ({ onAuthenticated, onClickRegister }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
|
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
|
||||||
|
|
||||||
|
@ -297,58 +123,44 @@ export const LoginForm: React.FunctionComponent<{
|
||||||
defaultValues: { email: defaultEmail },
|
defaultValues: { email: defaultEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestLogin = trpc.auth.requestLogin.useMutation();
|
const session = useSession();
|
||||||
const authenticateLogin = trpc.auth.authenticateLogin.useMutation();
|
|
||||||
|
|
||||||
const queryClient = trpc.useContext();
|
const queryClient = trpc.useContext();
|
||||||
const [token, setToken] = React.useState<string>();
|
const [email, setEmail] = React.useState<string>();
|
||||||
const posthog = usePostHog();
|
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 (
|
return (
|
||||||
<VerifyCode
|
<VerifyCode
|
||||||
onSubmit={async (code) => {
|
onSubmit={async (code) => {
|
||||||
const res = await authenticateLogin.mutateAsync({
|
const success = await verifyCode({
|
||||||
code,
|
email,
|
||||||
token,
|
token: code,
|
||||||
});
|
});
|
||||||
|
if (!success) {
|
||||||
if (!res.user) {
|
|
||||||
throw new Error("Failed to authenticate user");
|
throw new Error("Failed to authenticate user");
|
||||||
} else {
|
} else {
|
||||||
onAuthenticated?.();
|
|
||||||
queryClient.invalidate();
|
queryClient.invalidate();
|
||||||
posthog?.identify(res.user.id, {
|
const s = await session.update();
|
||||||
email: res.user.email,
|
if (s?.user) {
|
||||||
name: res.user.name,
|
posthog?.identify(s.user.id, {
|
||||||
});
|
email: s.user.email,
|
||||||
posthog?.capture("login");
|
name: s.user.name,
|
||||||
}
|
});
|
||||||
}}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
posthog?.capture("login");
|
||||||
|
router.push(callbackUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={() => setToken(undefined)}
|
onChange={() => setEmail(undefined)}
|
||||||
email={getValues("email")}
|
email={getValues("email")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -356,26 +168,15 @@ export const LoginForm: React.FunctionComponent<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(async (data) => {
|
onSubmit={handleSubmit(async ({ email }) => {
|
||||||
const res = await requestLogin.mutateAsync({
|
const res = await sendVerificationEmail(email);
|
||||||
email: data.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res?.error) {
|
||||||
setToken(res.token);
|
setError("email", {
|
||||||
|
message: t("userNotFound"),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
switch (res.reason) {
|
setEmail(email);
|
||||||
case "emailNotAllowed":
|
|
||||||
setError("email", {
|
|
||||||
message: t("emailNotAllowed"),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "userNotFound":
|
|
||||||
setError("email", {
|
|
||||||
message: t("userNotFound"),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { trpc } from "@rallly/backend";
|
|
||||||
import { GlobeIcon } from "@rallly/icons";
|
import { GlobeIcon } from "@rallly/icons";
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
import {
|
import {
|
||||||
|
@ -19,36 +18,12 @@ import soft from "timezone-soft";
|
||||||
import { TimeFormatPicker } from "@/components/time-format-picker";
|
import { TimeFormatPicker } from "@/components/time-format-picker";
|
||||||
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { usePreferences } from "@/contexts/preferences";
|
||||||
import { useDayjs } from "@/utils/dayjs";
|
import { useDayjs } from "@/utils/dayjs";
|
||||||
|
|
||||||
export const TimePreferences = () => {
|
export const TimePreferences = () => {
|
||||||
const { timeZone, timeFormat } = useDayjs();
|
const { preferences, updatePreferences } = usePreferences();
|
||||||
const queryClient = trpc.useContext();
|
const { timeFormat, timeZone } = useDayjs();
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
@ -57,11 +32,9 @@ export const TimePreferences = () => {
|
||||||
<Trans i18nKey="timeZone" />
|
<Trans i18nKey="timeZone" />
|
||||||
</Label>
|
</Label>
|
||||||
<TimeZoneSelect
|
<TimeZoneSelect
|
||||||
value={timeZone}
|
value={preferences.timeZone ?? timeZone}
|
||||||
onValueChange={(newTimeZone) => {
|
onValueChange={(newTimeZone) => {
|
||||||
updatePreferences.mutate({
|
updatePreferences({ timeZone: newTimeZone });
|
||||||
timeZone: newTimeZone,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -70,11 +43,9 @@ export const TimePreferences = () => {
|
||||||
<Trans i18nKey="timeFormat" />
|
<Trans i18nKey="timeFormat" />
|
||||||
</Label>
|
</Label>
|
||||||
<TimeFormatPicker
|
<TimeFormatPicker
|
||||||
value={timeFormat}
|
value={preferences.timeFormat ?? timeFormat}
|
||||||
onChange={(newTimeFormat) => {
|
onChange={(newTimeFormat) => {
|
||||||
updatePreferences.mutate({
|
updatePreferences({ timeFormat: newTimeFormat });
|
||||||
timeFormat: newTimeFormat,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { trpc } from "@rallly/backend";
|
|
||||||
import { HelpCircleIcon } from "@rallly/icons";
|
import { HelpCircleIcon } from "@rallly/icons";
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
|
@ -7,6 +6,7 @@ import Script from "next/script";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useUser } from "@/components/user-provider";
|
||||||
import { isFeedbackEnabled } from "@/utils/constants";
|
import { isFeedbackEnabled } from "@/utils/constants";
|
||||||
|
|
||||||
const FeaturebaseScript = () => (
|
const FeaturebaseScript = () => (
|
||||||
|
@ -63,7 +63,7 @@ export const FeaturebaseChangelog = ({ className }: { className?: string }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeaturebaseIdentify = () => {
|
export const FeaturebaseIdentify = () => {
|
||||||
const { data: user } = trpc.whoami.get.useQuery();
|
const { user } = useUser();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user?.isGuest !== false || !isFeedbackEnabled) return;
|
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,
|
TopBar,
|
||||||
TopBarTitle,
|
TopBarTitle,
|
||||||
} from "@/components/layouts/standard-layout/top-bar";
|
} from "@/components/layouts/standard-layout/top-bar";
|
||||||
|
import { LoginLink } from "@/components/login-link";
|
||||||
import {
|
import {
|
||||||
PageDialog,
|
PageDialog,
|
||||||
PageDialogDescription,
|
PageDialogDescription,
|
||||||
|
@ -157,7 +158,7 @@ const AdminControls = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<TopBar>
|
<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">
|
<div className="flex min-w-0 gap-4">
|
||||||
{router.asPath !== pollLink ? (
|
{router.asPath !== pollLink ? (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
|
@ -219,10 +220,10 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
|
||||||
<PageDialogFooter>
|
<PageDialogFooter>
|
||||||
{user.isGuest ? (
|
{user.isGuest ? (
|
||||||
<Button asChild variant="primary" size="lg">
|
<Button asChild variant="primary" size="lg">
|
||||||
<Link href="/login">
|
<LoginLink>
|
||||||
<LogInIcon className="-ml-1 h-5 w-5" />
|
<LogInIcon className="-ml-1 h-5 w-5" />
|
||||||
<Trans i18nKey="login" defaults="Login" />
|
<Trans i18nKey="login" defaults="Login" />
|
||||||
</Link>
|
</LoginLink>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button asChild variant="primary" size="lg">
|
<Button asChild variant="primary" size="lg">
|
||||||
|
@ -271,7 +272,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
|
||||||
if (!poll.data || !watchers.data || !participants.data) {
|
if (!poll.data || !watchers.data || !participants.data) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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" />
|
<Skeleton className="my-2 h-5 w-48" />
|
||||||
<div className="flex gap-x-2">
|
<div className="flex gap-x-2">
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24" />
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { IfCloudHosted } from "@/contexts/environment";
|
||||||
import { Plan } from "@/contexts/plan";
|
import { Plan } from "@/contexts/plan";
|
||||||
|
|
||||||
import { IconComponent, NextPageWithLayout } from "../../types";
|
import { IconComponent, NextPageWithLayout } from "../../types";
|
||||||
import { useUser } from "../user-provider";
|
import { IfAuthenticated, useUser } from "../user-provider";
|
||||||
|
|
||||||
const MenuItem = (props: {
|
const MenuItem = (props: {
|
||||||
icon: IconComponent;
|
icon: IconComponent;
|
||||||
|
@ -79,9 +79,11 @@ export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
|
||||||
</div>
|
</div>
|
||||||
<Plan />
|
<Plan />
|
||||||
</div>
|
</div>
|
||||||
<MenuItem href="/settings/profile" icon={UserIcon}>
|
<IfAuthenticated>
|
||||||
<Trans i18nKey="profile" defaults="Profile" />
|
<MenuItem href="/settings/profile" icon={UserIcon}>
|
||||||
</MenuItem>
|
<Trans i18nKey="profile" defaults="Profile" />
|
||||||
|
</MenuItem>
|
||||||
|
</IfAuthenticated>
|
||||||
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
|
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
|
||||||
<Trans i18nKey="preferences" defaults="Preferences" />
|
<Trans i18nKey="preferences" defaults="Preferences" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
FeaturebaseIdentify,
|
FeaturebaseIdentify,
|
||||||
} from "@/components/featurebase";
|
} from "@/components/featurebase";
|
||||||
import FeedbackButton from "@/components/feedback";
|
import FeedbackButton from "@/components/feedback";
|
||||||
|
import { LoginLink } from "@/components/login-link";
|
||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { Spinner } from "@/components/spinner";
|
import { Spinner } from "@/components/spinner";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
@ -22,7 +23,7 @@ import { UserDropdown } from "@/components/user-dropdown";
|
||||||
import { IfCloudHosted } from "@/contexts/environment";
|
import { IfCloudHosted } from "@/contexts/environment";
|
||||||
import { IfFreeUser } from "@/contexts/plan";
|
import { IfFreeUser } from "@/contexts/plan";
|
||||||
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
|
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
|
||||||
import { DayjsProvider } from "@/utils/dayjs";
|
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
||||||
|
|
||||||
import { IconComponent, NextPageWithLayout } from "../../types";
|
import { IconComponent, NextPageWithLayout } from "../../types";
|
||||||
import ModalProvider from "../modal/modal-provider";
|
import ModalProvider from "../modal/modal-provider";
|
||||||
|
@ -153,9 +154,9 @@ const MainNav = () => {
|
||||||
asChild
|
asChild
|
||||||
className="hidden sm:flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<Link href="/login">
|
<LoginLink>
|
||||||
<Trans i18nKey="login" defaults="Login" />
|
<Trans i18nKey="login" defaults="Login" />
|
||||||
</Link>
|
</LoginLink>
|
||||||
</Button>
|
</Button>
|
||||||
</IfGuest>
|
</IfGuest>
|
||||||
<IfCloudHosted>
|
<IfCloudHosted>
|
||||||
|
@ -179,10 +180,9 @@ export const StandardLayout: React.FunctionComponent<{
|
||||||
hideNav?: boolean;
|
hideNav?: boolean;
|
||||||
}> = ({ children, hideNav, ...rest }) => {
|
}> = ({ children, hideNav, ...rest }) => {
|
||||||
const key = hideNav ? "no-nav" : "nav";
|
const key = hideNav ? "no-nav" : "nav";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<DayjsProvider>
|
<ConnectedDayjsProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<div className="flex min-h-screen flex-col" {...rest}>
|
<div className="flex min-h-screen flex-col" {...rest}>
|
||||||
|
@ -222,7 +222,7 @@ export const StandardLayout: React.FunctionComponent<{
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</DayjsProvider>
|
</ConnectedDayjsProvider>
|
||||||
</UserProvider>
|
</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">
|
<Container className="flex h-[calc(75vh)] items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{props.icon ? (
|
{props.icon ? (
|
||||||
<p className="text-primary text-base font-semibold">
|
<props.icon className="text-primary inline-block h-14 w-14" />
|
||||||
<props.icon className="inline-block h-14 w-14" />
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,16 +17,16 @@ export const PageDialog = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageDialogContent = (props: React.PropsWithChildren) => {
|
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) => {
|
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) => {
|
export const PageDialogFooter = (props: React.PropsWithChildren) => {
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { trpc } from "@rallly/backend";
|
||||||
import { BellOffIcon, BellRingIcon } from "@rallly/icons";
|
import { BellOffIcon, BellRingIcon } from "@rallly/icons";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
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 { useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
@ -31,7 +31,6 @@ const NotificationsToggle: React.FunctionComponent = () => {
|
||||||
|
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const watch = trpc.polls.watch.useMutation({
|
const watch = trpc.polls.watch.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// TODO (Luke Vella) [2023-04-08]: We should have a separate query for getting watchers
|
// 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"
|
className="flex items-center gap-2 px-2.5"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (user.isGuest) {
|
if (user.isGuest) {
|
||||||
// TODO (Luke Vella) [2023-06-06]: Open Login Modal
|
signIn();
|
||||||
router.push("/login");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// toggle
|
// 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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { trpc } from "@rallly/backend";
|
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
||||||
import {
|
import {
|
||||||
|
@ -16,6 +15,7 @@ import { z } from "zod";
|
||||||
import { TimeFormatPicker } from "@/components/time-format-picker";
|
import { TimeFormatPicker } from "@/components/time-format-picker";
|
||||||
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { usePreferences } from "@/contexts/preferences";
|
||||||
import { useDayjs } from "@/utils/dayjs";
|
import { useDayjs } from "@/utils/dayjs";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
@ -27,42 +27,25 @@ const formSchema = z.object({
|
||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const DateTimePreferencesForm = () => {
|
const DateTimePreferencesForm = () => {
|
||||||
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
|
const { timeFormat, weekStart, timeZone, locale } = useDayjs();
|
||||||
|
const { preferences, updatePreferences } = usePreferences();
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
timeFormat: preferences.timeFormat ?? timeFormat,
|
||||||
|
weekStart: preferences.weekStart ?? weekStart,
|
||||||
|
timeZone: preferences.timeZone ?? timeZone,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = form;
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(async (data) => {
|
onSubmit={handleSubmit(async (data) => {
|
||||||
await update.mutateAsync(data);
|
updatePreferences(data);
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
@ -70,7 +53,6 @@ const DateTimePreferencesForm = () => {
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="timeZone"
|
name="timeZone"
|
||||||
defaultValue={userPreferences?.timeZone ?? timeZone}
|
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -88,7 +70,6 @@ const DateTimePreferencesForm = () => {
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="timeFormat"
|
name="timeFormat"
|
||||||
defaultValue={userPreferences?.timeFormat ?? localeTimeFormat}
|
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -106,7 +87,6 @@ const DateTimePreferencesForm = () => {
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="weekStart"
|
name="weekStart"
|
||||||
defaultValue={userPreferences?.weekStart ?? localeWeekStart}
|
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -144,12 +124,17 @@ const DateTimePreferencesForm = () => {
|
||||||
>
|
>
|
||||||
<Trans i18nKey="save" />
|
<Trans i18nKey="save" />
|
||||||
</Button>
|
</Button>
|
||||||
{userPreferences !== null ? (
|
{preferences.timeFormat || preferences.weekStart ? (
|
||||||
<Button
|
<Button
|
||||||
loading={deleteUserPreferences.isLoading}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteUserPreferences.mutateAsync();
|
updatePreferences({
|
||||||
form.reset(locale);
|
weekStart: null,
|
||||||
|
timeFormat: null,
|
||||||
|
});
|
||||||
|
form.reset({
|
||||||
|
weekStart: locale.weekStart,
|
||||||
|
timeFormat: locale.timeFormat,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans
|
<Trans
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
|
import { trpc } from "@rallly/backend";
|
||||||
import { ArrowUpRight } from "@rallly/icons";
|
import { ArrowUpRight } from "@rallly/icons";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { LanguageSelect } from "@/components/poll/language-selector";
|
import { LanguageSelect } from "@/components/poll/language-selector";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { updateLanguage } from "@/contexts/preferences";
|
import { useUser } from "@/components/user-provider";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
|
@ -19,6 +21,7 @@ type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
export const LanguagePreference = () => {
|
export const LanguagePreference = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const { user } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
@ -26,11 +29,17 @@ export const LanguagePreference = () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updatePreferences = trpc.user.updatePreferences.useMutation();
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(async (data) => {
|
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();
|
router.reload();
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { trpc } from "@rallly/backend";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -15,7 +14,7 @@ import { UserAvatar } from "@/components/user";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { useUser } from "@/components/user-provider";
|
||||||
|
|
||||||
export const ProfileSettings = () => {
|
export const ProfileSettings = () => {
|
||||||
const { user } = useUser();
|
const { user, refresh } = useUser();
|
||||||
|
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -23,7 +22,7 @@ export const ProfileSettings = () => {
|
||||||
}>({
|
}>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user.isGuest ? "" : user.name,
|
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 watchName = watch("name");
|
||||||
|
|
||||||
const queryClient = trpc.useContext();
|
|
||||||
const changeName = trpc.user.changeName.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.whoami.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-y-4">
|
<div className="grid gap-y-4">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(async (data) => {
|
onSubmit={handleSubmit(async (data) => {
|
||||||
await changeName.mutateAsync({ name: data.name });
|
await refresh({ name: data.name });
|
||||||
reset(data);
|
reset(data);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { CommandList } from "cmdk";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import spacetime from "spacetime";
|
||||||
|
|
||||||
import { Trans } from "@/components/trans";
|
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>(
|
export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
({ value, onValueChange, disabled }, ref) => {
|
({ value, onValueChange, disabled }, ref) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const popoverContentId = "timeZoneSelect__popoverContent";
|
const popoverContentId = "timeZoneSelect__popoverContent";
|
||||||
|
const fuzzyValue = value ? findFuzzyTz(value) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover modal={false} open={open} onOpenChange={setOpen}>
|
<Popover modal={false} open={open} onOpenChange={setOpen}>
|
||||||
|
@ -97,8 +156,8 @@ export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
>
|
>
|
||||||
<GlobeIcon className="h-4 w-4" />
|
<GlobeIcon className="h-4 w-4" />
|
||||||
<span className="grow truncate text-left">
|
<span className="grow truncate text-left">
|
||||||
{value ? (
|
{fuzzyValue ? (
|
||||||
options.find((option) => option.value === value)?.label
|
fuzzyValue.label
|
||||||
) : (
|
) : (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="timeZoneSelect__defaultValue"
|
i18nKey="timeZoneSelect__defaultValue"
|
||||||
|
|
|
@ -22,7 +22,10 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@rallly/ui/dropdown-menu";
|
} from "@rallly/ui/dropdown-menu";
|
||||||
import Link from "next/link";
|
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 { Trans } from "@/components/trans";
|
||||||
import { CurrentUserAvatar } from "@/components/user";
|
import { CurrentUserAvatar } from "@/components/user";
|
||||||
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
|
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
|
||||||
|
@ -60,12 +63,17 @@ export const UserDropdown = () => {
|
||||||
<Trans i18nKey="polls" defaults="Polls" />
|
<Trans i18nKey="polls" defaults="Polls" />
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild={true}>
|
<IfAuthenticated>
|
||||||
<Link href="/settings/profile" className="flex items-center gap-x-2">
|
<DropdownMenuItem asChild={true}>
|
||||||
<UserIcon className="h-4 w-4" />
|
<Link
|
||||||
<Trans i18nKey="profile" defaults="Profile" />
|
href="/settings/profile"
|
||||||
</Link>
|
className="flex items-center gap-x-2"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<UserIcon className="h-4 w-4" />
|
||||||
|
<Trans i18nKey="profile" defaults="Profile" />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</IfAuthenticated>
|
||||||
<DropdownMenuItem asChild={true}>
|
<DropdownMenuItem asChild={true}>
|
||||||
<Link
|
<Link
|
||||||
href="/settings/preferences"
|
href="/settings/preferences"
|
||||||
|
@ -124,30 +132,36 @@ export const UserDropdown = () => {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<IfGuest>
|
<IfGuest>
|
||||||
<DropdownMenuItem asChild={true}>
|
<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" />
|
<LogInIcon className="h-4 w-4" />
|
||||||
<Trans i18nKey="login" defaults="login" />
|
<Trans i18nKey="login" defaults="login" />
|
||||||
</Link>
|
</LoginLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild={true}>
|
<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" />
|
<UserPlusIcon className="h-4 w-4" />
|
||||||
<Trans i18nKey="createAnAccount" defaults="Register" />
|
<Trans i18nKey="createAnAccount" defaults="Register" />
|
||||||
</Link>
|
</RegisterLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild={true}>
|
<DropdownMenuItem
|
||||||
<Link href="/logout" className="flex items-center gap-x-2">
|
className="flex items-center gap-x-2"
|
||||||
<RefreshCcwIcon className="h-4 w-4" />
|
onSelect={() =>
|
||||||
<Trans i18nKey="forgetMe" />
|
signOut({
|
||||||
</Link>
|
redirect: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCcwIcon className="h-4 w-4" />
|
||||||
|
<Trans i18nKey="forgetMe" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</IfGuest>
|
</IfGuest>
|
||||||
<IfAuthenticated>
|
<IfAuthenticated>
|
||||||
<DropdownMenuItem asChild={true}>
|
<DropdownMenuItem
|
||||||
<Link href="/logout" className="flex items-center gap-x-2">
|
className="flex items-center gap-x-2"
|
||||||
<LogOutIcon className="h-4 w-4" />
|
onSelect={() => signOut()}
|
||||||
<Trans i18nKey="logout" />
|
>
|
||||||
</Link>
|
<LogOutIcon className="h-4 w-4" />
|
||||||
|
<Trans i18nKey="logout" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</IfAuthenticated>
|
</IfAuthenticated>
|
||||||
</DropdownMenuContent>
|
</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 { useTranslation } from "next-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PostHogProvider } from "@/contexts/posthog";
|
import { PostHogProvider } from "@/contexts/posthog";
|
||||||
import { useWhoAmI } from "@/contexts/whoami";
|
import { PreferencesProvider } from "@/contexts/preferences";
|
||||||
import { isSelfHosted } from "@/utils/constants";
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
|
||||||
import { useRequiredContext } from "./use-required-context";
|
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<{
|
export const UserContext = React.createContext<{
|
||||||
user: UserSession & { name: string };
|
user: z.infer<typeof userSchema>;
|
||||||
refresh: () => void;
|
refresh: (data?: Record<string, unknown>) => Promise<Session | null>;
|
||||||
ownsObject: (obj: { userId: string | null }) => boolean;
|
ownsObject: (obj: { userId: string | null }) => boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
@ -46,66 +60,67 @@ export const IfGuest = (props: { children?: React.ReactNode }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserProvider = (props: { children?: React.ReactNode }) => {
|
export const UserProvider = (props: { children?: React.ReactNode }) => {
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
const user = session.data?.user;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const queryClient = trpc.useContext();
|
React.useEffect(() => {
|
||||||
|
if (session.status === "unauthenticated") {
|
||||||
const user = useWhoAmI();
|
// Begin: Legacy token migration
|
||||||
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
|
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
|
// 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, {
|
trpc.user.subscription.useQuery(undefined, {
|
||||||
enabled: !isSelfHosted,
|
enabled: !isSelfHosted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const name = user
|
if (!user || !session.data) {
|
||||||
? user.isGuest === false
|
|
||||||
? user.name
|
|
||||||
: user.id.substring(0, 10)
|
|
||||||
: t("guest");
|
|
||||||
|
|
||||||
if (!user || userPreferences === undefined) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider
|
<UserContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user: { ...user, name },
|
user: {
|
||||||
refresh: () => {
|
id: user.id as string,
|
||||||
return queryClient.whoami.invalidate();
|
name: user.name ?? t("guest"),
|
||||||
|
email: user.email || null,
|
||||||
|
isGuest: user.email === null,
|
||||||
},
|
},
|
||||||
|
refresh: session.update,
|
||||||
ownsObject: ({ userId }) => {
|
ownsObject: ({ userId }) => {
|
||||||
return userId ? [user.id].includes(userId) : false;
|
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>
|
</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 { TimeFormat } from "@rallly/database";
|
||||||
import Cookies from "js-cookie";
|
import React from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useSetState } from "react-use";
|
||||||
|
|
||||||
import { getBrowserTimeZone } from "@/utils/date-time-utils";
|
type Preferences = {
|
||||||
import { useDayjs } from "@/utils/dayjs";
|
timeZone?: string | null;
|
||||||
|
locale?: string | null;
|
||||||
export const useSystemPreferences = () => {
|
timeFormat?: TimeFormat | null;
|
||||||
const { i18n } = useTranslation();
|
weekStart?: number | null;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateLanguage = (language: string) => {
|
type PreferencesContextValue = {
|
||||||
Cookies.set("NEXT_LOCALE", language, {
|
preferences: Preferences;
|
||||||
expires: 30,
|
updatePreferences: (preferences: Partial<Preferences>) => void;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUserPreferences = () => {
|
const PreferencesContext = React.createContext<PreferencesContextValue>({
|
||||||
const { data, isFetched } = trpc.userPreferences.get.useQuery(undefined, {
|
preferences: {},
|
||||||
staleTime: Infinity,
|
updatePreferences: () => {},
|
||||||
cacheTime: Infinity,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
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
|
return (
|
||||||
// by looking at the accept-language header.
|
<PreferencesContext.Provider
|
||||||
if (isFetched) {
|
value={{
|
||||||
return {
|
preferences,
|
||||||
automatic: data === null,
|
updatePreferences: (newPreferences) => {
|
||||||
timeFormat: data?.timeFormat ?? sytemPreferences.timeFormat,
|
setPreferences(newPreferences);
|
||||||
weekStart: data?.weekStart ?? sytemPreferences.weekStart,
|
onUpdate?.(newPreferences);
|
||||||
timeZone: data?.timeZone ?? sytemPreferences.timeZone,
|
},
|
||||||
} as const;
|
}}
|
||||||
}
|
>
|
||||||
|
{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 languages from "@rallly/languages";
|
||||||
import languageParser from "accept-language-parser";
|
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);
|
const supportedLocales = Object.keys(languages);
|
||||||
|
|
||||||
// these paths are always public
|
export default withAuth(
|
||||||
const publicPaths = ["/login", "/register", "/invite", "/auth"];
|
function middleware(req) {
|
||||||
// these paths always require authentication
|
const { headers, nextUrl } = req;
|
||||||
const protectedPaths = ["/settings/profile"];
|
const newUrl = nextUrl.clone();
|
||||||
|
|
||||||
const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => {
|
// if the user is already logged in, don't let them access the login page
|
||||||
const session = await getSession(req, res);
|
if (
|
||||||
const isGuest = session.user?.isGuest !== false;
|
/^\/(login|register)/.test(newUrl.pathname) &&
|
||||||
|
req.nextauth.token?.email
|
||||||
|
) {
|
||||||
|
newUrl.pathname = "/";
|
||||||
|
return NextResponse.redirect(newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isGuest) {
|
// Check if locale is specified in cookie
|
||||||
// already logged in
|
const preferredLocale = req.nextauth.token?.locale;
|
||||||
return false;
|
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 (acceptLanguageHeader) {
|
||||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === "true") {
|
const locale = languageParser.pick(
|
||||||
// when self-hosting, only public paths don't require login
|
supportedLocales,
|
||||||
return !publicPaths.some((publicPath) =>
|
acceptLanguageHeader,
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
if (locale) {
|
||||||
const { headers, cookies, nextUrl } = req;
|
newUrl.pathname = `/${locale}${newUrl.pathname}`;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],
|
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],
|
||||||
|
|
|
@ -5,7 +5,7 @@ import React from "react";
|
||||||
import ErrorPage from "@/components/error-page";
|
import ErrorPage from "@/components/error-page";
|
||||||
import { getStandardLayout } from "@/components/layouts/standard-layout";
|
import { getStandardLayout } from "@/components/layouts/standard-layout";
|
||||||
import { NextPageWithLayout } from "@/types";
|
import { NextPageWithLayout } from "@/types";
|
||||||
import { withPageTranslations } from "@/utils/with-page-translations";
|
import { getStaticTranslations } from "@/utils/with-page-translations";
|
||||||
|
|
||||||
const Custom404: NextPageWithLayout = () => {
|
const Custom404: NextPageWithLayout = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -20,6 +20,6 @@ const Custom404: NextPageWithLayout = () => {
|
||||||
|
|
||||||
Custom404.getLayout = getStandardLayout;
|
Custom404.getLayout = getStandardLayout;
|
||||||
|
|
||||||
export const getStaticProps = withPageTranslations();
|
export const getStaticProps = getStaticTranslations;
|
||||||
|
|
||||||
export default Custom404;
|
export default Custom404;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||||
import "tailwindcss/tailwind.css";
|
import "tailwindcss/tailwind.css";
|
||||||
import "../style.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 { TooltipProvider } from "@rallly/ui/tooltip";
|
||||||
import { domMax, LazyMotion } from "framer-motion";
|
import { domMax, LazyMotion } from "framer-motion";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
@ -10,9 +10,9 @@ import { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { appWithTranslation } from "next-i18next";
|
import { appWithTranslation } from "next-i18next";
|
||||||
import { DefaultSeo } from "next-seo";
|
import { DefaultSeo } from "next-seo";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import Maintenance from "@/components/maintenance";
|
import Maintenance from "@/components/maintenance";
|
||||||
|
|
||||||
|
@ -25,12 +25,8 @@ const inter = Inter({
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
type PageProps = {
|
type AppPropsWithLayout = AppProps & {
|
||||||
user?: UserSession;
|
Component: NextPageWithLayout;
|
||||||
};
|
|
||||||
|
|
||||||
type AppPropsWithLayout = AppProps<PageProps> & {
|
|
||||||
Component: NextPageWithLayout<PageProps>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
|
const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
|
||||||
|
@ -41,54 +37,56 @@ const MyApp: NextPage<AppPropsWithLayout> = ({ Component, pageProps }) => {
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
const getLayout = Component.getLayout ?? ((page) => page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domMax}>
|
<SessionProvider>
|
||||||
<DefaultSeo
|
<LazyMotion features={domMax}>
|
||||||
openGraph={{
|
<DefaultSeo
|
||||||
siteName: "Rallly",
|
openGraph={{
|
||||||
type: "website",
|
siteName: "Rallly",
|
||||||
url: absoluteUrl(),
|
type: "website",
|
||||||
images: [
|
url: absoluteUrl(),
|
||||||
{
|
images: [
|
||||||
url: absoluteUrl("/og-image-1200.png"),
|
{
|
||||||
width: 1200,
|
url: absoluteUrl("/og-image-1200.png"),
|
||||||
height: 630,
|
width: 1200,
|
||||||
alt: "Rallly | Schedule group meetings",
|
height: 630,
|
||||||
type: "image/png",
|
alt: "Rallly | Schedule group meetings",
|
||||||
},
|
type: "image/png",
|
||||||
],
|
},
|
||||||
}}
|
],
|
||||||
facebook={{
|
}}
|
||||||
appId: "920386682263077",
|
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),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
<Head>
|
||||||
<style jsx global>{`
|
<meta
|
||||||
html {
|
name="viewport"
|
||||||
--font-inter: ${inter.style.fontFamily};
|
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
|
||||||
}
|
/>
|
||||||
`}</style>
|
</Head>
|
||||||
<TooltipProvider delayDuration={200}>
|
{process.env.NEXT_PUBLIC_PADDLE_VENDOR_ID ? (
|
||||||
{getLayout(<Component {...pageProps} />)}
|
<Script
|
||||||
</TooltipProvider>
|
src="https://cdn.paddle.com/paddle/paddle.js"
|
||||||
</LazyMotion>
|
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 { prisma } from "@rallly/database";
|
||||||
import { ArrowRightIcon, ShieldCloseIcon } from "@rallly/icons";
|
import { GetStaticPaths, GetStaticProps } from "next";
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import { GetServerSideProps } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import { getStandardLayout } from "@/components/layouts/standard-layout";
|
const Page = () => {
|
||||||
import {
|
return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Page.getLayout = getStandardLayout;
|
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = withSessionSsr(
|
export const getStaticPaths: GetStaticPaths = () => {
|
||||||
composeGetServerSideProps(async (ctx) => {
|
return {
|
||||||
const res = await prisma.poll.findUnique({
|
paths: [],
|
||||||
where: {
|
fallback: true,
|
||||||
adminUrlId: ctx.params?.urlId as string,
|
};
|
||||||
},
|
};
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
userId: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||||
return {
|
// We get these props to be able to render the og:image
|
||||||
notFound: true,
|
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 (!poll) {
|
||||||
if (res.user || ctx.req.session.user?.id === res.userId) {
|
return { props: {}, notFound: 404 };
|
||||||
// redirect to the poll page
|
}
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: `/poll/${res.id}`,
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise allow the current user to take ownership of the poll
|
return {
|
||||||
return {
|
props: {},
|
||||||
props: {
|
redirect: {
|
||||||
userId: res.userId,
|
destination: `/poll/${poll.id}`,
|
||||||
pollId: res.id,
|
permanent: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, withPageTranslations()),
|
};
|
||||||
);
|
|
||||||
|
|
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 { 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 = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
externalResolver: true,
|
externalResolver: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// export API handler
|
// 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 { DisableNotificationsPayload } from "@rallly/backend";
|
||||||
import {
|
|
||||||
composeGetServerSideProps,
|
|
||||||
withSessionSsr,
|
|
||||||
} from "@rallly/backend/next";
|
|
||||||
import { decryptToken } from "@rallly/backend/session";
|
import { decryptToken } from "@rallly/backend/session";
|
||||||
import { prisma } from "@rallly/database";
|
import { prisma } from "@rallly/database";
|
||||||
import { BellIcon } from "@rallly/icons";
|
import { GetServerSideProps } from "next";
|
||||||
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 { AuthLayout } from "@/components/layouts/auth-layout";
|
import { getServerSession } from "@/utils/auth";
|
||||||
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";
|
|
||||||
|
|
||||||
const Redirect = (props: React.PropsWithChildren<{ redirect: string }>) => {
|
const Page = () => {
|
||||||
const router = useRouter();
|
return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Data = { title: string; adminUrlId: string; pollId: string };
|
export default Page;
|
||||||
|
|
||||||
type PageProps =
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
| {
|
const token = ctx.query.token as string;
|
||||||
error: "pollNotFound" | "invalidToken";
|
const session = await getServerSession(ctx.req, ctx.res);
|
||||||
data: undefined;
|
|
||||||
}
|
|
||||||
| { error: undefined; data: Data };
|
|
||||||
|
|
||||||
const Page: NextPageWithLayout<PageProps> = (props) => {
|
if (!session || session.user.email === null) {
|
||||||
const { t } = useTranslation();
|
return {
|
||||||
const posthog = usePostHog();
|
props: {},
|
||||||
|
redirect: {
|
||||||
useMount(() => {
|
destination:
|
||||||
if (!props.error) {
|
"/login?callbackUrl=" + encodeURIComponent(ctx.req.url ?? "/"),
|
||||||
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 && token) {
|
||||||
const payload = await decryptToken<DisableNotificationsPayload>(token);
|
const payload = await decryptToken<DisableNotificationsPayload>(token);
|
||||||
|
if (payload) {
|
||||||
if (!payload) {
|
const watcher = await prisma.watcher.findFirst({
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
id: watcher.id,
|
userId: session.user.id,
|
||||||
|
pollId: payload.pollId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
if (watcher) {
|
||||||
props: {
|
await prisma.watcher.delete({
|
||||||
data: {
|
where: {
|
||||||
title: watcher.poll.title,
|
id: watcher.id,
|
||||||
adminUrlId: watcher.poll.adminUrlId,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
};
|
|
||||||
} 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 {
|
return {
|
||||||
props: {
|
props: {},
|
||||||
data: {
|
redirect: {
|
||||||
adminUrlId: poll.adminUrlId,
|
destination: `/poll/${payload.pollId}`,
|
||||||
title: poll.title,
|
|
||||||
pollId: poll.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export default Page;
|
return {
|
||||||
|
props: {},
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -1,84 +1,31 @@
|
||||||
import { trpc } from "@rallly/backend";
|
import { InfoIcon } from "@rallly/icons";
|
||||||
import { CheckCircleIcon } from "@rallly/icons";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Link from "next/link";
|
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 {
|
||||||
import { StandardLayout } from "@/components/layouts/standard-layout";
|
PageDialog,
|
||||||
import { Spinner } from "@/components/spinner";
|
PageDialogDescription,
|
||||||
import { NextPageWithLayout } from "@/types";
|
PageDialogFooter,
|
||||||
import { usePostHog } from "@/utils/posthog";
|
PageDialogHeader,
|
||||||
import { getStaticTranslations } from "@/utils/with-page-translations";
|
PageDialogTitle,
|
||||||
|
} from "@/components/page-dialog";
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
<AuthLayout title={t("login")}>
|
<PageDialog icon={InfoIcon}>
|
||||||
{authenticate.isLoading ? (
|
<PageDialogHeader>
|
||||||
<div className="flex items-center gap-4">
|
<PageDialogTitle>Please login again</PageDialogTitle>
|
||||||
<Spinner />
|
<PageDialogDescription>
|
||||||
<Trans i18nKey="loading" />
|
This login was initiated with an older version of Rallly. Please login
|
||||||
</div>
|
again to continue. Sorry for the inconvinience.
|
||||||
) : authenticate.isSuccess ? (
|
</PageDialogDescription>
|
||||||
<div className="space-y-2">
|
</PageDialogHeader>
|
||||||
<div className="flex h-10 items-center justify-center gap-4">
|
<PageDialogFooter>
|
||||||
<CheckCircleIcon className={clsx("h-8 text-green-500")} />
|
<Link href="/login" className="text-link">
|
||||||
</div>
|
Login
|
||||||
<div className="text-gray-800">{t("loginSuccessful")}</div>
|
</Link>
|
||||||
<div className="text-sm text-gray-500">
|
</PageDialogFooter>
|
||||||
<Trans
|
</PageDialog>
|
||||||
t={t}
|
|
||||||
i18nKey="redirect"
|
|
||||||
components={{
|
|
||||||
a: <Link className="underline" href={defaultRedirectPath} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<Trans i18nKey="expiredOrInvalidLink" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Page.getLayout = (page) => {
|
|
||||||
return <StandardLayout hideNav={true}>{page}</StandardLayout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|
||||||
export const getStaticProps = getStaticTranslations;
|
|
||||||
|
|
|
@ -14,11 +14,12 @@ import React from "react";
|
||||||
import { Poll } from "@/components/poll";
|
import { Poll } from "@/components/poll";
|
||||||
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
|
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { UserDropdown } from "@/components/user-dropdown";
|
||||||
import { UserProvider, useUser } from "@/components/user-provider";
|
import { UserProvider, useUser } from "@/components/user-provider";
|
||||||
import { VisibilityProvider } from "@/components/visibility";
|
import { VisibilityProvider } from "@/components/visibility";
|
||||||
import { PermissionsContext } from "@/contexts/permissions";
|
import { PermissionsContext } from "@/contexts/permissions";
|
||||||
import { usePoll } from "@/contexts/poll";
|
import { usePoll } from "@/contexts/poll";
|
||||||
import { DayjsProvider } from "@/utils/dayjs";
|
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
||||||
import { getStaticTranslations } from "@/utils/with-page-translations";
|
import { getStaticTranslations } from "@/utils/with-page-translations";
|
||||||
|
|
||||||
import Error404 from "../404";
|
import Error404 from "../404";
|
||||||
|
@ -66,22 +67,24 @@ const GoToApp = () => {
|
||||||
const poll = usePoll();
|
const poll = usePoll();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
if (poll.user?.id !== user.id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center justify-between gap-2 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Button asChild>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
asChild
|
||||||
|
className={poll.userId !== user.id ? "hidden" : ""}
|
||||||
|
>
|
||||||
<Link href={`/poll/${poll.id}`}>
|
<Link href={`/poll/${poll.id}`}>
|
||||||
<ArrowUpLeftIcon className="h-4 w-4" />
|
<ArrowUpLeftIcon className="h-4 w-4" />
|
||||||
<Trans i18nKey="manage" />
|
<Trans i18nKey="manage" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<div>
|
||||||
</>
|
<UserDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -116,11 +119,11 @@ const Page = ({ id, title, user }: PageProps) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<DayjsProvider>
|
<ConnectedDayjsProvider>
|
||||||
<Prefetch>
|
<Prefetch>
|
||||||
<LegacyPollContextProvider>
|
<LegacyPollContextProvider>
|
||||||
<VisibilityProvider>
|
<VisibilityProvider>
|
||||||
<div className="">
|
<div>
|
||||||
<svg
|
<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"
|
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"
|
aria-hidden="true"
|
||||||
|
@ -144,8 +147,8 @@ const Page = ({ id, title, user }: PageProps) => {
|
||||||
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
|
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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 />
|
<Poll />
|
||||||
<div className="mt-4 space-y-4 text-center text-gray-500">
|
<div className="mt-4 space-y-4 text-center text-gray-500">
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
|
@ -169,7 +172,7 @@ const Page = ({ id, title, user }: PageProps) => {
|
||||||
</VisibilityProvider>
|
</VisibilityProvider>
|
||||||
</LegacyPollContextProvider>
|
</LegacyPollContextProvider>
|
||||||
</Prefetch>
|
</Prefetch>
|
||||||
</DayjsProvider>
|
</ConnectedDayjsProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,40 +1,15 @@
|
||||||
import { Loader2Icon } from "@rallly/icons";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { LoginForm } from "@/components/auth/auth-forms";
|
import { LoginForm } from "@/components/auth/auth-forms";
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
import { StandardLayout } from "@/components/layouts/standard-layout";
|
import { StandardLayout } from "@/components/layouts/standard-layout";
|
||||||
import { PageDialog } from "@/components/page-dialog";
|
|
||||||
import { useWhoAmI } from "@/contexts/whoami";
|
|
||||||
import { NextPageWithLayout } from "@/types";
|
import { NextPageWithLayout } from "@/types";
|
||||||
|
|
||||||
import { getStaticTranslations } from "../utils/with-page-translations";
|
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 Page: NextPageWithLayout<{ referer: string | null }> = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const whoami = useWhoAmI();
|
|
||||||
|
|
||||||
if (whoami?.isGuest === false) {
|
|
||||||
return <Redirect />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { withSessionSsr } from "@rallly/backend/next";
|
import { signOut } from "next-auth/react";
|
||||||
import { NextPage } from "next";
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerSideProps = withSessionSsr(async (ctx) => {
|
Page.getLayout = (page) => {
|
||||||
ctx.req.session.destroy();
|
return <StandardLayout hideNav={true}>{page}</StandardLayout>;
|
||||||
return {
|
};
|
||||||
redirect: {
|
|
||||||
destination: ctx.req.headers.referer ?? "/polls",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Page;
|
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 { prisma } from "@rallly/database";
|
||||||
import { GetServerSideProps } from "next";
|
import { GetStaticPaths, GetStaticProps } from "next";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
export default Page;
|
||||||
const participantUrlId = ctx.query.urlId as string;
|
|
||||||
const token = ctx.query.token as string;
|
|
||||||
|
|
||||||
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: {
|
where: {
|
||||||
participantUrlId: participantUrlId,
|
participantUrlId: ctx.params?.urlId as string,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!poll) {
|
||||||
return { notFound: true };
|
return { props: {}, notFound: 404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
props: {},
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: `/invite/${res.id}${token ? `?token=${token}` : ""}`,
|
destination: `/poll/${poll.id}`,
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { InfoIcon } from "@rallly/icons";
|
import { InfoIcon } from "@rallly/icons";
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
|
||||||
import Link from "next/link";
|
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
|
|
||||||
import { getPollLayout } from "@/components/layouts/poll-layout";
|
import { getPollLayout } from "@/components/layouts/poll-layout";
|
||||||
|
import { LoginLink } from "@/components/login-link";
|
||||||
import { Poll } from "@/components/poll";
|
import { Poll } from "@/components/poll";
|
||||||
|
import { RegisterLink } from "@/components/register-link";
|
||||||
import { useUser } from "@/components/user-provider";
|
import { useUser } from "@/components/user-provider";
|
||||||
import { usePoll } from "@/contexts/poll";
|
import { usePoll } from "@/contexts/poll";
|
||||||
import { NextPageWithLayout } from "@/types";
|
import { NextPageWithLayout } from "@/types";
|
||||||
|
@ -35,16 +36,11 @@ const GuestPollAlert = () => {
|
||||||
i18nKey="guestPollAlertDescription"
|
i18nKey="guestPollAlertDescription"
|
||||||
defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
|
defaults="<0>Create an account</0> or <1>login</1> to claim this poll."
|
||||||
components={[
|
components={[
|
||||||
<Link
|
<RegisterLink
|
||||||
className="hover:text-primary underline"
|
className="hover:text-primary underline"
|
||||||
key="register"
|
key="register"
|
||||||
href="/register"
|
|
||||||
/>,
|
|
||||||
<Link
|
|
||||||
className="hover:text-primary underline"
|
|
||||||
key="login"
|
|
||||||
href="/login"
|
|
||||||
/>,
|
/>,
|
||||||
|
<LoginLink className="hover:text-primary underline" key="login" />,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
|
@ -1,28 +1,196 @@
|
||||||
|
import { trpc } from "@rallly/backend";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
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 { StandardLayout } from "@/components/layouts/standard-layout";
|
||||||
|
import { TextInput } from "@/components/text-input";
|
||||||
import { NextPageWithLayout } from "@/types";
|
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 { AuthLayout } from "../components/auth/auth-layout";
|
||||||
import { getStaticTranslations } from "../utils/with-page-translations";
|
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 Page: NextPageWithLayout = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{t("register")}</title>
|
<title>{t("register")}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<RegisterForm
|
<RegisterForm />
|
||||||
onRegistered={() => {
|
|
||||||
router.replace("/");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AuthLayout>
|
</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 { TimeFormat } from "@rallly/database";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
|
@ -12,10 +11,10 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import updateLocale from "dayjs/plugin/updateLocale";
|
import updateLocale from "dayjs/plugin/updateLocale";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
|
|
||||||
|
import { usePreferences } from "@/contexts/preferences";
|
||||||
import { getBrowserTimeZone } from "@/utils/date-time-utils";
|
import { getBrowserTimeZone } from "@/utils/date-time-utils";
|
||||||
|
|
||||||
import { useRequiredContext } from "../components/use-required-context";
|
import { useRequiredContext } from "../components/use-required-context";
|
||||||
|
@ -166,55 +165,65 @@ const DayjsContext = React.createContext<{
|
||||||
locale: {
|
locale: {
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
timeZone: string;
|
|
||||||
};
|
};
|
||||||
weekStart: number;
|
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
|
weekStart: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
DayjsContext.displayName = "DayjsContext";
|
||||||
|
|
||||||
export const useDayjs = () => {
|
export const useDayjs = () => {
|
||||||
return useRequiredContext(DayjsContext);
|
return useRequiredContext(DayjsContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DayjsProvider: React.FunctionComponent<{
|
export const DayjsProvider: React.FunctionComponent<{
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}> = ({ children }) => {
|
config?: {
|
||||||
const router = useRouter();
|
locale?: string;
|
||||||
|
timeZone?: string;
|
||||||
const localeConfig = dayjsLocales[router.locale ?? "en"];
|
localeOverrides?: {
|
||||||
const { data } = trpc.userPreferences.get.useQuery();
|
weekStart?: number;
|
||||||
|
timeFormat?: TimeFormat;
|
||||||
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, children }) => {
|
||||||
|
const l = config?.locale ?? "en";
|
||||||
|
const state = useAsync(async () => {
|
||||||
|
return await dayjsLocales[l].import();
|
||||||
|
}, [l]);
|
||||||
|
|
||||||
const preferredTimeZone = data?.timeZone ?? locale.timeZone;
|
if (!state.value) {
|
||||||
|
|
||||||
if (state.loading) {
|
|
||||||
// wait for locale to load before rendering
|
// wait for locale to load before rendering
|
||||||
return null;
|
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 (
|
return (
|
||||||
<DayjsContext.Provider
|
<DayjsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -224,13 +233,35 @@ export const DayjsProvider: React.FunctionComponent<{
|
||||||
: dayjs(date).tz(preferredTimeZone);
|
: dayjs(date).tz(preferredTimeZone);
|
||||||
},
|
},
|
||||||
dayjs,
|
dayjs,
|
||||||
locale,
|
locale: localeConfig, // locale defaults
|
||||||
timeZone: preferredTimeZone,
|
timeZone: preferredTimeZone,
|
||||||
weekStart: dayjs.localeData().firstDayOfWeek() === 0 ? 0 : 1,
|
timeFormat:
|
||||||
timeFormat: data?.timeFormat ?? localeConfig.timeFormat,
|
config?.localeOverrides?.timeFormat ?? localeConfig.timeFormat,
|
||||||
|
weekStart: config?.localeOverrides?.weekStart ?? localeConfig.weekStart,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DayjsContext.Provider>
|
</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 {
|
import { GetStaticProps } from "next";
|
||||||
GetServerSideProps,
|
|
||||||
GetServerSidePropsContext,
|
|
||||||
GetStaticProps,
|
|
||||||
} from "next";
|
|
||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
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) => {
|
export const getStaticTranslations: GetStaticProps = async (ctx) => {
|
||||||
const locale = ctx.locale ?? "en";
|
const locale = ctx.locale ?? "en";
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -68,8 +68,6 @@ test.describe.serial(() => {
|
||||||
|
|
||||||
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
|
const codeInput = page.getByPlaceholder("Enter your 6-digit code");
|
||||||
|
|
||||||
codeInput.waitFor({ state: "visible" });
|
|
||||||
|
|
||||||
const code = await getCode();
|
const code = await getCode();
|
||||||
|
|
||||||
await codeInput.type(code);
|
await codeInput.type(code);
|
||||||
|
@ -127,8 +125,6 @@ test.describe.serial(() => {
|
||||||
|
|
||||||
await page.goto(magicLink);
|
await page.goto(magicLink);
|
||||||
|
|
||||||
page.getByText("Click here").click();
|
|
||||||
|
|
||||||
await page.waitForURL("/polls");
|
await page.waitForURL("/polls");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -74,6 +74,8 @@ test.describe(() => {
|
||||||
|
|
||||||
test("should be able to edit submission", async ({ page: newPage }) => {
|
test("should be able to edit submission", async ({ page: newPage }) => {
|
||||||
await newPage.goto(editSubmissionUrl);
|
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 type { IncomingMessage, ServerResponse } from "http";
|
||||||
import { getIronSession } from "iron-session";
|
import { getIronSession } from "iron-session";
|
||||||
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {
|
import { NextApiHandler } from "next";
|
||||||
GetServerSideProps,
|
|
||||||
GetServerSidePropsContext,
|
|
||||||
NextApiHandler,
|
|
||||||
} from "next";
|
|
||||||
|
|
||||||
import { sessionConfig } from "../session-config";
|
import { sessionConfig } from "../session-config";
|
||||||
import { createSSGHelperFromContext } from "../trpc/context";
|
|
||||||
import { composeGetServerSideProps } from "./utils";
|
|
||||||
|
|
||||||
export function withSessionRoute(handler: NextApiHandler) {
|
export function withSessionRoute(handler: NextApiHandler) {
|
||||||
return withIronSessionApiRoute(handler, sessionConfig);
|
return withIronSessionApiRoute(handler, sessionConfig);
|
||||||
|
@ -21,60 +15,3 @@ export const getSession = async (
|
||||||
) => {
|
) => {
|
||||||
return getIronSession(req, res, sessionConfig);
|
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 * as trpcNext from "@trpc/server/adapters/next";
|
||||||
|
|
||||||
import { createContext } from "../../trpc/context";
|
|
||||||
import { appRouter } from "../../trpc/routers";
|
import { appRouter } from "../../trpc/routers";
|
||||||
import { withSessionRoute } from "../session";
|
|
||||||
|
|
||||||
export const trpcNextApiHandler = withSessionRoute(
|
export interface TRPCContext {
|
||||||
trpcNext.createNextApiHandler({
|
user: { id: string; isGuest: boolean };
|
||||||
|
emailClient: EmailClient;
|
||||||
|
isSelfHosted: boolean;
|
||||||
|
isEmailBlocked?: (email: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trpcNextApiHandler = (context: TRPCContext) => {
|
||||||
|
return trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext,
|
createContext: async () => {
|
||||||
}),
|
return context;
|
||||||
);
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { TimeFormat } from "@rallly/database";
|
|
||||||
import { sealData, unsealData } from "iron-session";
|
import { sealData, unsealData } from "iron-session";
|
||||||
|
|
||||||
import { sessionConfig } from "./session-config";
|
import { sessionConfig } from "./session-config";
|
||||||
|
@ -6,11 +5,6 @@ import { sessionConfig } from "./session-config";
|
||||||
type UserSessionData = {
|
type UserSessionData = {
|
||||||
id: string;
|
id: string;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
preferences?: {
|
|
||||||
timeZone?: string;
|
|
||||||
weekStart?: number;
|
|
||||||
timeFormat?: TimeFormat;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module "iron-session" {
|
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 { prisma } from "@rallly/database";
|
||||||
import { absoluteUrl } from "@rallly/utils";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createToken, decryptToken } from "../../session";
|
import { createToken, decryptToken } from "../../session";
|
||||||
import { generateOtp } from "../../utils/nanoid";
|
import { generateOtp } from "../../utils/nanoid";
|
||||||
import { publicProcedure, router } from "../trpc";
|
import { publicProcedure, router } from "../trpc";
|
||||||
import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
|
import { RegistrationTokenPayload } from "../types";
|
||||||
|
|
||||||
// assigns participants and comments created by guests to a user
|
// assigns participants and comments created by guests to a user
|
||||||
// we could have multiple guests because a login might be triggered from one device
|
// 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({
|
export const auth = router({
|
||||||
|
// @deprecated
|
||||||
requestRegistration: publicProcedure
|
requestRegistration: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
@ -82,7 +61,7 @@ export const auth = router({
|
||||||
| { ok: true; token: string }
|
| { ok: true; token: string }
|
||||||
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
|
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
|
||||||
> => {
|
> => {
|
||||||
if (isEmailBlocked(input.email)) {
|
if (ctx.isEmailBlocked?.(input.email)) {
|
||||||
return { ok: false, reason: "emailNotAllowed" };
|
return { ok: false, reason: "emailNotAllowed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,6 +103,8 @@ export const auth = router({
|
||||||
z.object({
|
z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
|
timeZone: z.string().optional(),
|
||||||
|
locale: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
@ -143,115 +124,17 @@ export const auth = router({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
timeZone: input.timeZone,
|
||||||
|
locale: input.locale,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.session.user?.isGuest) {
|
if (ctx.user.isGuest) {
|
||||||
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
|
await mergeGuestsIntoUser(user.id, [ctx.user.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.session.user = {
|
|
||||||
isGuest: false,
|
|
||||||
id: user.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
await ctx.session.save();
|
|
||||||
|
|
||||||
return { ok: true, user };
|
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
|
getUserPermission: publicProcedure
|
||||||
.input(z.object({ token: z.string() }))
|
.input(z.object({ token: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { auth } from "./auth";
|
||||||
import { polls } from "./polls";
|
import { polls } from "./polls";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
import { userPreferences } from "./user-preferences";
|
import { userPreferences } from "./user-preferences";
|
||||||
import { whoami } from "./whoami";
|
|
||||||
|
|
||||||
export const appRouter = mergeRouters(
|
export const appRouter = mergeRouters(
|
||||||
router({
|
router({
|
||||||
whoami,
|
|
||||||
auth,
|
auth,
|
||||||
polls,
|
polls,
|
||||||
user,
|
user,
|
||||||
|
|
|
@ -121,9 +121,7 @@ export const polls = router({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pollLink = ctx.user.isGuest
|
const pollLink = absoluteUrl(`/poll/${pollId}`);
|
||||||
? absoluteUrl(`/admin/${adminToken}`)
|
|
||||||
: absoluteUrl(`/poll/${pollId}`);
|
|
||||||
|
|
||||||
const participantLink = shortUrl(`/invite/${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: {
|
where: {
|
||||||
pollId: input.pollId,
|
pollId: input.pollId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
@ -298,18 +296,13 @@ export const polls = router({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (watcher) {
|
||||||
throw new TRPCError({
|
await prisma.watcher.delete({
|
||||||
code: "BAD_REQUEST",
|
where: {
|
||||||
message: "Not watching this poll",
|
id: watcher.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.watcher.delete({
|
|
||||||
where: {
|
|
||||||
id: res.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
getByAdminUrlId: possiblyPublicProcedure
|
getByAdminUrlId: possiblyPublicProcedure
|
||||||
.input(
|
.input(
|
||||||
|
@ -321,28 +314,6 @@ export const polls = router({
|
||||||
const res = await prisma.poll.findUnique({
|
const res = await prisma.poll.findUnique({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
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: {
|
where: {
|
||||||
adminUrlId: input.urlId,
|
adminUrlId: input.urlId,
|
||||||
|
|
|
@ -6,13 +6,7 @@ import { publicProcedure, router } from "../trpc";
|
||||||
export const userPreferences = router({
|
export const userPreferences = router({
|
||||||
get: publicProcedure.query(async ({ ctx }) => {
|
get: publicProcedure.query(async ({ ctx }) => {
|
||||||
if (ctx.user.isGuest) {
|
if (ctx.user.isGuest) {
|
||||||
return ctx.user.preferences
|
return null;
|
||||||
? {
|
|
||||||
timeZone: ctx.user.preferences.timeZone ?? null,
|
|
||||||
timeFormat: ctx.user.preferences.timeFormat ?? null,
|
|
||||||
weekStart: ctx.user.preferences.weekStart ?? null,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
} else {
|
} else {
|
||||||
return await prisma.userPreferences.findUnique({
|
return await prisma.userPreferences.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@ -48,21 +42,11 @@ export const userPreferences = router({
|
||||||
...input,
|
...input,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
ctx.session.user = {
|
|
||||||
...ctx.user,
|
|
||||||
preferences: { ...ctx.user.preferences, ...input },
|
|
||||||
};
|
|
||||||
await ctx.session.save();
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
delete: publicProcedure.mutation(async ({ ctx }) => {
|
delete: publicProcedure.mutation(async ({ ctx }) => {
|
||||||
if (ctx.user.isGuest) {
|
if (ctx.user.isGuest) {
|
||||||
ctx.session.user = {
|
// delete guest preferences
|
||||||
...ctx.user,
|
|
||||||
preferences: undefined,
|
|
||||||
};
|
|
||||||
await ctx.session.save();
|
|
||||||
} else {
|
} else {
|
||||||
await prisma.userPreferences.delete({
|
await prisma.userPreferences.delete({
|
||||||
where: {
|
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 { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
|
import { TRPCContext } from "../next/trpc/server";
|
||||||
import { getSubscriptionStatus } from "../utils/auth";
|
import { getSubscriptionStatus } from "../utils/auth";
|
||||||
import { Context } from "./context";
|
|
||||||
|
|
||||||
const t = initTRPC.context<Context>().create({
|
const t = initTRPC.context<TRPCContext>().create({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
errorFormatter({ shape }) {
|
errorFormatter({ shape }) {
|
||||||
return shape;
|
return shape;
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
export type RegistrationTokenPayload = {
|
export type RegistrationTokenPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
code: string;
|
locale?: string;
|
||||||
};
|
timeZone?: string;
|
||||||
|
|
||||||
export type LoginTokenPayload = {
|
|
||||||
userId: string;
|
|
||||||
code: 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 {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
email String @unique() @db.Citext
|
email String @unique() @db.Citext
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
emailVerified DateTime? @map("email_verified")
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
timeZone String? @map("time_zone")
|
||||||
comments Comment[]
|
weekStart Int? @map("week_start")
|
||||||
polls Poll[]
|
timeFormat TimeFormat? @map("time_format")
|
||||||
watcher Watcher[]
|
locale String?
|
||||||
events Event[]
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
customerId String? @map("customer_id")
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
customerId String? @map("customer_id")
|
||||||
subscriptionId String? @unique @map("subscription_id")
|
subscriptionId String? @unique @map("subscription_id")
|
||||||
|
|
||||||
|
comments Comment[]
|
||||||
|
polls Poll[]
|
||||||
|
watcher Watcher[]
|
||||||
|
events Event[]
|
||||||
|
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
@ -70,6 +76,7 @@ model Subscription {
|
||||||
@@map("subscriptions")
|
@@map("subscriptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @deprecated
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
userId String @id @map("user_id")
|
userId String @id @map("user_id")
|
||||||
timeZone String? @map("time_zone")
|
timeZone String? @map("time_zone")
|
||||||
|
@ -219,3 +226,12 @@ model Comment {
|
||||||
@@index([pollId], type: Hash)
|
@@index([pollId], type: Hash)
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
@@map("verification_tokens")
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
prisma migrate deploy --schema=./prisma/schema.prisma
|
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/gen-mapping" "^0.1.0"
|
||||||
"@jridgewell/trace-mapping" "^0.3.9"
|
"@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":
|
"@aws-crypto/ie11-detection@^3.0.0":
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz"
|
||||||
|
@ -2399,6 +2418,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@octokit/openapi-types" "^16.0.0"
|
"@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":
|
"@peculiar/asn1-schema@^2.1.6", "@peculiar/asn1-schema@^2.3.0":
|
||||||
version "2.3.3"
|
version "2.3.3"
|
||||||
resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz"
|
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"
|
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"
|
||||||
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
|
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:
|
cookie@^0.4.1:
|
||||||
version "0.4.2"
|
version "0.4.2"
|
||||||
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz"
|
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz"
|
||||||
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
|
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:
|
copy-anything@^3.0.2:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.3.tgz"
|
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/formula" "^3.0.1"
|
||||||
"@sideway/pinpoint" "^2.0.0"
|
"@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:
|
js-beautify@^1.6.12:
|
||||||
version "1.14.7"
|
version "1.14.7"
|
||||||
resolved "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.7.tgz"
|
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"
|
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
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:
|
next-i18next@^13.0.3:
|
||||||
version "13.1.6"
|
version "13.1.6"
|
||||||
resolved "https://registry.npmjs.org/next-i18next/-/next-i18next-13.1.6.tgz"
|
resolved "https://registry.npmjs.org/next-i18next/-/next-i18next-13.1.6.tgz"
|
||||||
|
@ -8651,11 +8695,26 @@ nth-check@^2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase "^1.0.0"
|
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:
|
object-assign@^4.0.1, object-assign@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
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:
|
object-hash@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
|
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"
|
define-properties "^1.1.4"
|
||||||
es-abstract "^1.20.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:
|
once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz"
|
||||||
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
|
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:
|
optionator@^0.9.3:
|
||||||
version "0.9.3"
|
version "0.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
|
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"
|
fflate "^0.4.1"
|
||||||
rrweb-snapshot "^1.1.14"
|
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:
|
prelude-ls@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
|
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"
|
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"
|
||||||
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
|
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:
|
pretty@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz"
|
||||||
|
|
Loading…
Add table
Reference in a new issue