Merge pull request #326 from yordis/improve-actions

chore: improve idp integration using server action
This commit is contained in:
Max Peintner
2025-04-16 09:40:05 +02:00
committed by GitHub
6 changed files with 117 additions and 82 deletions

View File

@@ -46,6 +46,7 @@
"copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1",
"jose": "^5.3.0",
"lucide-react": "0.469.0",
"moment": "^2.29.4",
"next": "15.2.0-canary.33",
"next-intl": "^3.25.1",

View File

@@ -1,7 +1,9 @@
"use client";
import { clsx } from "clsx";
import { Loader2Icon } from "lucide-react";
import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react";
import { useFormStatus } from "react-dom";
export type SignInWithIdentityProviderProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
@@ -15,15 +17,25 @@ export const BaseButton = forwardRef<
HTMLButtonElement,
SignInWithIdentityProviderProps
>(function BaseButton(props, ref) {
const formStatus = useFormStatus();
return (
<button
{...props}
type="button"
type="submit"
ref={ref}
disabled={formStatus.pending}
className={clsx(
"transition-all cursor-pointer flex flex-row items-center bg-background-light-400 text-text-light-500 dark:bg-background-dark-500 dark:text-text-dark-500 border border-divider-light hover:border-black dark:border-divider-dark hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500 outline-none rounded-md px-4 text-sm",
"flex-1 transition-all cursor-pointer flex flex-row items-center bg-background-light-400 text-text-light-500 dark:bg-background-dark-500 dark:text-text-dark-500 border border-divider-light hover:border-black dark:border-divider-dark hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500 outline-none rounded-md px-4 text-sm",
props.className,
)}
/>
>
<div className="flex-1 justify-between flex items-center gap-4">
<div className="flex-1 flex flex-row items-center">
{props.children}
</div>
{formStatus.pending && <Loader2Icon className="w-4 h-4 animate-spin" />}
</div>
</button>
);
});

View File

@@ -4,16 +4,9 @@ import { useTranslations } from "next-intl";
import { forwardRef } from "react";
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
export const SignInWithGithub = forwardRef<
HTMLButtonElement,
SignInWithIdentityProviderProps
>(function SignInWithGithub(props, ref) {
const { children, name, ...restProps } = props;
const t = useTranslations("idp");
function GitHubLogo() {
return (
<BaseButton {...restProps} ref={ref}>
<div className="mx-2 my-2 flex items-center justify-center">
<>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -40,6 +33,21 @@ export const SignInWithGithub = forwardRef<
clipRule="evenodd"
></path>
</svg>
</>
);
}
export const SignInWithGithub = forwardRef<
HTMLButtonElement,
SignInWithIdentityProviderProps
>(function SignInWithGithub(props, ref) {
const { children, name, ...restProps } = props;
const t = useTranslations("idp");
return (
<BaseButton {...restProps} ref={ref}>
<div className="mx-2 my-2 flex items-center justify-center">
<GitHubLogo />
</div>
{children ? (
children

View File

@@ -1,13 +1,12 @@
"use client";
import { idpTypeToSlug } from "@/lib/idp";
import { startIDPFlow } from "@/lib/server/idp";
import { redirectToIdp } from "@/lib/server/idp";
import {
IdentityProvider,
IdentityProviderType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
import { ReactNode, useCallback, useState } from "react";
import { ReactNode, useActionState } from "react";
import { Alert } from "./alert";
import { SignInWithIdentityProviderProps } from "./idps/base-button";
import { SignInWithApple } from "./idps/sign-in-with-apple";
@@ -31,45 +30,10 @@ export function SignInWithIdp({
organization,
linkOnly,
}: Readonly<SignInWithIDPProps>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const [state, action, _isPending] = useActionState(redirectToIdp, {});
const startFlow = useCallback(
async (idpId: string, provider: string) => {
setLoading(true);
const params = new URLSearchParams();
if (linkOnly) params.set("link", "true");
if (requestId) params.set("requestId", requestId);
if (organization) params.set("organization", organization);
try {
const response = await startIDPFlow({
idpId,
successUrl: `/idp/${provider}/success?` + params.toString(),
failureUrl: `/idp/${provider}/failure?` + params.toString(),
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response?.redirect) {
return router.push(response.redirect);
}
} catch {
setError("Could not start IDP flow");
} finally {
setLoading(false);
}
},
[requestId, organization, linkOnly, router],
);
const renderIDPButton = (idp: IdentityProvider) => {
const renderIDPButton = (idp: IdentityProvider, index: number) => {
const { id, name, type } = idp;
const onClick = () => startFlow(id, idpTypeToSlug(type));
const components: Partial<
Record<
@@ -93,16 +57,27 @@ export function SignInWithIdp({
const Component = components[type];
return Component ? (
<Component key={id} name={name} onClick={onClick} />
<form action={action} className="flex" key={`idp-${index}`}>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="provider" value={idpTypeToSlug(type)} />
<input type="hidden" name="requestId" value={requestId} />
<input type="hidden" name="organization" value={organization} />
<input
type="hidden"
name="linkOnly"
value={linkOnly ? "true" : "false"}
/>
<Component key={id} name={name} />
</form>
) : null;
};
return (
<div className="flex flex-col w-full space-y-2 text-sm">
{identityProviders?.map(renderIDPButton)}
{error && (
{state?.error && (
<div className="py-4">
<Alert>{error}</Alert>
<Alert>{state?.error}</Alert>
</div>
)}
</div>

View File

@@ -6,11 +6,45 @@ import {
startIdentityProviderFlow,
} from "@/lib/zitadel";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getNextUrl } from "../client";
import { getServiceUrlFromHeaders } from "../service";
import { checkEmailVerification } from "../verify-helper";
import { createSessionForIdpAndUpdateCookie } from "./cookie";
export type RedirectToIdpState = { error?: string | null } | undefined;
export async function redirectToIdp(
prevState: RedirectToIdpState,
formData: FormData,
): Promise<RedirectToIdpState> {
const params = new URLSearchParams();
const linkOnly = formData.get("linkOnly") === "true";
const requestId = formData.get("requestId") as string;
const organization = formData.get("organization") as string;
const idpId = formData.get("id") as string;
const provider = formData.get("provider") as string;
if (linkOnly) params.set("link", "true");
if (requestId) params.set("requestId", requestId);
if (organization) params.set("organization", organization);
const response = await startIDPFlow({
idpId,
successUrl: `/idp/${provider}/success?` + params.toString(),
failureUrl: `/idp/${provider}/failure?` + params.toString(),
});
if (response && "error" in response && response?.error) {
return { error: response.error };
}
if (response && "redirect" in response && response?.redirect) {
redirect(response.redirect);
}
}
export type StartIDPFlowCommand = {
idpId: string;
successUrl: string;

25
pnpm-lock.yaml generated
View File

@@ -104,6 +104,9 @@ importers:
jose:
specifier: ^5.3.0
version: 5.8.0
lucide-react:
specifier: 0.469.0
version: 0.469.0(react@19.0.0)
moment:
specifier: ^2.29.4
version: 2.30.1
@@ -1484,9 +1487,6 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.13':
resolution: {integrity: sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -3384,6 +3384,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.469.0:
resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -5889,7 +5894,7 @@ snapshots:
'@react-aria/ssr@3.9.6(react@19.0.0)':
dependencies:
'@swc/helpers': 0.5.5
'@swc/helpers': 0.5.15
react: 19.0.0
'@react-aria/utils@3.25.3(react@19.0.0)':
@@ -5897,13 +5902,13 @@ snapshots:
'@react-aria/ssr': 3.9.6(react@19.0.0)
'@react-stately/utils': 3.10.4(react@19.0.0)
'@react-types/shared': 3.25.0(react@19.0.0)
'@swc/helpers': 0.5.5
'@swc/helpers': 0.5.15
clsx: 2.1.1
react: 19.0.0
'@react-stately/utils@3.10.4(react@19.0.0)':
dependencies:
'@swc/helpers': 0.5.13
'@swc/helpers': 0.5.15
react: 19.0.0
'@react-types/shared@3.25.0(react@19.0.0)':
@@ -5978,10 +5983,6 @@ snapshots:
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.13':
dependencies:
tslib: 2.8.1
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -8241,6 +8242,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.469.0(react@19.0.0):
dependencies:
react: 19.0.0
lz-string@1.5.0: {}
magic-string@0.30.12: