show already setupped methods

This commit is contained in:
peintnermax
2024-04-23 10:50:58 +02:00
parent 44435ad0e5
commit 827af38220
6 changed files with 277 additions and 369 deletions

View File

@@ -2,6 +2,8 @@ import {
getBrandingSettings, getBrandingSettings,
getLoginSettings, getLoginSettings,
getSession, getSession,
getUserByID,
listAuthenticationMethodTypes,
server, server,
} from "#/lib/zitadel"; } from "#/lib/zitadel";
import Alert from "#/ui/Alert"; import Alert from "#/ui/Alert";
@@ -34,8 +36,15 @@ export default async function Page({
organization organization
); );
return getSession(server, recent.id, recent.token).then((response) => { return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) { if (response?.session && response.session.factors?.user?.id) {
return response.session; return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
} }
}); });
} }
@@ -43,8 +52,15 @@ export default async function Page({
async function loadSessionById(sessionId: string, organization?: string) { async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization); const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => { return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) { if (response?.session && response.session.factors?.user?.id) {
return response.session; return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
} }
}); });
} }
@@ -67,19 +83,18 @@ export default async function Page({
></UserAvatar> ></UserAvatar>
)} )}
{!sessionFactors && <div className="py-4"></div>}
{!(loginName || sessionId) && ( {!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert> <Alert>Provide your active session as loginName param</Alert>
)} )}
{loginSettings ? ( {loginSettings && sessionFactors ? (
<ChooseSecondFactorToSetup <ChooseSecondFactorToSetup
loginName={loginName} loginName={loginName}
sessionId={sessionId} sessionId={sessionId}
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} organization={organization}
loginSettings={loginSettings} loginSettings={loginSettings}
userMethods={sessionFactors.authMethods ?? []}
></ChooseSecondFactorToSetup> ></ChooseSecondFactorToSetup>
) : ( ) : (
<Alert>No second factors available to setup.</Alert> <Alert>No second factors available to setup.</Alert>

View File

@@ -1,6 +1,7 @@
import { getBrandingSettings, getLoginSettings, server } from "#/lib/zitadel"; import { getBrandingSettings, getLoginSettings, server } from "#/lib/zitadel";
import DynamicTheme from "#/ui/DynamicTheme"; import DynamicTheme from "#/ui/DynamicTheme";
import LoginOTP from "#/ui/LoginOTP"; import LoginOTP from "#/ui/LoginOTP";
import LoginPasskey from "#/ui/LoginPasskey";
import VerifyU2F from "#/ui/VerifyU2F"; import VerifyU2F from "#/ui/VerifyU2F";
export default async function Page({ export default async function Page({
@@ -22,12 +23,13 @@ export default async function Page({
<p className="ztdl-p">Verify your account with your device.</p> <p className="ztdl-p">Verify your account with your device.</p>
<VerifyU2F <LoginPasskey
loginName={loginName} loginName={loginName}
sessionId={sessionId} sessionId={sessionId}
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} organization={organization}
></VerifyU2F> altPassword={false}
></LoginPasskey>
</div> </div>
</DynamicTheme> </DynamicTheme>
); );

View File

@@ -7,6 +7,7 @@ let colors = {
text: { light: { contrast: {} }, dark: { contrast: {} } }, text: { light: { contrast: {} }, dark: { contrast: {} } },
link: { light: { contrast: {} }, dark: { contrast: {} } }, link: { light: { contrast: {} }, dark: { contrast: {} } },
}; };
const shades = [ const shades = [
"50", "50",
"100", "100",
@@ -49,7 +50,51 @@ module.exports = {
}, },
theme: { theme: {
extend: { extend: {
colors, colors: {
...colors,
state: {
success: {
light: {
background: "#cbf4c9",
color: "#0e6245",
},
dark: {
background: "#68cf8340",
color: "#cbf4c9",
},
},
error: {
light: {
background: "#ffc1c1",
color: "#620e0e",
},
dark: {
background: "#af455359",
color: "#ffc1c1",
},
},
neutral: {
light: {
background: "#e4e7e4",
color: "#000000",
},
dark: {
background: "#1a253c",
color: "#ffffff",
},
},
alert: {
light: {
background: "#fbbf24",
color: "#92400e",
},
dark: {
background: "#92400e50",
color: "#fbbf24",
},
},
},
},
animation: { animation: {
shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;", shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;",
}, },

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { LoginSettings } from "@zitadel/server"; import { AuthenticationMethodType, LoginSettings } from "@zitadel/server";
import Link from "next/link"; import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import clsx from "clsx";
type Props = { type Props = {
loginName?: string; loginName?: string;
@@ -9,6 +11,7 @@ type Props = {
authRequestId?: string; authRequestId?: string;
organization?: string; organization?: string;
loginSettings: LoginSettings; loginSettings: LoginSettings;
userMethods: AuthenticationMethodType[];
}; };
export default function ChooseSecondFactorToSetup({ export default function ChooseSecondFactorToSetup({
@@ -17,16 +20,19 @@ export default function ChooseSecondFactorToSetup({
authRequestId, authRequestId,
organization, organization,
loginSettings, loginSettings,
userMethods,
}: Props) { }: Props) {
const cardClasses = (alreadyAdded: boolean) =>
clsx(
"bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ",
alreadyAdded ? "opacity-50" : "hover:shadow-lg hover:dark:bg-white/10"
);
const TOTP = (alreadyAdded: boolean) => {
return ( return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{loginSettings.secondFactors.map((factor, i) => {
return (
<div key={"method-" + i}>
{factor === 1 && (
<Link <Link
href="/otp/time-based/set" href={userMethods.includes(4) ? "" : "/otp/time-based/set"}
className="bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 hover:shadow-lg hover:dark:bg-white/10 border border-divider-light dark:border-divider-dark transition-all " className={cardClasses(alreadyAdded)}
> >
<div className="font-medium flex items-center"> <div className="font-medium flex items-center">
<svg <svg
@@ -71,15 +77,20 @@ C72,238.87917,85.87916,225,102.99997,225H248z"
/> />
</svg>{" "} </svg>{" "}
<span>Authenticator App</span> <span>Authenticator App</span>
{alreadyAdded && (
<>
<span className="flex-1"></span>
<Setup />
</>
)}
</div> </div>
</Link> </Link>
)} );
};
{factor === 2 && ( const U2F = (alreadyAdded: boolean) => {
<Link return (
href="/u2f/set" <Link href="/u2f/set" className={cardClasses(alreadyAdded)}>
className="bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 hover:shadow-lg hover:dark:bg-white/10 border border-divider-light dark:border-divider-dark transition-all "
>
<div className="font-medium flex items-center"> <div className="font-medium flex items-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -96,14 +107,20 @@ C72,238.87917,85.87916,225,102.99997,225H248z"
/> />
</svg> </svg>
<span>Universal Second Factor</span> <span>Universal Second Factor</span>
{alreadyAdded && (
<>
<span className="flex-1"></span>
<Setup />
</>
)}
</div> </div>
</Link> </Link>
)} );
{factor === 3 && ( };
<Link
href="/otp/email/set" const EMAIL = (alreadyAdded: boolean) => {
className="bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 hover:shadow-lg hover:dark:bg-white/10 border border-divider-light dark:border-divider-dark transition-all " return (
> <Link href="/otp/email/set" className={cardClasses(alreadyAdded)}>
<div className="font-medium flex items-center"> <div className="font-medium flex items-center">
<svg <svg
className="w-8 h-8 mr-4" className="w-8 h-8 mr-4"
@@ -121,14 +138,20 @@ C72,238.87917,85.87916,225,102.99997,225H248z"
</svg> </svg>
<span>Code via Email</span> <span>Code via Email</span>
{alreadyAdded && (
<>
<span className="flex-1"></span>
<Setup />
</>
)}
</div> </div>
</Link> </Link>
)} );
{factor === 4 && ( };
<Link
href="/otp/sms/set" const SMS = (alreadyAdded: boolean) => {
className="bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 hover:shadow-lg hover:dark:bg-white/10 border border-divider-light dark:border-divider-dark transition-all " return (
> <Link href="/otp/sms/set" className={cardClasses(alreadyAdded)}>
<div className="font-medium flex items-center"> <div className="font-medium flex items-center">
<svg <svg
className="w-8 h-8 mr-4" className="w-8 h-8 mr-4"
@@ -145,12 +168,33 @@ C72,238.87917,85.87916,225,102.99997,225H248z"
/> />
</svg> </svg>
<span>Code via SMS</span> <span>Code via SMS</span>
{alreadyAdded && (
<>
<span className="flex-1"></span>
<Setup />
</>
)}
</div> </div>
</Link> </Link>
)} );
};
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{loginSettings.secondFactors.map((factor, i) => {
return (
<div key={"method-" + i}>
{factor === 1 && TOTP(userMethods.includes(4))}
{factor === 2 && U2F(userMethods.includes(5))}
{factor === 3 && EMAIL(userMethods.includes(7))}
{factor === 4 && SMS(userMethods.includes(6))}
</div> </div>
); );
})} })}
</div> </div>
); );
} }
function Setup() {
return <StateBadge state={BadgeState.Success}>Setup</StateBadge>;
}

View File

@@ -0,0 +1,35 @@
import clsx from "clsx";
import { ReactNode } from "react";
export enum BadgeState {
Info = "info",
Error = "error",
Success = "success",
Alert = "alert",
}
export type StateBadgeProps = {
state: BadgeState;
children: ReactNode;
};
const getBadgeClasses = (state: BadgeState) =>
clsx({
"w-fit border-box h-18.5px flex flex-row items-center whitespace-nowrap tracking-wider leading-4 items-center justify-center px-2 py-2px text-12px rounded-full shadow-sm":
true,
"bg-state-success-light-background text-state-success-light-color dark:bg-state-success-dark-background dark:text-state-success-dark-color ":
state === BadgeState.Success,
"bg-state-neutral-light-background text-state-neutral-light-color dark:bg-state-neutral-dark-background dark:text-state-neutral-dark-color":
state === BadgeState.Info,
"bg-state-error-light-background text-state-error-light-color dark:bg-state-error-dark-background dark:text-state-error-dark-color":
state === BadgeState.Error,
"bg-state-alert-light-background text-state-alert-light-color dark:bg-state-alert-dark-background dark:text-state-alert-dark-color":
state === BadgeState.Alert,
});
export function StateBadge({
state = BadgeState.Success,
children,
}: StateBadgeProps) {
return <span className={`${getBadgeClasses(state)}`}>{children}</span>;
}

View File

@@ -1,233 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
import { Button, ButtonVariants } from "./Button";
import Alert from "./Alert";
import { Spinner } from "./Spinner";
import { Checks } from "@zitadel/server";
// either loginName or sessionId must be provided
type Props = {
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
};
export default function VerifyU2F({
loginName,
sessionId,
authRequestId,
organization,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
setLoading(true);
updateSessionForChallenge()
.then((response) => {
const pK =
response.challenges.webAuthN.publicKeyCredentialRequestOptions
.publicKey;
if (pK) {
submitLoginAndContinue(pK)
.then(() => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
} else {
setError("Could not request passkey challenge");
setLoading(false);
}
})
.catch((error) => {
setError(error);
setLoading(false);
});
}
}, []);
async function updateSessionForChallenge() {
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
sessionId,
organization,
challenges: {
webAuthN: {
domain: "",
userVerificationRequirement: 1,
},
},
authRequestId,
}),
});
setLoading(false);
if (!res.ok) {
const error = await res.json();
throw error.details.details;
}
return res.json();
}
async function submitLogin(data: any) {
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
sessionId,
organization,
checks: {
webAuthN: { credentialAssertionData: data },
} as Checks,
authRequestId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
async function submitLoginAndContinue(
publicKey: any
): Promise<boolean | void> {
publicKey.challenge = coerceToArrayBuffer(
publicKey.challenge,
"publicKey.challenge"
);
publicKey.allowCredentials.map((listItem: any) => {
listItem.id = coerceToArrayBuffer(
listItem.id,
"publicKey.allowCredentials.id"
);
});
navigator.credentials
.get({
publicKey,
})
.then((assertedCredential: any) => {
if (assertedCredential) {
const authData = new Uint8Array(
assertedCredential.response.authenticatorData
);
const clientDataJSON = new Uint8Array(
assertedCredential.response.clientDataJSON
);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(
assertedCredential.response.userHandle
);
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: assertedCredential.type,
response: {
authenticatorData: coerceToBase64Url(authData, "authData"),
clientDataJSON: coerceToBase64Url(
clientDataJSON,
"clientDataJSON"
),
signature: coerceToBase64Url(sig, "sig"),
userHandle: coerceToBase64Url(userHandle, "userHandle"),
},
};
return submitLogin(data).then((resp) => {
if (authRequestId && resp && resp.sessionId) {
return router.push(
`/login?` +
new URLSearchParams({
sessionId: resp.sessionId,
authRequest: authRequestId,
})
);
} else {
return router.push(
`/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
)
);
}
});
} else {
setLoading(false);
setError("An error on retrieving passkey");
return null;
}
})
.catch((error) => {
console.error(error);
setLoading(false);
// setError(error);
return null;
});
}
return (
<div className="w-full">
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading}
onClick={() => updateSessionForChallenge()}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</div>
);
}