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"}>
|
||||
Enable TLS for your SMTP connection
|
||||
</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"
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
|
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"
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
"location": "Location",
|
||||
"locationPlaceholder": "Joe's Coffee Shop",
|
||||
"login": "Login",
|
||||
"loginWith": "Login with {provider}",
|
||||
"logout": "Logout",
|
||||
"manage": "Manage",
|
||||
"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",
|
||||
"finalizeFeature": "Finalize",
|
||||
"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 }) {
|
||||
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 { 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() {
|
||||
return <LoginForm />;
|
||||
export default async function LoginPage({ params }: { params: Params }) {
|
||||
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 }) {
|
||||
|
|
|
@ -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 { Params } from "@/app/[locale]/types";
|
||||
import { getTranslation } from "@/app/i18n";
|
||||
import { AuthCard } from "@/components/auth/auth-layout";
|
||||
|
||||
export default function Page() {
|
||||
return <RegisterForm />;
|
||||
export default async function Page({ params }: { params: Params }) {
|
||||
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 }) {
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import { 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 { VerifyCode } from "@/components/auth/auth-forms";
|
||||
import { TextInput } from "@/components/text-input";
|
||||
import { useDayjs } from "@/utils/dayjs";
|
||||
import { requiredString, validEmail } from "@/utils/form-validation";
|
||||
|
@ -19,17 +18,14 @@ type RegisterFormData = {
|
|||
email: string;
|
||||
};
|
||||
|
||||
export const RegisterForm: React.FunctionComponent<{
|
||||
onClickLogin?: React.MouseEventHandler;
|
||||
}> = ({ onClickLogin }) => {
|
||||
const [defaultEmail, setDefaultEmail] = useDefaultEmail();
|
||||
export const RegisterForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const { timeZone } = useDayjs();
|
||||
const params = useParams<{ locale: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const { register, handleSubmit, getValues, setError, formState } =
|
||||
useForm<RegisterFormData>({
|
||||
defaultValues: { email: defaultEmail },
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
const queryClient = trpc.useUtils();
|
||||
|
@ -71,7 +67,6 @@ export const RegisterForm: React.FunctionComponent<{
|
|||
callbackUrl: searchParams?.get("callbackUrl") ?? undefined,
|
||||
});
|
||||
}}
|
||||
onChange={() => setToken(undefined)}
|
||||
email={getValues("email")}
|
||||
/>
|
||||
);
|
||||
|
@ -156,24 +151,6 @@ export const RegisterForm: React.FunctionComponent<{
|
|||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,22 +1,13 @@
|
|||
"use client";
|
||||
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 React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { createGlobalState } from "react-use";
|
||||
|
||||
import { usePostHog } from "@/utils/posthog";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
import { requiredString, validEmail } from "../../utils/form-validation";
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import { TextInput } from "../text-input";
|
||||
|
||||
export const useDefaultEmail = createGlobalState("");
|
||||
|
||||
const verifyCode = async (options: { email: string; token: string }) => {
|
||||
export const verifyCode = async (options: { email: string; token: string }) => {
|
||||
const url = `${
|
||||
window.location.origin
|
||||
}/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<{
|
||||
email: string;
|
||||
onSubmit: (code: string) => Promise<void>;
|
||||
onChange: () => void;
|
||||
}> = ({ onChange, onSubmit, email }) => {
|
||||
}> = ({ onSubmit, email }) => {
|
||||
const { register, handleSubmit, setError, formState } = useForm<{
|
||||
code: string;
|
||||
}>();
|
||||
|
@ -73,7 +63,6 @@ export const VerifyCode: React.FunctionComponent<{
|
|||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
@ -113,132 +102,3 @@ export const VerifyCode: React.FunctionComponent<{
|
|||
</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 { Logo } from "@/components/logo";
|
||||
import { IfCloudHosted } from "@/contexts/environment";
|
||||
|
||||
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
|
||||
export const AuthCard = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="h-full p-3 sm:p-8">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
|
||||
<Logo />
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">{children}</div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const AuthFooter = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-gray-500 text-sm mt-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,19 +16,14 @@ import NextAuth, {
|
|||
} from "next-auth/next";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
|
||||
import { absoluteUrl } from "@/utils/absolute-url";
|
||||
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
|
||||
import { isOIDCEnabled, oidcName } from "@/utils/constants";
|
||||
import { emailClient } from "@/utils/emails";
|
||||
|
||||
const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||
({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
secret: process.env.SECRET_PASSWORD,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
providers: [
|
||||
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({
|
||||
|
@ -111,7 +106,39 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
|||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
// 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) =>
|
||||
({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
secret: process.env.SECRET_PASSWORD,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
providers: providers,
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
signOut: "/logout",
|
||||
|
@ -119,25 +146,30 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
|||
},
|
||||
callbacks: {
|
||||
async signIn({ user, email }) {
|
||||
if (email?.verificationRequest) {
|
||||
// Make sure email is allowed
|
||||
if (user.email) {
|
||||
const userExists =
|
||||
const isBlocked = isEmailBlocked(user.email);
|
||||
if (isBlocked) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
email: user.email as string,
|
||||
},
|
||||
})) > 0;
|
||||
})) === 0;
|
||||
|
||||
if (userExists) {
|
||||
if (isEmailBlocked(user.email)) {
|
||||
if (isUnregisteredUser) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (user.email) {
|
||||
// merge guest user into newly logged in user
|
||||
const session = await getServerSession(...args);
|
||||
if (session && session.user.email === null) {
|
||||
|
|
|
@ -12,3 +12,7 @@ export const monthlyPriceUsd = 7;
|
|||
|
||||
export const annualPriceUsd = 42;
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
@ -35,6 +56,7 @@ model User {
|
|||
watcher Watcher[]
|
||||
events Event[]
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
accounts Account[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
|
@ -85,6 +85,11 @@
|
|||
"NEXT_PUBLIC_VERCEL_URL",
|
||||
"NODE_ENV",
|
||||
"NOREPLY_EMAIL",
|
||||
"OIDC_ENABLED",
|
||||
"OIDC_NAME",
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"PADDLE_PUBLIC_KEY",
|
||||
"PORT",
|
||||
"SECRET_PASSWORD",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue