Add login with microsoft (#1008)

This commit is contained in:
Luke Vella 2024-01-30 16:45:49 +07:00 committed by GitHub
parent 93f98cffe6
commit 3abd3608be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 104 additions and 40 deletions

View file

@ -10,7 +10,6 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
transpilePackages: [ transpilePackages: [
"@rallly/backend", "@rallly/backend",

View file

@ -221,7 +221,6 @@
"integrations": "Integrations", "integrations": "Integrations",
"contacts": "Contacts", "contacts": "Contacts",
"unlockFeatures": "Unlock all Pro features.", "unlockFeatures": "Unlock all Pro features.",
"back": "Back",
"pollStatusAll": "All", "pollStatusAll": "All",
"pollStatusLive": "Live", "pollStatusLive": "Live",
"pollStatusFinalized": "Finalized", "pollStatusFinalized": "Finalized",
@ -238,5 +237,8 @@
"registrations": "Registrations", "registrations": "Registrations",
"inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.", "inviteParticipantsDescription": "Copy and share the invite link to start gathering responses from your participants.",
"inviteLink": "Invite Link", "inviteLink": "Invite Link",
"inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll." "inviteParticipantLinkInfo": "Anyone with this link will be able to vote on your poll.",
"accountNotLinkedTitle": "Your account cannot be linked to an existing user",
"accountNotLinkedDescription": "A user with this email already exists. Please log in using the original method.",
"or": "Or"
} }

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 343 B

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { UserIcon } from "lucide-react"; import { AlertTriangleIcon, UserIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { getProviders, signIn, useSession } from "next-auth/react"; import { getProviders, signIn, useSession } from "next-auth/react";
@ -21,6 +22,7 @@ const allowGuestAccess = !isSelfHosted;
export function LoginForm() { export function LoginForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const searchParams = useSearchParams();
const { register, handleSubmit, getValues, formState, setError } = useForm<{ const { register, handleSubmit, getValues, formState, setError } = useForm<{
email: string; email: string;
@ -38,21 +40,13 @@ export function LoginForm() {
const [email, setEmail] = React.useState<string>(); const [email, setEmail] = React.useState<string>();
const posthog = usePostHog(); const posthog = usePostHog();
const router = useRouter(); const router = useRouter();
const callbackUrl = (useSearchParams()?.get("callbackUrl") as string) ?? "/"; const callbackUrl = searchParams?.get("callbackUrl") ?? "/";
const error = searchParams?.get("error");
const alternativeLoginMethods = React.useMemo(() => { const alternativeLoginMethods = React.useMemo(() => {
const res: Array<{ login: () => void; icon: JSX.Element; name: string }> = const res: Array<{ login: () => void; icon: JSX.Element; name: string }> =
[]; [];
if (allowGuestAccess) {
res.push({
login: () => {
router.push(callbackUrl);
},
icon: <UserIcon className="text-muted-foreground size-5" />,
name: t("continueAsGuest"),
});
}
if (providers?.oidc) { if (providers?.oidc) {
res.push({ res.push({
login: () => { login: () => {
@ -78,6 +72,35 @@ export function LoginForm() {
name: t("loginWith", { provider: providers.google.name }), name: t("loginWith", { provider: providers.google.name }),
}); });
} }
if (providers?.["azure-ad"]) {
res.push({
login: () => {
signIn("azure-ad", {
callbackUrl,
});
},
icon: (
<Image
src="/static/microsoft.svg"
width={20}
height={20}
alt="Azure AD"
/>
),
name: t("loginWith", { provider: "Microsoft" }),
});
}
if (allowGuestAccess) {
res.push({
login: () => {
router.push(callbackUrl);
},
icon: <UserIcon className="text-muted-foreground size-5" />,
name: t("continueAsGuest"),
});
}
return res; return res;
}, [callbackUrl, providers, router, t]); }, [callbackUrl, providers, router, t]);
@ -148,7 +171,7 @@ export function LoginForm() {
total: 2, total: 2,
})} })}
</p> </p>
<fieldset className="mb-4"> <fieldset className="mb-2.5">
<label htmlFor="email" className="mb-1 text-gray-500"> <label htmlFor="email" className="mb-1 text-gray-500">
{t("email")} {t("email")}
</label> </label>
@ -176,12 +199,35 @@ export function LoginForm() {
variant="primary" variant="primary"
className="" className=""
> >
{t("continue")} {t("loginWith", {
provider: t("email"),
})}
</Button> </Button>
{error === "OAuthAccountNotLinked" ? (
<Alert icon={AlertTriangleIcon} variant="destructive">
<AlertTitle>
{t("accountNotLinkedTitle", {
defaultValue:
"Your account cannot be linked to an existing user",
})}
</AlertTitle>
<AlertDescription>
{t("accountNotLinkedDescription", {
defaultValue:
"A user with this email already exists. Please log in using the original method.",
})}
</AlertDescription>
</Alert>
) : null}
{alternativeLoginMethods.length > 0 ? ( {alternativeLoginMethods.length > 0 ? (
<> <>
<hr className="border-grey-500 my-4 border-t" /> <div className="relative my-4">
<div className="grid gap-4"> <hr className="border-grey-500 absolute top-1/2 w-full border-t" />
<span className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white px-2 text-center text-xs uppercase text-gray-400">
{t("or", { defaultValue: "Or" })}
</span>
</div>
<div className="grid gap-2.5">
{alternativeLoginMethods.map((method, i) => ( {alternativeLoginMethods.map((method, i) => (
<Button size="lg" key={i} onClick={method.login}> <Button size="lg" key={i} onClick={method.login}>
{method.icon} {method.icon}

View file

@ -13,6 +13,7 @@ import { NextAuthOptions, RequestInternal } from "next-auth";
import NextAuth, { import NextAuth, {
getServerSession as getServerSessionWithOptions, getServerSession as getServerSessionWithOptions,
} from "next-auth/next"; } from "next-auth/next";
import AzureADProvider from "next-auth/providers/azure-ad";
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 GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
@ -146,6 +147,20 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
); );
} }
if (
process.env.MICROSOFT_TENANT_ID &&
process.env.MICROSOFT_CLIENT_ID &&
process.env.MICROSOFT_CLIENT_SECRET
) {
providers.push(
AzureADProvider({
tenantId: process.env.MICROSOFT_TENANT_ID,
clientId: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
}),
);
}
const getAuthOptions = (...args: GetServerSessionParams) => const getAuthOptions = (...args: GetServerSessionParams) =>
({ ({
adapter: CustomPrismaAdapter(prisma), adapter: CustomPrismaAdapter(prisma),

View file

@ -44,9 +44,9 @@ test.describe.serial(() => {
// your login page test logic // your login page test logic
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type(testUserEmail); .fill(testUserEmail);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Login with Email" }).click();
// Make sure the user doesn't exist yet and that logging in is not possible // Make sure the user doesn't exist yet and that logging in is not possible
await expect( await expect(
@ -59,10 +59,10 @@ test.describe.serial(() => {
await page.getByText("Create an account").waitFor(); await page.getByText("Create an account").waitFor();
await page.getByPlaceholder("Jessie Smith").type("Test User"); await page.getByPlaceholder("Jessie Smith").fill("Test User");
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type(testUserEmail); .fill(testUserEmail);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
@ -70,7 +70,7 @@ test.describe.serial(() => {
const code = await getCode(); const code = await getCode();
await codeInput.type(code); await codeInput.fill(code);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
@ -84,10 +84,10 @@ test.describe.serial(() => {
await page.getByText("Create an account").waitFor(); await page.getByText("Create an account").waitFor();
await page.getByPlaceholder("Jessie Smith").type("Test User"); await page.getByPlaceholder("Jessie Smith").fill("Test User");
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type(testUserEmail); .fill(testUserEmail);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
@ -101,9 +101,9 @@ test.describe.serial(() => {
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type(testUserEmail); .fill(testUserEmail);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Login with Email" }).click();
const { email } = await mailServer.captureOne(testUserEmail, { const { email } = await mailServer.captureOne(testUserEmail, {
wait: 5000, wait: 5000,
@ -131,13 +131,13 @@ test.describe.serial(() => {
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type(testUserEmail); .fill(testUserEmail);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Login with Email" }).click();
const code = await getCode(); const code = await getCode();
await page.getByPlaceholder("Enter your 6-digit code").type(code); await page.getByPlaceholder("Enter your 6-digit code").fill(code);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();
@ -151,13 +151,13 @@ test.describe.serial(() => {
await page await page
.getByPlaceholder("jessie.smith@example.com") .getByPlaceholder("jessie.smith@example.com")
.type("Test@example.com"); .fill("Test@example.com");
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Login with Email" }).click();
const code = await getCode(); const code = await getCode();
await page.getByPlaceholder("Enter your 6-digit code").type(code); await page.getByPlaceholder("Enter your 6-digit code").fill(code);
await page.getByRole("button", { name: "Continue", exact: true }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click();

View file

@ -10,7 +10,7 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-gray-50 text-foreground", default: "bg-gray-50 text-foreground",
destructive: destructive:
"text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", "text-destructive bg-rose-50 border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -46,7 +46,7 @@ const AlertTitle = React.forwardRef<
<h5 <h5
ref={ref} ref={ref}
className={cn( className={cn(
"mb-2 text-sm font-bold leading-none tracking-tight", "mb-2 text-sm font-semibold leading-none tracking-tight",
className, className,
)} )}
{...props} {...props}
@ -60,10 +60,7 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn("text-sm [&_p]:leading-relaxed", className)}
"text-muted-foreground text-sm [&_p]:leading-relaxed",
className,
)}
{...props} {...props}
/> />
)); ));

View file

@ -64,6 +64,10 @@
"DISABLE_LANDING_PAGE", "DISABLE_LANDING_PAGE",
"EMAIL_PROVIDER", "EMAIL_PROVIDER",
"MAINTENANCE_MODE", "MAINTENANCE_MODE",
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"MICROSOFT_TENANT_ID",
"NEXT_PUBLIC_ABOUT_PAGE_URL",
"NEXT_PUBLIC_APP_BASE_URL", "NEXT_PUBLIC_APP_BASE_URL",
"NEXT_PUBLIC_APP_VERSION", "NEXT_PUBLIC_APP_VERSION",
"NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_BASE_URL",