mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-21 21:06:20 +02:00
✨ Add support for OpenID Connect (#939)
This commit is contained in:
parent
9ceb27f6e3
commit
7c03059bc0
18 changed files with 562 additions and 305 deletions
|
@ -68,3 +68,49 @@ These variables need to be configured to let Rallly send out transactional email
|
||||||
<ParamField path="SMTP_TLS_ENABLED" default={"false"}>
|
<ParamField path="SMTP_TLS_ENABLED" default={"false"}>
|
||||||
Enable TLS for your SMTP connection
|
Enable TLS for your SMTP connection
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
### Single Sign On (SSO) with OpenID Connect (OIDC)
|
||||||
|
|
||||||
|
To enable SSO with an OIDC compliant identity provider you will need to configure the following variables.
|
||||||
|
|
||||||
|
<ParamField path="OIDC_ENABLED">
|
||||||
|
Must be set to `true` to enable OIDC Login
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="OIDC_NAME" default="OpenID Connect">
|
||||||
|
The user-facing name of your provider as it will be shown on the login page
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="OIDC_DISCOVERY_URL">
|
||||||
|
URL of the `.well-known/openid-configuration` endpoint for your OIDC provider
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="OIDC_CLIENT_ID">
|
||||||
|
The client ID of your OIDC application
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="OIDC_CLIENT_SECRET">
|
||||||
|
The client secret of your OIDC application
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
#### Required Scopes
|
||||||
|
|
||||||
|
The following scopes are required for OIDC to function properly.
|
||||||
|
|
||||||
|
- `openid`: Essential for OIDC to function, used to perform authentication.
|
||||||
|
- `profile`: Access to the user's personal information such as name and picture.
|
||||||
|
- `email`: Access to the user's email address.
|
||||||
|
|
||||||
|
#### Callback URL / Redirect URI
|
||||||
|
|
||||||
|
The callback URL for your OIDC application must be set to:
|
||||||
|
|
||||||
|
```
|
||||||
|
{NEXT_PUBLIC_BASE_URL}/api/auth/callback/oidc
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
Replace `{NEXT_PUBLIC_BASE_URL}` with the base URL of your Rallly instance.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
Ensure this URL is added to the list of allowed redirect URIs in your OIDC provider's application settings.
|
||||||
|
|
43
apps/landing/declarations/environment.d.ts
vendored
43
apps/landing/declarations/environment.d.ts
vendored
|
@ -68,6 +68,49 @@ declare global {
|
||||||
* Determines what email provider to use. "smtp" or "ses"
|
* Determines what email provider to use. "smtp" or "ses"
|
||||||
*/
|
*/
|
||||||
EMAIL_PROVIDER?: "smtp" | "ses";
|
EMAIL_PROVIDER?: "smtp" | "ses";
|
||||||
|
/**
|
||||||
|
* Name of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_NAME?: string;
|
||||||
|
/**
|
||||||
|
* URL of the oidc provider .well-known/openid-configuration endpoint
|
||||||
|
*/
|
||||||
|
OIDC_DISCOVERY_URL?: string;
|
||||||
|
/**
|
||||||
|
* Client ID of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_CLIENT_ID?: string;
|
||||||
|
/**
|
||||||
|
* Client secret of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_CLIENT_SECRET?: string;
|
||||||
|
/**
|
||||||
|
* Scopes that should be used when configuring the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_SCOPES?: string;
|
||||||
|
/**
|
||||||
|
* If Rallly should expect the oidc provider to return an ID token
|
||||||
|
*/
|
||||||
|
OIDC_ID_TOKEN_EXPECTED?: string;
|
||||||
|
/**
|
||||||
|
* When using an oidc provider that support the userinfo endpoint, set this to "true" to
|
||||||
|
* use it instead of getting the user info from the ID token
|
||||||
|
*/
|
||||||
|
OIDC_FORCE_USER_INFO?: string;
|
||||||
|
/**
|
||||||
|
* When using a provider that does not provide a userinfo endpoint in its discovery document,
|
||||||
|
* `OIDC_FORCE_USER_INFO` may be set to the URL of the userinfo endpoint.
|
||||||
|
* `OIDC_USER_INFO_URL` may not be usable when `OIDC_FORCE_USER_INFO` is set to `true`
|
||||||
|
*/
|
||||||
|
OIDC_USER_INFO_URL?: string;
|
||||||
|
/**
|
||||||
|
* The name of the `name` field returned by the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_NAME_CLAIM?: string;
|
||||||
|
/**
|
||||||
|
* The name of the `email` field returned by the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_EMAIL_CLAIM?: string;
|
||||||
/**
|
/**
|
||||||
* AWS access key ID
|
* AWS access key ID
|
||||||
*/
|
*/
|
||||||
|
|
20
apps/web/declarations/environment.d.ts
vendored
20
apps/web/declarations/environment.d.ts
vendored
|
@ -64,6 +64,26 @@ declare global {
|
||||||
* Determines what email provider to use. "smtp" or "ses"
|
* Determines what email provider to use. "smtp" or "ses"
|
||||||
*/
|
*/
|
||||||
EMAIL_PROVIDER?: "smtp" | "ses";
|
EMAIL_PROVIDER?: "smtp" | "ses";
|
||||||
|
/**
|
||||||
|
* Set to "true" to enable OIDC authentication
|
||||||
|
*/
|
||||||
|
OIDC_ENABLED?: string;
|
||||||
|
/**
|
||||||
|
* Name of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_NAME?: string;
|
||||||
|
/**
|
||||||
|
* URL of the oidc provider .well-known/openid-configuration endpoint
|
||||||
|
*/
|
||||||
|
OIDC_DISCOVERY_URL?: string;
|
||||||
|
/**
|
||||||
|
* Client ID of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_CLIENT_ID?: string;
|
||||||
|
/**
|
||||||
|
* Client secret of the oidc provider
|
||||||
|
*/
|
||||||
|
OIDC_CLIENT_SECRET?: string;
|
||||||
/**
|
/**
|
||||||
* AWS access key ID
|
* AWS access key ID
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"locationPlaceholder": "Joe's Coffee Shop",
|
"locationPlaceholder": "Joe's Coffee Shop",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
|
"loginWith": "Login with {provider}",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?",
|
"mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?",
|
||||||
|
@ -226,5 +227,6 @@
|
||||||
"continueAs": "Continue as",
|
"continueAs": "Continue as",
|
||||||
"finalizeFeature": "Finalize",
|
"finalizeFeature": "Finalize",
|
||||||
"duplicateFeature": "Duplicate",
|
"duplicateFeature": "Duplicate",
|
||||||
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>"
|
"pageMovedDescription": "Redirecting to <a>{newUrl}</a>",
|
||||||
|
"notRegistered": "Don't have an account? <a>Register</a>"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <AuthLayout>{children}</AuthLayout>;
|
return (
|
||||||
|
<div className="h-full p-3 sm:p-8">
|
||||||
|
<div className="mx-auto max-w-lg">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
157
apps/web/src/app/[locale]/(auth)/login/login-form.tsx
Normal file
157
apps/web/src/app/[locale]/(auth)/login/login-form.tsx
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
"use client";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { LogInIcon, UserIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { signIn, useSession } from "next-auth/react";
|
||||||
|
import { usePostHog } from "posthog-js/react";
|
||||||
|
import React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { trpc } from "@/app/providers";
|
||||||
|
import { VerifyCode, verifyCode } from "@/components/auth/auth-forms";
|
||||||
|
import { TextInput } from "@/components/text-input";
|
||||||
|
import { IfCloudHosted } from "@/contexts/environment";
|
||||||
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
import { validEmail } from "@/utils/form-validation";
|
||||||
|
|
||||||
|
export function LoginForm({ oidcConfig }: { oidcConfig?: { name: string } }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { register, handleSubmit, getValues, formState, setError } = useForm<{
|
||||||
|
email: string;
|
||||||
|
}>({
|
||||||
|
defaultValues: { email: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = trpc.useUtils();
|
||||||
|
const [email, setEmail] = React.useState<string>();
|
||||||
|
const posthog = usePostHog();
|
||||||
|
const router = useRouter();
|
||||||
|
const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/";
|
||||||
|
|
||||||
|
const hasOIDCProvider = !!oidcConfig;
|
||||||
|
const allowGuestAccess = !isSelfHosted;
|
||||||
|
const hasAlternativeLoginMethods = hasOIDCProvider || allowGuestAccess;
|
||||||
|
const sendVerificationEmail = (email: string) => {
|
||||||
|
return signIn("email", {
|
||||||
|
redirect: false,
|
||||||
|
email,
|
||||||
|
callbackUrl,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
return (
|
||||||
|
<VerifyCode
|
||||||
|
onSubmit={async (code) => {
|
||||||
|
const success = await verifyCode({
|
||||||
|
email,
|
||||||
|
token: code,
|
||||||
|
});
|
||||||
|
if (!success) {
|
||||||
|
throw new Error("Failed to authenticate user");
|
||||||
|
} else {
|
||||||
|
queryClient.invalidate();
|
||||||
|
const s = await session.update();
|
||||||
|
if (s?.user) {
|
||||||
|
posthog?.identify(s.user.id, {
|
||||||
|
email: s.user.email,
|
||||||
|
name: s.user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
posthog?.capture("login", {
|
||||||
|
method: "verification-code",
|
||||||
|
});
|
||||||
|
router.push(callbackUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
email={getValues("email")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(async ({ email }) => {
|
||||||
|
const res = await sendVerificationEmail(email);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
setError("email", {
|
||||||
|
message: t("userNotFound"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEmail(email);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
|
||||||
|
<p className="mb-4 text-gray-500">
|
||||||
|
{t("stepSummary", {
|
||||||
|
current: 1,
|
||||||
|
total: 2,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<fieldset className="mb-4">
|
||||||
|
<label htmlFor="email" className="mb-1 text-gray-500">
|
||||||
|
{t("email")}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
className="w-full"
|
||||||
|
id="email"
|
||||||
|
proportions="lg"
|
||||||
|
autoFocus={true}
|
||||||
|
error={!!formState.errors.email}
|
||||||
|
disabled={formState.isSubmitting}
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
{...register("email", { validate: validEmail })}
|
||||||
|
/>
|
||||||
|
{formState.errors.email?.message ? (
|
||||||
|
<div className="mt-2 text-sm text-rose-500">
|
||||||
|
{formState.errors.email.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
loading={formState.isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
variant="primary"
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
{t("continue")}
|
||||||
|
</Button>
|
||||||
|
{hasAlternativeLoginMethods ? (
|
||||||
|
<>
|
||||||
|
<hr className="border-t border-grey-500 my-4" />
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<IfCloudHosted>
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link href="/">
|
||||||
|
<UserIcon className="w-4 h-4" />
|
||||||
|
<Trans i18nKey="continueAsGuest" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</IfCloudHosted>
|
||||||
|
{hasOIDCProvider ? (
|
||||||
|
<Button
|
||||||
|
icon={LogInIcon}
|
||||||
|
size="lg"
|
||||||
|
onClick={() => signIn("oidc")}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="loginWith"
|
||||||
|
values={{ provider: oidcConfig.name }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,33 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Trans } from "react-i18next/TransWithoutContext";
|
||||||
|
|
||||||
|
import { LoginForm } from "@/app/[locale]/(auth)/login/login-form";
|
||||||
import { Params } from "@/app/[locale]/types";
|
import { Params } from "@/app/[locale]/types";
|
||||||
import { getTranslation } from "@/app/i18n";
|
import { getTranslation } from "@/app/i18n";
|
||||||
import { LoginForm } from "@/components/auth/auth-forms";
|
import { AuthCard } from "@/components/auth/auth-layout";
|
||||||
|
import { isOIDCEnabled, oidcName } from "@/utils/constants";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default async function LoginPage({ params }: { params: Params }) {
|
||||||
return <LoginForm />;
|
const { t } = await getTranslation(params.locale);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AuthCard>
|
||||||
|
<LoginForm
|
||||||
|
oidcConfig={isOIDCEnabled ? { name: oidcName } : undefined}
|
||||||
|
/>
|
||||||
|
</AuthCard>
|
||||||
|
<div className="mt-4 text-center pt-4 text-gray-500 sm:text-base">
|
||||||
|
<Trans
|
||||||
|
t={t}
|
||||||
|
i18nKey="notRegistered"
|
||||||
|
defaults="Don't have an account? <a>Register</a>"
|
||||||
|
components={{
|
||||||
|
a: <Link href="/register" className="text-link" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }) {
|
export async function generateMetadata({ params }: { params: Params }) {
|
||||||
|
|
|
@ -1,9 +1,29 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Trans } from "react-i18next/TransWithoutContext";
|
||||||
|
|
||||||
import { RegisterForm } from "@/app/[locale]/(auth)/register/register-page";
|
import { RegisterForm } from "@/app/[locale]/(auth)/register/register-page";
|
||||||
import { Params } from "@/app/[locale]/types";
|
import { Params } from "@/app/[locale]/types";
|
||||||
import { getTranslation } from "@/app/i18n";
|
import { getTranslation } from "@/app/i18n";
|
||||||
|
import { AuthCard } from "@/components/auth/auth-layout";
|
||||||
|
|
||||||
export default function Page() {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
return <RegisterForm />;
|
const { t } = await getTranslation(params.locale);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AuthCard>
|
||||||
|
<RegisterForm />
|
||||||
|
</AuthCard>
|
||||||
|
<div className="mt-4 text-center pt-4 text-gray-500 sm:text-base">
|
||||||
|
<Trans
|
||||||
|
t={t}
|
||||||
|
i18nKey="alreadyRegistered"
|
||||||
|
components={{
|
||||||
|
a: <Link href="/login" className="text-link" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }) {
|
export async function generateMetadata({ params }: { params: Params }) {
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { usePostHog } from "posthog-js/react";
|
import { usePostHog } from "posthog-js/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { useDefaultEmail, VerifyCode } from "@/components/auth/auth-forms";
|
import { VerifyCode } from "@/components/auth/auth-forms";
|
||||||
import { TextInput } from "@/components/text-input";
|
import { TextInput } from "@/components/text-input";
|
||||||
import { useDayjs } from "@/utils/dayjs";
|
import { useDayjs } from "@/utils/dayjs";
|
||||||
import { requiredString, validEmail } from "@/utils/form-validation";
|
import { requiredString, validEmail } from "@/utils/form-validation";
|
||||||
|
@ -19,17 +18,14 @@ type RegisterFormData = {
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RegisterForm: React.FunctionComponent<{
|
export const RegisterForm = () => {
|
||||||
onClickLogin?: React.MouseEventHandler;
|
|
||||||
}> = ({ onClickLogin }) => {
|
|
||||||
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timeZone } = useDayjs();
|
const { timeZone } = useDayjs();
|
||||||
const params = useParams<{ locale: string }>();
|
const params = useParams<{ locale: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { register, handleSubmit, getValues, setError, formState } =
|
const { register, handleSubmit, getValues, setError, formState } =
|
||||||
useForm<RegisterFormData>({
|
useForm<RegisterFormData>({
|
||||||
defaultValues: { email: defaultEmail },
|
defaultValues: { email: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = trpc.useUtils();
|
const queryClient = trpc.useUtils();
|
||||||
|
@ -71,7 +67,6 @@ export const RegisterForm: React.FunctionComponent<{
|
||||||
callbackUrl: searchParams?.get("callbackUrl") ?? undefined,
|
callbackUrl: searchParams?.get("callbackUrl") ?? undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onChange={() => setToken(undefined)}
|
|
||||||
email={getValues("email")}
|
email={getValues("email")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -156,24 +151,6 @@ export const RegisterForm: React.FunctionComponent<{
|
||||||
>
|
>
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,22 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
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";
|
||||||
import { createGlobalState } from "react-use";
|
|
||||||
|
|
||||||
import { usePostHog } from "@/utils/posthog";
|
import { requiredString } from "../../utils/form-validation";
|
||||||
import { trpc } from "@/utils/trpc/client";
|
|
||||||
|
|
||||||
import { requiredString, validEmail } from "../../utils/form-validation";
|
|
||||||
import { TextInput } from "../text-input";
|
import { TextInput } from "../text-input";
|
||||||
|
|
||||||
export const useDefaultEmail = createGlobalState("");
|
export const verifyCode = async (options: { email: string; token: string }) => {
|
||||||
|
|
||||||
const verifyCode = async (options: { email: string; token: string }) => {
|
|
||||||
const url = `${
|
const url = `${
|
||||||
window.location.origin
|
window.location.origin
|
||||||
}/api/auth/callback/email?email=${encodeURIComponent(options.email)}&token=${
|
}/api/auth/callback/email?email=${encodeURIComponent(options.email)}&token=${
|
||||||
|
@ -31,8 +22,7 @@ const verifyCode = async (options: { email: string; token: string }) => {
|
||||||
export const VerifyCode: React.FunctionComponent<{
|
export const VerifyCode: React.FunctionComponent<{
|
||||||
email: string;
|
email: string;
|
||||||
onSubmit: (code: string) => Promise<void>;
|
onSubmit: (code: string) => Promise<void>;
|
||||||
onChange: () => void;
|
}> = ({ onSubmit, email }) => {
|
||||||
}> = ({ onChange, onSubmit, email }) => {
|
|
||||||
const { register, handleSubmit, setError, formState } = useForm<{
|
const { register, handleSubmit, setError, formState } = useForm<{
|
||||||
code: string;
|
code: string;
|
||||||
}>();
|
}>();
|
||||||
|
@ -73,7 +63,6 @@ export const VerifyCode: React.FunctionComponent<{
|
||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
@ -113,132 +102,3 @@ export const VerifyCode: React.FunctionComponent<{
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoginForm: React.FunctionComponent<{
|
|
||||||
onClickRegister?: (
|
|
||||||
e: React.MouseEvent<HTMLAnchorElement>,
|
|
||||||
email: string,
|
|
||||||
) => void;
|
|
||||||
}> = ({ onClickRegister }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
|
|
||||||
|
|
||||||
const { register, handleSubmit, getValues, formState, setError } = useForm<{
|
|
||||||
email: string;
|
|
||||||
}>({
|
|
||||||
defaultValues: { email: defaultEmail },
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = useSession();
|
|
||||||
const queryClient = trpc.useUtils();
|
|
||||||
const [email, setEmail] = React.useState<string>();
|
|
||||||
const posthog = usePostHog();
|
|
||||||
const router = useRouter();
|
|
||||||
const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/";
|
|
||||||
|
|
||||||
const sendVerificationEmail = (email: string) => {
|
|
||||||
return signIn("email", {
|
|
||||||
redirect: false,
|
|
||||||
email,
|
|
||||||
callbackUrl,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (email) {
|
|
||||||
return (
|
|
||||||
<VerifyCode
|
|
||||||
onSubmit={async (code) => {
|
|
||||||
const success = await verifyCode({
|
|
||||||
email,
|
|
||||||
token: code,
|
|
||||||
});
|
|
||||||
if (!success) {
|
|
||||||
throw new Error("Failed to authenticate user");
|
|
||||||
} else {
|
|
||||||
queryClient.invalidate();
|
|
||||||
const s = await session.update();
|
|
||||||
if (s?.user) {
|
|
||||||
posthog?.identify(s.user.id, {
|
|
||||||
email: s.user.email,
|
|
||||||
name: s.user.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
posthog?.capture("login", {
|
|
||||||
method: "verification-code",
|
|
||||||
});
|
|
||||||
router.push(callbackUrl);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={() => setEmail(undefined)}
|
|
||||||
email={getValues("email")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(async ({ email }) => {
|
|
||||||
const res = await sendVerificationEmail(email);
|
|
||||||
|
|
||||||
if (res?.error) {
|
|
||||||
setError("email", {
|
|
||||||
message: t("userNotFound"),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setEmail(email);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="mb-1 text-2xl font-bold">{t("login")}</div>
|
|
||||||
<p className="mb-4 text-gray-500">
|
|
||||||
{t("stepSummary", {
|
|
||||||
current: 1,
|
|
||||||
total: 2,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<fieldset className="mb-4">
|
|
||||||
<label htmlFor="email" className="mb-1 text-gray-500">
|
|
||||||
{t("email")}
|
|
||||||
</label>
|
|
||||||
<TextInput
|
|
||||||
className="w-full"
|
|
||||||
id="email"
|
|
||||||
proportions="lg"
|
|
||||||
autoFocus={true}
|
|
||||||
error={!!formState.errors.email}
|
|
||||||
disabled={formState.isSubmitting}
|
|
||||||
placeholder={t("emailPlaceholder")}
|
|
||||||
{...register("email", { validate: validEmail })}
|
|
||||||
/>
|
|
||||||
{formState.errors.email?.message ? (
|
|
||||||
<div className="mt-2 text-sm text-rose-500">
|
|
||||||
{formState.errors.email.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</fieldset>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
loading={formState.isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
variant="primary"
|
|
||||||
className=""
|
|
||||||
>
|
|
||||||
{t("continue")}
|
|
||||||
</Button>
|
|
||||||
<Button size="lg" asChild>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
onClick={(e) => {
|
|
||||||
React.startTransition(() => {
|
|
||||||
setDefaultEmail(getValues("email"));
|
|
||||||
onClickRegister?.(e, getValues("email"));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("createAnAccount")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,31 +1,22 @@
|
||||||
import Link from "next/link";
|
|
||||||
import { Trans } from "next-i18next";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { IfCloudHosted } from "@/contexts/environment";
|
|
||||||
|
|
||||||
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
|
export const AuthCard = ({ children }: { children?: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full p-3 sm:p-8">
|
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||||
<div className="mx-auto max-w-lg">
|
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
|
||||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
<Logo />
|
||||||
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
|
|
||||||
<Logo />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 sm:p-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
<IfCloudHosted>
|
|
||||||
<p className="mt-8 text-center">
|
|
||||||
<Link
|
|
||||||
href="/polls"
|
|
||||||
className="text-muted-foreground hover:underline"
|
|
||||||
>
|
|
||||||
<Trans i18nKey="continueAsGuest" defaults="Continue as Guest" />
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</IfCloudHosted>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-4 sm:p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthFooter = ({ children }: { children?: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 text-gray-500 text-sm mt-4">
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,11 +16,121 @@ import NextAuth, {
|
||||||
} from "next-auth/next";
|
} from "next-auth/next";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import EmailProvider from "next-auth/providers/email";
|
import EmailProvider from "next-auth/providers/email";
|
||||||
|
import { Provider } from "next-auth/providers/index";
|
||||||
|
|
||||||
import { absoluteUrl } from "@/utils/absolute-url";
|
import { absoluteUrl } from "@/utils/absolute-url";
|
||||||
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
|
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
|
||||||
|
import { isOIDCEnabled, oidcName } from "@/utils/constants";
|
||||||
import { emailClient } from "@/utils/emails";
|
import { emailClient } from "@/utils/emails";
|
||||||
|
|
||||||
|
const providers: Provider[] = [
|
||||||
|
// 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: absoluteUrl("/auth/login", {
|
||||||
|
magicLink: url,
|
||||||
|
}),
|
||||||
|
code: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// If we have an OAuth provider configured, we add it to the list of providers
|
||||||
|
if (isOIDCEnabled) {
|
||||||
|
providers.push({
|
||||||
|
id: "oidc",
|
||||||
|
name: oidcName,
|
||||||
|
type: "oauth",
|
||||||
|
wellKnown: process.env.OIDC_DISCOVERY_URL,
|
||||||
|
authorization: { params: { scope: "openid email profile" } },
|
||||||
|
clientId: process.env.OIDC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
|
idToken: true,
|
||||||
|
checks: ["state"],
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const getAuthOptions = (...args: GetServerSessionParams) =>
|
const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
({
|
({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
|
@ -28,90 +138,7 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
},
|
},
|
||||||
providers: [
|
providers: providers,
|
||||||
// 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: absoluteUrl("/auth/login", {
|
|
||||||
magicLink: url,
|
|
||||||
}),
|
|
||||||
code: token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
signOut: "/logout",
|
signOut: "/logout",
|
||||||
|
@ -119,25 +146,30 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user, email }) {
|
async signIn({ user, email }) {
|
||||||
if (email?.verificationRequest) {
|
// Make sure email is allowed
|
||||||
if (user.email) {
|
if (user.email) {
|
||||||
const userExists =
|
const isBlocked = isEmailBlocked(user.email);
|
||||||
(await prisma.user.count({
|
if (isBlocked) {
|
||||||
where: {
|
return false;
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
})) > 0;
|
|
||||||
|
|
||||||
if (userExists) {
|
|
||||||
if (isEmailBlocked(user.email)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (user.email) {
|
}
|
||||||
|
|
||||||
|
// For now, we don't allow users to login unless they have
|
||||||
|
// registered an account. This is just because we need a name
|
||||||
|
// to display on the dashboard. The flow can be modified so that
|
||||||
|
// the name is requested after the user has logged in.
|
||||||
|
if (email?.verificationRequest) {
|
||||||
|
const isUnregisteredUser =
|
||||||
|
(await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
email: user.email as string,
|
||||||
|
},
|
||||||
|
})) === 0;
|
||||||
|
|
||||||
|
if (isUnregisteredUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// merge guest user into newly logged in user
|
// merge guest user into newly logged in user
|
||||||
const session = await getServerSession(...args);
|
const session = await getServerSession(...args);
|
||||||
if (session && session.user.email === null) {
|
if (session && session.user.email === null) {
|
||||||
|
|
|
@ -12,3 +12,7 @@ export const monthlyPriceUsd = 7;
|
||||||
|
|
||||||
export const annualPriceUsd = 42;
|
export const annualPriceUsd = 42;
|
||||||
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION;
|
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION;
|
||||||
|
|
||||||
|
export const isOIDCEnabled = process.env.OIDC_ENABLED === "true";
|
||||||
|
|
||||||
|
export const oidcName = process.env.OIDC_NAME ?? "OpenID Connect";
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Account_userId_idx" ON "Account" USING HASH ("userId");
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Account";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"provider_account_id" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING HASH ("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
|
|
@ -16,6 +16,27 @@ enum TimeFormat {
|
||||||
@@map("time_format")
|
@@map("time_format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String @map("provider_account_id")
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
@@index([userId], type: Hash)
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
@ -35,6 +56,7 @@ model User {
|
||||||
watcher Watcher[]
|
watcher Watcher[]
|
||||||
events Event[]
|
events Event[]
|
||||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||||
|
accounts Account[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,6 +85,11 @@
|
||||||
"NEXT_PUBLIC_VERCEL_URL",
|
"NEXT_PUBLIC_VERCEL_URL",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"NOREPLY_EMAIL",
|
"NOREPLY_EMAIL",
|
||||||
|
"OIDC_ENABLED",
|
||||||
|
"OIDC_NAME",
|
||||||
|
"OIDC_DISCOVERY_URL",
|
||||||
|
"OIDC_CLIENT_ID",
|
||||||
|
"OIDC_CLIENT_SECRET",
|
||||||
"PADDLE_PUBLIC_KEY",
|
"PADDLE_PUBLIC_KEY",
|
||||||
"PORT",
|
"PORT",
|
||||||
"SECRET_PASSWORD",
|
"SECRET_PASSWORD",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue