mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-11 16:45:13 +00:00
chore: reproducible pipeline with dev containers (#10305)
# Which Problems Are Solved - The previous monorepo in monorepo structure for the login app and its related packages was fragmented, complicated and buggy. - The process for building and testing the login container was inconsistent between local development and CI. - Lack of clear documentation as well as easy and reliable ways for non-frontend developers to reproduce and fix failing PR checks locally. # How the Problems Are Solved - Consolidated the login app and its related npm packages by moving the main package to `apps/login/apps/login` and merging `apps/login/packages/integration` and `apps/login/packages/acceptance` into the main `apps/login` package. - Migrated from Docker Compose-based test setups to dev container-based setups, adding support for multiple dev container configurations: - `.devcontainer/base` - `.devcontainer/turbo-lint-unit` - `.devcontainer/turbo-lint-unit-debug` - `.devcontainer/login-integration` - `.devcontainer/login-integration-debug` - Added npm scripts to run the new dev container setups, enabling exact reproduction of GitHub PR checks locally, and updated the pipeline to use these containers. - Cleaned up Dockerfiles and docker-bake.hcl files to only build the production image for the login app. - Cleaned up compose files to focus on dev environments in dev containers. - Updated `CONTRIBUTING.md` with guidance on running and debugging PR checks locally using the new dev container approach. - Introduced separate Dockerfiles for the login app to distinguish between using published client packages and building clients from local protos. - Ensured the login container is always built in the pipeline for use in integration and acceptance tests. - Updated Makefile and GitHub Actions workflows to use `--frozen-lockfile` for installing pnpm packages, ensuring reproducible installs. - Disabled GitHub release creation by the changeset action. - Refactored the `/build` directory structure for clarity and maintainability. - Added a `clean` command to `docks/package.json`. - Experimentally added `knip` to the `zitadel-client` package for improved linting of dependencies and exports. # Additional Changes - Fixed Makefile commands for consistency and reliability. - Improved the structure and clarity of the `/build` directory to support seamless integration of the login build. - Enhanced documentation and developer experience for running and debugging CI checks locally. # Additional Context - See updated `CONTRIBUTING.md` for new local development and debugging instructions. - These changes are a prerequisite for further improvements to the CI pipeline and local development workflow. - Closes #10276
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Fragment } from "react";
|
||||
|
||||
type Props = {
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export function AddressBar({ domain }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 overflow-hidden p-3.5 lg:px-5 lg:py-3">
|
||||
<div className="text-gray-600">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex space-x-1 text-sm font-medium">
|
||||
<div className="max-w-[150px] overflow-hidden text-ellipsis px-2 text-gray-500">
|
||||
<span className="whitespace-nowrap">{domain}</span>
|
||||
</div>
|
||||
{pathname ? (
|
||||
<>
|
||||
<span className="text-gray-600">/</span>
|
||||
{pathname
|
||||
.split("/")
|
||||
.slice(1)
|
||||
.filter((s) => !!s)
|
||||
.map((segment) => {
|
||||
return (
|
||||
<Fragment key={segment}>
|
||||
<span>
|
||||
<span
|
||||
key={segment}
|
||||
className="animate-[highlight_1s_ease-in-out_1] rounded-full px-1.5 py-0.5 text-gray-800 dark:text-gray-100"
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="text-gray-600">/</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { clsx } from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
type?: AlertType;
|
||||
};
|
||||
|
||||
export enum AlertType {
|
||||
ALERT,
|
||||
INFO,
|
||||
}
|
||||
|
||||
const yellow =
|
||||
"border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200";
|
||||
// const red =
|
||||
"border-red-600/40 dark:border-red-500/20 bg-red-200/30 text-red-600 dark:bg-red-700/20 dark:text-red-200";
|
||||
const neutral =
|
||||
"border-divider-light dark:border-divider-dark bg-black/5 text-gray-600 dark:bg-white/10 dark:text-gray-200";
|
||||
|
||||
export function Alert({ children, type = AlertType.ALERT }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex scroll-px-40 flex-row items-center justify-center rounded-md border py-2 pr-2",
|
||||
{
|
||||
[yellow]: type === AlertType.ALERT,
|
||||
[neutral]: type === AlertType.INFO,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{type === AlertType.ALERT && (
|
||||
<ExclamationTriangleIcon className="ml-2 mr-2 h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
{type === AlertType.INFO && (
|
||||
<InformationCircleIcon className="ml-2 mr-2 h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="w-full text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ColorShade, getColorHash } from "@/helpers/colors";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import { getInitials } from "./avatar";
|
||||
|
||||
interface AvatarProps {
|
||||
appName: string;
|
||||
imageUrl?: string;
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
export function AppAvatar({ appName, imageUrl, shadow }: AvatarProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const credentials = getInitials(appName, appName);
|
||||
|
||||
const color: ColorShade = getColorHash(appName);
|
||||
|
||||
const avatarStyleDark = {
|
||||
backgroundColor: color[900],
|
||||
color: color[200],
|
||||
};
|
||||
|
||||
const avatarStyleLight = {
|
||||
backgroundColor: color[200],
|
||||
color: color[900],
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dark:group-focus:ring-offset-blue dark:text-blue pointer-events-none flex h-[100px] w-[100px] cursor-default items-center justify-center rounded-full bg-primary-light-500 text-primary-light-contrast-500 transition-colors duration-200 hover:bg-primary-light-400 group-focus:outline-none group-focus:ring-2 group-focus:ring-primary-light-200 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 hover:dark:bg-primary-dark-500 dark:group-focus:ring-primary-dark-400 ${
|
||||
shadow ? "shadow" : ""
|
||||
}`}
|
||||
style={resolvedTheme === "light" ? avatarStyleLight : avatarStyleDark}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
height={48}
|
||||
width={48}
|
||||
alt="avatar"
|
||||
className="h-full w-full rounded-full border border-divider-light dark:border-divider-dark"
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-3xl uppercase`}>{credentials}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { BadgeState, StateBadge } from "./state-badge";
|
||||
|
||||
const cardClasses = (alreadyAdded: boolean) =>
|
||||
clsx(
|
||||
"relative 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 cursor-default"
|
||||
: "hover:shadow-lg hover:dark:bg-white/10",
|
||||
);
|
||||
|
||||
const LinkWrapper = ({
|
||||
alreadyAdded,
|
||||
children,
|
||||
link,
|
||||
}: {
|
||||
alreadyAdded: boolean;
|
||||
children: ReactNode;
|
||||
link: string;
|
||||
}) => {
|
||||
return !alreadyAdded ? (
|
||||
<Link href={link} className={cardClasses(alreadyAdded)}>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={cardClasses(alreadyAdded)}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TOTP = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="mr-4 h-8 w-8 -translate-x-[2px] transform fill-current text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>timer-lock-outline</title>
|
||||
<path d="M11 8H13V14H11V8M13 19.92C12.67 19.97 12.34 20 12 20C8.13 20 5 16.87 5 13S8.13 6 12 6C14.82 6 17.24 7.67 18.35 10.06C18.56 10.04 18.78 10 19 10C19.55 10 20.07 10.11 20.57 10.28C20.23 9.22 19.71 8.24 19.03 7.39L20.45 5.97C20 5.46 19.55 5 19.04 4.56L17.62 6C16.07 4.74 14.12 4 12 4C7.03 4 3 8.03 3 13S7.03 22 12 22C12.42 22 12.83 21.96 13.24 21.91C13.09 21.53 13 21.12 13 20.7V19.92M15 1H9V3H15V1M23 17.3V20.8C23 21.4 22.4 22 21.7 22H16.2C15.6 22 15 21.4 15 20.7V17.2C15 16.6 15.6 16 16.2 16V14.5C16.2 13.1 17.6 12 19 12S21.8 13.1 21.8 14.5V16C22.4 16 23 16.6 23 17.3M20.5 14.5C20.5 13.7 19.8 13.2 19 13.2S17.5 13.7 17.5 14.5V16H20.5V14.5Z" />
|
||||
</svg>{" "}
|
||||
<span>Authenticator App</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const U2F = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="mr-4 h-8 w-8"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
|
||||
/>
|
||||
</svg>
|
||||
<span>Universal Second Factor</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const EMAIL = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="mr-4 h-8 w-8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>Code via Email</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const SMS = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="mr-4 h-8 w-8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
<span>Code via SMS</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="mr-4 h-8 w-8"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
|
||||
/>
|
||||
</svg>
|
||||
<span>Passkeys</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const PASSWORD = (alreadyAdded: boolean, link: string) => {
|
||||
return (
|
||||
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center font-medium",
|
||||
alreadyAdded ? "" : "",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="mr-4 h-7 w-8 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>form-textbox-password</title>
|
||||
<path d="M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z" />
|
||||
</svg>
|
||||
<span>Password</span>
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
<Setup />
|
||||
</>
|
||||
)}
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
function Setup() {
|
||||
return (
|
||||
<div className="absolute right-2 top-0 transform">
|
||||
<StateBadge evenPadding={true} state={BadgeState.Success}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</StateBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export enum AuthenticationMethod {
|
||||
Passkey = "passkey",
|
||||
Password = "password",
|
||||
}
|
||||
|
||||
export const methods = [
|
||||
AuthenticationMethod.Passkey,
|
||||
AuthenticationMethod.Password,
|
||||
];
|
||||
|
||||
export function AuthenticationMethodRadio({
|
||||
selected,
|
||||
selectionChanged,
|
||||
}: {
|
||||
selected: any;
|
||||
selectionChanged: (value: any) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<RadioGroup value={selected} onChange={selectionChanged}>
|
||||
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
|
||||
<div className="flex flex-row space-x-4">
|
||||
{methods.map((method) => (
|
||||
<RadioGroup.Option
|
||||
key={method}
|
||||
value={method}
|
||||
data-testid={method + "-radio"}
|
||||
className={({ active, checked }) =>
|
||||
`${
|
||||
active
|
||||
? "ring-2 ring-primary-light-500 ring-opacity-60 dark:ring-white/20"
|
||||
: ""
|
||||
} ${
|
||||
checked
|
||||
? "bg-background-light-400 ring-2 ring-primary-light-500 dark:bg-background-dark-400 dark:ring-primary-dark-500"
|
||||
: "bg-background-light-400 dark:bg-background-dark-400"
|
||||
} boder-divider-light relative flex h-full flex-1 cursor-pointer rounded-lg border px-5 py-4 hover:shadow-lg focus:outline-none dark:border-divider-dark dark:hover:bg-white/10`
|
||||
}
|
||||
>
|
||||
{({ checked }) => (
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center text-sm">
|
||||
{method === "passkey" && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="mb-3 h-8 w-8"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{method === "password" && (
|
||||
<svg
|
||||
className="mb-3 h-8 w-8 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>form-textbox-password</title>
|
||||
<path d="M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z" />
|
||||
</svg>
|
||||
)}
|
||||
<RadioGroup.Label
|
||||
as="p"
|
||||
className={`font-medium ${checked ? "" : ""}`}
|
||||
>
|
||||
{method === AuthenticationMethod.Passkey && (
|
||||
<Translated
|
||||
i18nKey="methods.passkey"
|
||||
namespace="register"
|
||||
/>
|
||||
)}
|
||||
{method === AuthenticationMethod.Password && (
|
||||
<Translated
|
||||
i18nKey="methods.password"
|
||||
namespace="register"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { ColorShade, getColorHash } from "@/helpers/colors";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
|
||||
interface AvatarProps {
|
||||
name: string | null | undefined;
|
||||
loginName: string;
|
||||
imageUrl?: string;
|
||||
size?: "small" | "base" | "large";
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
export function getInitials(name: string, loginName: string) {
|
||||
let credentials = "";
|
||||
if (name) {
|
||||
const split = name.split(" ");
|
||||
if (split) {
|
||||
const initials =
|
||||
split[0].charAt(0) + (split[1] ? split[1].charAt(0) : "");
|
||||
credentials = initials;
|
||||
} else {
|
||||
credentials = name.charAt(0);
|
||||
}
|
||||
} else {
|
||||
const username = loginName.split("@")[0];
|
||||
let separator = "_";
|
||||
if (username.includes("-")) {
|
||||
separator = "-";
|
||||
}
|
||||
if (username.includes(".")) {
|
||||
separator = ".";
|
||||
}
|
||||
const split = username.split(separator);
|
||||
const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : "");
|
||||
credentials = initials;
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
size = "base",
|
||||
name,
|
||||
loginName,
|
||||
imageUrl,
|
||||
shadow,
|
||||
}: AvatarProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const credentials = getInitials(name ?? loginName, loginName);
|
||||
|
||||
const color: ColorShade = getColorHash(loginName);
|
||||
|
||||
const avatarStyleDark = {
|
||||
backgroundColor: color[900],
|
||||
color: color[200],
|
||||
};
|
||||
|
||||
const avatarStyleLight = {
|
||||
backgroundColor: color[200],
|
||||
color: color[900],
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dark:group-focus:ring-offset-blue dark:text-blue pointer-events-none flex h-full w-full flex-shrink-0 cursor-default items-center justify-center rounded-full bg-primary-light-500 text-primary-light-contrast-500 transition-colors duration-200 hover:bg-primary-light-400 group-focus:outline-none group-focus:ring-2 group-focus:ring-primary-light-200 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 hover:dark:bg-primary-dark-500 dark:group-focus:ring-primary-dark-400 ${
|
||||
shadow ? "shadow" : ""
|
||||
} ${
|
||||
size === "large"
|
||||
? "h-20 w-20 font-normal"
|
||||
: size === "base"
|
||||
? "h-[38px] w-[38px] font-bold"
|
||||
: size === "small"
|
||||
? "!h-[32px] !w-[32px] text-[13px] font-bold"
|
||||
: "h-12 w-12"
|
||||
}`}
|
||||
style={resolvedTheme === "light" ? avatarStyleLight : avatarStyleDark}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
height={48}
|
||||
width={48}
|
||||
alt="avatar"
|
||||
className="h-full w-full rounded-full border border-divider-light dark:border-divider-dark"
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`uppercase ${size === "large" ? "text-xl" : "text-13px"}`}
|
||||
>
|
||||
{credentials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export function BackButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
<Translated i18nKey="back" namespace="common" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { clsx } from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const Label = ({
|
||||
children,
|
||||
animateRerendering,
|
||||
color,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
animateRerendering?: boolean;
|
||||
color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red";
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("rounded-full px-1.5", {
|
||||
"bg-gray-800 text-gray-500": color === "default",
|
||||
"bg-pink-500 text-pink-100": color === "pink",
|
||||
"bg-blue-500 text-blue-100": color === "blue",
|
||||
"bg-cyan-500 text-cyan-100": color === "cyan",
|
||||
"bg-red-500 text-red-100": color === "red",
|
||||
"bg-violet-500 text-violet-100": color === "violet",
|
||||
"bg-orange-500 text-orange-100": color === "orange",
|
||||
"animate-[highlight_1s_ease-in-out_1]": animateRerendering,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const Boundary = ({
|
||||
children,
|
||||
labels = ["children"],
|
||||
size = "default",
|
||||
color = "default",
|
||||
animateRerendering = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
labels?: string[];
|
||||
size?: "small" | "default";
|
||||
color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red";
|
||||
animateRerendering?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("relative rounded-lg border border-dashed", {
|
||||
"p-3 lg:p-5": size === "small",
|
||||
"p-4 lg:p-9 lg:pb-6": size === "default",
|
||||
"border-divider-light dark:border-divider-dark": color === "default",
|
||||
"border-pink-500": color === "pink",
|
||||
"border-blue-500": color === "blue",
|
||||
"border-cyan-500": color === "cyan",
|
||||
"border-red-500": color === "red",
|
||||
"border-violet-500": color === "violet",
|
||||
"border-orange-500": color === "orange",
|
||||
"animate-[rerender_1s_ease-in-out_1] text-pink-500": animateRerendering,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute -top-2 flex space-x-1 text-[9px] uppercase leading-4 tracking-widest",
|
||||
{
|
||||
"left-3 lg:left-5": size === "small",
|
||||
"left-4 lg:left-9": size === "default",
|
||||
},
|
||||
)}
|
||||
>
|
||||
{labels.map((label) => {
|
||||
return (
|
||||
<Label
|
||||
key={label}
|
||||
color={color}
|
||||
animateRerendering={animateRerendering}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { clsx } from "clsx";
|
||||
import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react";
|
||||
|
||||
export enum ButtonSizes {
|
||||
Small = "Small",
|
||||
Large = "Large",
|
||||
}
|
||||
|
||||
export enum ButtonVariants {
|
||||
Primary = "Primary",
|
||||
Secondary = "Secondary",
|
||||
Destructive = "Destructive",
|
||||
}
|
||||
|
||||
export enum ButtonColors {
|
||||
Neutral = "Neutral",
|
||||
Primary = "Primary",
|
||||
Warn = "Warn",
|
||||
}
|
||||
|
||||
export type ButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
size?: ButtonSizes;
|
||||
variant?: ButtonVariants;
|
||||
color?: ButtonColors;
|
||||
};
|
||||
|
||||
export const getButtonClasses = (
|
||||
size: ButtonSizes,
|
||||
variant: ButtonVariants,
|
||||
color: ButtonColors,
|
||||
) =>
|
||||
clsx({
|
||||
"box-border font-normal leading-36px text-14px inline-flex items-center rounded-md focus:outline-none transition-colors transition-shadow duration-300": true,
|
||||
"shadow hover:shadow-xl active:shadow-xl disabled:border-none disabled:bg-gray-300 disabled:text-gray-600 disabled:shadow-none disabled:cursor-not-allowed disabled:dark:bg-gray-800 disabled:dark:text-gray-900":
|
||||
variant === ButtonVariants.Primary,
|
||||
"bg-primary-light-500 dark:bg-primary-dark-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-400 text-primary-light-contrast-500 dark:text-primary-dark-contrast-500":
|
||||
variant === ButtonVariants.Primary && color !== ButtonColors.Warn,
|
||||
"bg-warn-light-500 dark:bg-warn-dark-500 hover:bg-warn-light-400 hover:dark:bg-warn-dark-400 text-white dark:text-white":
|
||||
variant === ButtonVariants.Primary && color === ButtonColors.Warn,
|
||||
"border border-button-light-border dark:border-button-dark-border text-gray-950 hover:bg-gray-500 hover:bg-opacity-20 hover:dark:bg-white hover:dark:bg-opacity-10 focus:bg-gray-500 focus:bg-opacity-20 focus:dark:bg-white focus:dark:bg-opacity-10 dark:text-white disabled:text-gray-600 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent disabled:cursor-not-allowed disabled:dark:text-gray-900":
|
||||
variant === ButtonVariants.Secondary,
|
||||
"border border-button-light-border dark:border-button-dark-border text-warn-light-500 dark:text-warn-dark-500 hover:bg-warn-light-500 hover:bg-opacity-10 dark:hover:bg-warn-light-500 dark:hover:bg-opacity-10 focus:bg-warn-light-500 focus:bg-opacity-20 dark:focus:bg-warn-light-500 dark:focus:bg-opacity-20":
|
||||
color === ButtonColors.Warn && variant !== ButtonVariants.Primary,
|
||||
"px-16 py-2": size === ButtonSizes.Large,
|
||||
"px-4 h-[36px]": size === ButtonSizes.Small,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className = "",
|
||||
variant = ButtonVariants.Primary,
|
||||
size = ButtonSizes.Small,
|
||||
color = ButtonColors.Primary,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
className={`${getButtonClasses(size, variant, color)} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "@/helpers/validators";
|
||||
import {
|
||||
checkSessionAndSetPassword,
|
||||
sendPassword,
|
||||
} from "@/lib/server/password";
|
||||
import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { PasswordComplexity } from "./password-complexity";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
sessionId: string;
|
||||
loginName: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function ChangePasswordForm({
|
||||
passwordComplexitySettings,
|
||||
sessionId,
|
||||
loginName,
|
||||
requestId,
|
||||
organization,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
password: "",
|
||||
comfirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("password");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
async function submitChange(values: Inputs) {
|
||||
setLoading(true);
|
||||
|
||||
const changeResponse = checkSessionAndSetPassword({
|
||||
sessionId,
|
||||
password: values.password,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not change password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (changeResponse && "error" in changeResponse && changeResponse.error) {
|
||||
setError(
|
||||
typeof changeResponse.error === "string"
|
||||
? changeResponse.error
|
||||
: "Unknown error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changeResponse) {
|
||||
setError("Could not change password");
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for a second, to prevent eventual consistency issues
|
||||
|
||||
const passwordResponse = await sendPassword({
|
||||
loginName,
|
||||
organization,
|
||||
checks: create(ChecksSchema, {
|
||||
password: { password: values.password },
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (
|
||||
passwordResponse &&
|
||||
"error" in passwordResponse &&
|
||||
passwordResponse.error
|
||||
) {
|
||||
setError(passwordResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
passwordResponse &&
|
||||
"redirect" in passwordResponse &&
|
||||
passwordResponse.redirect
|
||||
) {
|
||||
return router.push(passwordResponse.redirect);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const watchPassword = watch("password", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
const hasMinLength =
|
||||
passwordComplexitySettings &&
|
||||
watchPassword?.length >= passwordComplexitySettings.minLength;
|
||||
const hasSymbol = symbolValidator(watchPassword);
|
||||
const hasNumber = numberValidator(watchPassword);
|
||||
const hasUppercase = upperCaseValidator(watchPassword);
|
||||
const hasLowercase = lowerCaseValidator(watchPassword);
|
||||
|
||||
const policyIsValid =
|
||||
passwordComplexitySettings &&
|
||||
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
|
||||
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
|
||||
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
|
||||
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
|
||||
hasMinLength;
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
{...register("password", {
|
||||
required: t("change.required.newPassword"),
|
||||
})}
|
||||
label="New Password"
|
||||
error={errors.password?.message as string}
|
||||
data-testid="password-change-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
{...register("confirmPassword", {
|
||||
required: t("change.required.confirmPassword"),
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
data-testid="password-change-confirm-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordComplexitySettings && (
|
||||
<PasswordComplexity
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
password={watchPassword}
|
||||
equals={!!watchPassword && watchPassword === watchConfirmPassword}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<BackButton data-testid="back-button" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={
|
||||
loading ||
|
||||
!policyIsValid ||
|
||||
!formState.isValid ||
|
||||
watchPassword !== watchConfirmPassword
|
||||
}
|
||||
onClick={handleSubmit(submitChange)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="change.submit" namespace="password" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import classNames from "clsx";
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type CheckboxProps = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> & {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChangeVal?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
function Checkbox(
|
||||
{
|
||||
className = "",
|
||||
checked = false,
|
||||
disabled = false,
|
||||
onChangeVal,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [enabled, setEnabled] = useState<boolean>(checked);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(checked);
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<div className="box-sizing block">
|
||||
<input
|
||||
ref={ref}
|
||||
checked={enabled}
|
||||
onChange={(event) => {
|
||||
setEnabled(event.target?.checked);
|
||||
onChangeVal && onChangeVal(event.target?.checked);
|
||||
}}
|
||||
disabled={disabled}
|
||||
type="checkbox"
|
||||
className={classNames(
|
||||
"form-checkbox rounded border-gray-300 text-primary-light-500 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 focus:ring-offset-0 dark:text-primary-dark-500",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
LoginSettings,
|
||||
PasskeysType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { PASSKEYS, PASSWORD } from "./auth-methods";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
authMethods: AuthenticationMethodType[];
|
||||
params: URLSearchParams;
|
||||
loginSettings: LoginSettings | undefined;
|
||||
};
|
||||
|
||||
export function ChooseAuthenticatorToLogin({
|
||||
authMethods,
|
||||
params,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{authMethods.includes(AuthenticationMethodType.PASSWORD) &&
|
||||
loginSettings?.allowUsernamePassword && (
|
||||
<div className="ztdl-p">
|
||||
<Translated i18nKey="chooseAlternativeMethod" namespace="idp" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid w-full grid-cols-1 gap-5 pt-4">
|
||||
{authMethods.includes(AuthenticationMethodType.PASSWORD) &&
|
||||
loginSettings?.allowUsernamePassword &&
|
||||
PASSWORD(false, "/password?" + params)}
|
||||
{authMethods.includes(AuthenticationMethodType.PASSKEY) &&
|
||||
loginSettings?.passkeysType == PasskeysType.ALLOWED &&
|
||||
PASSKEYS(false, "/passkey?" + params)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
LoginSettings,
|
||||
PasskeysType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import { PASSKEYS, PASSWORD } from "./auth-methods";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
authMethods: AuthenticationMethodType[];
|
||||
params: URLSearchParams;
|
||||
loginSettings: LoginSettings;
|
||||
};
|
||||
|
||||
export function ChooseAuthenticatorToSetup({
|
||||
authMethods,
|
||||
params,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
if (authMethods.length !== 0) {
|
||||
return (
|
||||
<Alert type={AlertType.ALERT}>
|
||||
<Translated i18nKey="allSetup" namespace="authenticator" />
|
||||
</Alert>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{loginSettings.passkeysType == PasskeysType.NOT_ALLOWED &&
|
||||
!loginSettings.allowUsernamePassword && (
|
||||
<Alert type={AlertType.ALERT}>
|
||||
<Translated
|
||||
i18nKey="noMethodsAvailable"
|
||||
namespace="authenticator"
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid w-full grid-cols-1 gap-5 pt-4">
|
||||
{!authMethods.includes(AuthenticationMethodType.PASSWORD) &&
|
||||
loginSettings.allowUsernamePassword &&
|
||||
PASSWORD(false, "/password/set?" + params)}
|
||||
{!authMethods.includes(AuthenticationMethodType.PASSKEY) &&
|
||||
loginSettings.passkeysType == PasskeysType.ALLOWED &&
|
||||
PASSKEYS(false, "/passkey/set?" + params)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session";
|
||||
import {
|
||||
LoginSettings,
|
||||
SecondFactorType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
loginName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
loginSettings: LoginSettings;
|
||||
userMethods: AuthenticationMethodType[];
|
||||
checkAfter: boolean;
|
||||
phoneVerified: boolean;
|
||||
emailVerified: boolean;
|
||||
force: boolean;
|
||||
};
|
||||
|
||||
export function ChooseSecondFactorToSetup({
|
||||
userId,
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
loginSettings,
|
||||
userMethods,
|
||||
checkAfter,
|
||||
phoneVerified,
|
||||
emailVerified,
|
||||
force,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
if (sessionId) {
|
||||
params.append("sessionId", sessionId);
|
||||
}
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
if (checkAfter) {
|
||||
params.append("checkAfter", "true");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid w-full grid-cols-1 gap-5 pt-4">
|
||||
{loginSettings.secondFactors.map((factor) => {
|
||||
switch (factor) {
|
||||
case SecondFactorType.OTP:
|
||||
return TOTP(
|
||||
userMethods.includes(AuthenticationMethodType.TOTP),
|
||||
"/otp/time-based/set?" + params,
|
||||
);
|
||||
case SecondFactorType.U2F:
|
||||
return U2F(
|
||||
userMethods.includes(AuthenticationMethodType.U2F),
|
||||
"/u2f/set?" + params,
|
||||
);
|
||||
case SecondFactorType.OTP_EMAIL:
|
||||
return (
|
||||
emailVerified &&
|
||||
EMAIL(
|
||||
userMethods.includes(AuthenticationMethodType.OTP_EMAIL),
|
||||
"/otp/email/set?" + params,
|
||||
)
|
||||
);
|
||||
case SecondFactorType.OTP_SMS:
|
||||
return (
|
||||
phoneVerified &&
|
||||
SMS(
|
||||
userMethods.includes(AuthenticationMethodType.OTP_SMS),
|
||||
"/otp/sms/set?" + params,
|
||||
)
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{!force && (
|
||||
<button
|
||||
className="text-sm transition-all hover:text-primary-light-500 dark:hover:text-primary-dark-500"
|
||||
onClick={async () => {
|
||||
const resp = await skipMFAAndContinueWithNextUrl({
|
||||
userId,
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (resp?.redirect) {
|
||||
return router.push(resp.redirect);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
data-testid="reset-button"
|
||||
>
|
||||
<Translated i18nKey="set.skip" namespace="mfa" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
|
||||
|
||||
type Props = {
|
||||
loginName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
userMethods: AuthenticationMethodType[];
|
||||
};
|
||||
|
||||
export function ChooseSecondFactor({
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
userMethods,
|
||||
}: Props) {
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
if (sessionId) {
|
||||
params.append("sessionId", sessionId);
|
||||
}
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-5 pt-4">
|
||||
{userMethods.map((method, i) => {
|
||||
return (
|
||||
<div key={"method-" + i}>
|
||||
{method === AuthenticationMethodType.TOTP &&
|
||||
TOTP(false, "/otp/time-based?" + params)}
|
||||
{method === AuthenticationMethodType.U2F &&
|
||||
U2F(false, "/u2f?" + params)}
|
||||
{method === AuthenticationMethodType.OTP_EMAIL &&
|
||||
EMAIL(false, "/otp/email?" + params)}
|
||||
{method === AuthenticationMethodType.OTP_SMS &&
|
||||
SMS(false, "/otp/sms?" + params)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { completeDeviceAuthorization } from "@/lib/server/device";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export function ConsentScreen({
|
||||
scope,
|
||||
nextUrl,
|
||||
deviceAuthorizationRequestId,
|
||||
appName,
|
||||
}: {
|
||||
scope?: string[];
|
||||
nextUrl: string;
|
||||
deviceAuthorizationRequestId: string;
|
||||
appName?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
async function denyDeviceAuth() {
|
||||
setLoading(true);
|
||||
const response = await completeDeviceAuthorization(
|
||||
deviceAuthorizationRequestId,
|
||||
)
|
||||
.catch(() => {
|
||||
setError("Could not register user");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response) {
|
||||
return router.push("/device");
|
||||
}
|
||||
}
|
||||
|
||||
const scopes = scope?.filter((s) => !!s);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center space-y-4 pt-4">
|
||||
<ul className="w-full list-disc space-y-2">
|
||||
{scopes?.length === 0 && (
|
||||
<span className="flex w-full flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 py-2 text-sm transition-all dark:bg-background-dark-400">
|
||||
<Translated i18nKey="device.scope.openid" namespace="device" />
|
||||
</span>
|
||||
)}
|
||||
{scopes?.map((s) => {
|
||||
const translationKey = `device.scope.${s}`;
|
||||
const description = t(translationKey);
|
||||
|
||||
// Check if the key itself is returned and provide a fallback
|
||||
const resolvedDescription =
|
||||
description === translationKey ? "" : description;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={s}
|
||||
className="flex w-full flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 py-2 text-sm transition-all dark:bg-background-dark-400"
|
||||
>
|
||||
<span>{resolvedDescription}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="ztdl-p text-left text-xs">
|
||||
<Translated
|
||||
i18nKey="request.disclaimer"
|
||||
namespace="device"
|
||||
data={{ appName: appName }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-row items-center">
|
||||
<Button
|
||||
onClick={() => {
|
||||
denyDeviceAuth();
|
||||
}}
|
||||
variant={ButtonVariants.Secondary}
|
||||
data-testid="deny-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="device.request.deny" namespace="device" />
|
||||
</Button>
|
||||
<span className="flex-grow"></span>
|
||||
|
||||
<Link href={nextUrl}>
|
||||
<Button
|
||||
data-testid="submit-button"
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
>
|
||||
<Translated i18nKey="device.request.submit" namespace="device" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function CopyToClipboard({ value }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
copy(value);
|
||||
const to = setTimeout(setCopied, 1000, false);
|
||||
return () => clearTimeout(to);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center px-2">
|
||||
<button
|
||||
id="tooltip-ctc"
|
||||
type="button"
|
||||
className="text-primary-light-500 dark:text-primary-dark-500"
|
||||
onClick={() => setCopied(true)}
|
||||
>
|
||||
{!copied ? (
|
||||
<ClipboardIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ClipboardDocumentCheckIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Default <head> tags we want shared across the app
|
||||
export function DefaultTags() {
|
||||
return (
|
||||
<>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="/favicon/apple-touch-icon.png"
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
/>
|
||||
<link
|
||||
href="/favicon/favicon-32x32.png"
|
||||
rel="icon"
|
||||
sizes="32x32"
|
||||
type="image/png"
|
||||
/>
|
||||
<link
|
||||
href="/favicon/favicon-16x16.png"
|
||||
rel="icon"
|
||||
sizes="16x16"
|
||||
type="image/png"
|
||||
/>
|
||||
<link href="/favicon/site.webmanifest" rel="manifest" />
|
||||
{/* <link
|
||||
color="#000000"
|
||||
href="/favicon/safari-pinned-tab.svg"
|
||||
rel="mask-icon"
|
||||
/> */}
|
||||
<link href="/favicon/favicon.ico" rel="shortcut icon" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Alert } from "@/components/alert";
|
||||
import { getDeviceAuthorizationRequest } from "@/lib/server/oidc";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {
|
||||
userCode: string;
|
||||
};
|
||||
|
||||
export function DeviceCodeForm({ userCode }: { userCode?: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
userCode: userCode || "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("device");
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
setLoading(true);
|
||||
|
||||
const response = await getDeviceAuthorizationRequest(value.userCode)
|
||||
.catch(() => {
|
||||
setError("Could not continue the request");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (!response || !response.deviceAuthorizationRequest?.id) {
|
||||
setError("Could not continue the request");
|
||||
return;
|
||||
}
|
||||
|
||||
return router.push(
|
||||
`/device/consent?` +
|
||||
new URLSearchParams({
|
||||
requestId: `device_${response.deviceAuthorizationRequest.id}`,
|
||||
user_code: value.userCode,
|
||||
}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="w-full">
|
||||
<div className="mt-4">
|
||||
<TextInput
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
{...register("userCode", { required: t("usercode.required.code") })}
|
||||
label="Code"
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitCodeAndContinue)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="verify" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Logo } from "@/components/logo";
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { ReactNode } from "react";
|
||||
import { AppAvatar } from "./app-avatar";
|
||||
import { ThemeWrapper } from "./theme-wrapper";
|
||||
|
||||
export function DynamicTheme({
|
||||
branding,
|
||||
children,
|
||||
appName,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
branding?: BrandingSettings;
|
||||
appName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ThemeWrapper branding={branding}>
|
||||
<div className="rounded-lg bg-background-light-400 px-8 py-12 dark:bg-background-dark-500">
|
||||
<div className="mx-auto flex flex-col items-center space-y-4">
|
||||
<div className="relative flex flex-row items-center justify-center gap-8">
|
||||
{branding && (
|
||||
<>
|
||||
<Logo
|
||||
lightSrc={branding.lightTheme?.logoUrl}
|
||||
darkSrc={branding.darkTheme?.logoUrl}
|
||||
height={appName ? 100 : 150}
|
||||
width={appName ? 100 : 150}
|
||||
/>
|
||||
|
||||
{appName && <AppAvatar appName={appName} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">{children}</div>
|
||||
<div className="flex flex-row justify-between"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/24/solid";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const ExternalLink = ({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
href: string;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex space-x-2 rounded-lg bg-gray-700 px-3 py-1 text-sm font-medium text-gray-100 hover:bg-gray-500 hover:text-white"
|
||||
>
|
||||
<div>{children}</div>
|
||||
|
||||
<ArrowRightIcon className="block w-4" />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { createNewSessionFromIdpIntent } from "@/lib/server/idp";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
// organization: string;
|
||||
idpIntent: {
|
||||
idpIntentId: string;
|
||||
idpIntentToken: string;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function IdpSignin({
|
||||
userId,
|
||||
idpIntent: { idpIntentId, idpIntentToken },
|
||||
requestId,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
createNewSessionFromIdpIntent({
|
||||
userId,
|
||||
idpIntent: {
|
||||
idpIntentId,
|
||||
idpIntentToken,
|
||||
},
|
||||
requestId,
|
||||
})
|
||||
.then((response) => {
|
||||
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("An internal error occurred");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
{loading && <Spinner className="h-5 w-5" />}
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"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>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
name?: string;
|
||||
e2e?: string;
|
||||
};
|
||||
|
||||
export const BaseButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function BaseButton(props, ref) {
|
||||
const formStatus = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type="submit"
|
||||
ref={ref}
|
||||
disabled={formStatus.pending}
|
||||
className={clsx(
|
||||
"flex flex-1 cursor-pointer flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 text-sm text-text-light-500 outline-none transition-all hover:border-black focus:border-primary-light-500 dark:border-divider-dark dark:bg-background-dark-500 dark:text-text-dark-500 hover:dark:border-white focus:dark:border-primary-dark-500",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<div className="flex flex-1 flex-row items-center">
|
||||
{props.children}
|
||||
</div>
|
||||
{formStatus.pending && <Loader2Icon className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete";
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function completeIDP({
|
||||
idpUserId,
|
||||
idpId,
|
||||
idpUserName,
|
||||
addHumanUser,
|
||||
requestId,
|
||||
organization,
|
||||
branding,
|
||||
idpIntent,
|
||||
}: {
|
||||
idpUserId: string;
|
||||
idpId: string;
|
||||
idpUserName: string;
|
||||
addHumanUser?: AddHumanUserRequest;
|
||||
requestId?: string;
|
||||
organization: string;
|
||||
branding?: BrandingSettings;
|
||||
idpIntent: {
|
||||
idpIntentId: string;
|
||||
idpIntentToken: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="completeRegister.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="completeRegister.description" namespace="idp" />
|
||||
</p>
|
||||
|
||||
<RegisterFormIDPIncomplete
|
||||
idpUserId={idpUserId}
|
||||
idpId={idpId}
|
||||
idpUserName={idpUserName}
|
||||
defaultValues={{
|
||||
email: addHumanUser?.email?.email || "",
|
||||
firstname: addHumanUser?.profile?.givenName || "",
|
||||
lastname: addHumanUser?.profile?.familyName || "",
|
||||
}}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
idpIntent={idpIntent}
|
||||
/>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { Alert, AlertType } from "../../alert";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function linkingFailed(
|
||||
branding?: BrandingSettings,
|
||||
error?: string,
|
||||
) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="linkingError.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="linkingError.description" namespace="idp" />
|
||||
</p>
|
||||
{error && (
|
||||
<div className="w-full">
|
||||
{<Alert type={AlertType.ALERT}>{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { IdpSignin } from "../../idp-signin";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function linkingSuccess(
|
||||
userId: string,
|
||||
idpIntent: { idpIntentId: string; idpIntentToken: string },
|
||||
requestId?: string,
|
||||
branding?: BrandingSettings,
|
||||
) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="linkingSuccess.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="linkingSuccess.description" namespace="idp" />
|
||||
</p>
|
||||
|
||||
<IdpSignin
|
||||
userId={userId}
|
||||
idpIntent={idpIntent}
|
||||
requestId={requestId}
|
||||
/>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { Alert, AlertType } from "../../alert";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function loginFailed(branding?: BrandingSettings, error?: string) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="loginError.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="loginError.description" namespace="idp" />
|
||||
</p>
|
||||
{error && (
|
||||
<div className="w-full">
|
||||
{<Alert type={AlertType.ALERT}>{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { DynamicTheme } from "../../dynamic-theme";
|
||||
import { IdpSignin } from "../../idp-signin";
|
||||
import { Translated } from "../../translated";
|
||||
|
||||
export async function loginSuccess(
|
||||
userId: string,
|
||||
idpIntent: { idpIntentId: string; idpIntentToken: string },
|
||||
requestId?: string,
|
||||
branding?: BrandingSettings,
|
||||
) {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="loginSuccess.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="loginSuccess.description" namespace="idp" />
|
||||
</p>
|
||||
|
||||
<IdpSignin
|
||||
userId={userId}
|
||||
idpIntent={idpIntent}
|
||||
requestId={requestId}
|
||||
/>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Translated } from "../translated";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
export const SignInWithApple = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithApple(props, ref) {
|
||||
const { children, name, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref}>
|
||||
<div className="flex h-12 w-12 items-center justify-center">
|
||||
<div className="h-6 w-6">
|
||||
<svg viewBox="0 0 170 170" fill="currentColor">
|
||||
<title>Apple Logo</title>
|
||||
<path d="M150.37 130.25c-2.45 5.66-5.35 10.87-8.71 15.66-4.58 6.53-8.33 11.05-11.22 13.56-4.48 4.12-9.28 6.23-14.42 6.35-3.69 0-8.14-1.05-13.32-3.18-5.197-2.12-9.973-3.17-14.34-3.17-4.58 0-9.492 1.05-14.746 3.17-5.262 2.13-9.501 3.24-12.742 3.35-4.929.21-9.842-1.96-14.746-6.52-3.13-2.73-7.045-7.41-11.735-14.04-5.032-7.08-9.169-15.29-12.41-24.65-3.471-10.11-5.211-19.9-5.211-29.378 0-10.857 2.346-20.221 7.045-28.068 3.693-6.303 8.606-11.275 14.755-14.925s12.793-5.51 19.948-5.629c3.915 0 9.049 1.211 15.429 3.591 6.362 2.388 10.447 3.599 12.238 3.599 1.339 0 5.877-1.416 13.57-4.239 7.275-2.618 13.415-3.702 18.445-3.275 13.63 1.1 23.87 6.473 30.68 16.153-12.19 7.386-18.22 17.731-18.1 31.002.11 10.337 3.86 18.939 11.23 25.769 3.34 3.17 7.07 5.62 11.22 7.36-.9 2.61-1.85 5.11-2.86 7.51zM119.11 7.24c0 8.102-2.96 15.667-8.86 22.669-7.12 8.324-15.732 13.134-25.071 12.375a25.222 25.222 0 0 1-.188-3.07c0-7.778 3.386-16.102 9.399-22.908 3.002-3.446 6.82-6.311 11.45-8.597 4.62-2.252 8.99-3.497 13.1-3.71.12 1.083.17 2.166.17 3.24z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="ml-4">
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<Translated i18nKey="signInWithApple" namespace="idp" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Translated } from "../translated";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
export const SignInWithAzureAd = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithAzureAd(props, ref) {
|
||||
const { children, name, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref}>
|
||||
<div className="flex h-12 w-12 items-center justify-center p-[10px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<path fill="#f25022" d="M1 1H10V10H1z"></path>
|
||||
<path fill="#00a4ef" d="M1 11H10V20H1z"></path>
|
||||
<path fill="#7fba00" d="M11 1H20V10H11z"></path>
|
||||
<path fill="#ffb900" d="M11 11H20V20H11z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="ml-4">
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<Translated i18nKey="signInWithAzureAD" namespace="idp" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
export const SignInWithGeneric = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithGeneric(props, ref) {
|
||||
const {
|
||||
children,
|
||||
name = "",
|
||||
className = "h-[50px] pl-20",
|
||||
...restProps
|
||||
} = props;
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref} className={className}>
|
||||
{children ? children : <span>{name}</span>}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Translated } from "../translated";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
function GitHubLogo() {
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 1024 1024"
|
||||
className="hidden h-8 w-8 dark:block"
|
||||
>
|
||||
<path
|
||||
fill="#fafafa"
|
||||
fillRule="evenodd"
|
||||
d="M512 0C229.12 0 0 229.12 0 512c0 226.56 146.56 417.92 350.08 485.76 25.6 4.48 35.2-10.88 35.2-24.32 0-12.16-.64-52.48-.64-95.36-128.64 23.68-161.92-31.36-172.16-60.16-5.76-14.72-30.72-60.16-52.48-72.32-17.92-9.6-43.52-33.28-.64-33.92 40.32-.64 69.12 37.12 78.72 52.48 46.08 77.44 119.68 55.68 149.12 42.24 4.48-33.28 17.92-55.68 32.64-68.48-113.92-12.8-232.96-56.96-232.96-252.8 0-55.68 19.84-101.76 52.48-137.6-5.12-12.8-23.04-65.28 5.12-135.68 0 0 42.88-13.44 140.8 52.48 40.96-11.52 84.48-17.28 128-17.28 43.52 0 87.04 5.76 128 17.28 97.92-66.56 140.8-52.48 140.8-52.48 28.16 70.4 10.24 122.88 5.12 135.68 32.64 35.84 52.48 81.28 52.48 137.6 0 196.48-119.68 240-233.6 252.8 18.56 16 34.56 46.72 34.56 94.72 0 68.48-.64 123.52-.64 140.8 0 13.44 9.6 29.44 35.2 24.32C877.44 929.92 1024 737.92 1024 512 1024 229.12 794.88 0 512 0z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 1024 1024"
|
||||
className="block h-8 w-8 dark:hidden"
|
||||
>
|
||||
<path
|
||||
fill="#1B1F23"
|
||||
fillRule="evenodd"
|
||||
d="M512 0C229.12 0 0 229.12 0 512c0 226.56 146.56 417.92 350.08 485.76 25.6 4.48 35.2-10.88 35.2-24.32 0-12.16-.64-52.48-.64-95.36-128.64 23.68-161.92-31.36-172.16-60.16-5.76-14.72-30.72-60.16-52.48-72.32-17.92-9.6-43.52-33.28-.64-33.92 40.32-.64 69.12 37.12 78.72 52.48 46.08 77.44 119.68 55.68 149.12 42.24 4.48-33.28 17.92-55.68 32.64-68.48-113.92-12.8-232.96-56.96-232.96-252.8 0-55.68 19.84-101.76 52.48-137.6-5.12-12.8-23.04-65.28 5.12-135.68 0 0 42.88-13.44 140.8 52.48 40.96-11.52 84.48-17.28 128-17.28 43.52 0 87.04 5.76 128 17.28 97.92-66.56 140.8-52.48 140.8-52.48 28.16 70.4 10.24 122.88 5.12 135.68 32.64 35.84 52.48 81.28 52.48 137.6 0 196.48-119.68 240-233.6 252.8 18.56 16 34.56 46.72 34.56 94.72 0 68.48-.64 123.52-.64 140.8 0 13.44 9.6 29.44 35.2 24.32C877.44 929.92 1024 737.92 1024 512 1024 229.12 794.88 0 512 0z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const SignInWithGithub = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithGithub(props, ref) {
|
||||
const { children, name, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref}>
|
||||
<div className="mx-2 my-2 flex items-center justify-center">
|
||||
<GitHubLogo />
|
||||
</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="ml-4">
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<Translated i18nKey="signInWithGithub" namespace="idp" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { SignInWithGitlab } from "./sign-in-with-gitlab";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("<SignInWithGitlab />", async () => {
|
||||
const messages = {
|
||||
idp: {
|
||||
signInWithGitlab: "Sign in with GitLab",
|
||||
},
|
||||
};
|
||||
|
||||
test("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGitlab />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(container.firstChild).toBeDefined();
|
||||
});
|
||||
|
||||
test("displays the default text", () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGitlab />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
const signInText = screen.getByText(/Sign in with Gitlab/i);
|
||||
expect(signInText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays the given text", () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGitlab name={"Gitlab"} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
const signInText = screen.getByText(/Gitlab/i);
|
||||
expect(signInText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Translated } from "../translated";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
export const SignInWithGitlab = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithGitlab(props, ref) {
|
||||
const { children, name, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref}>
|
||||
<div className="flex h-12 w-12 items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={25}
|
||||
height={24}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#e24329"
|
||||
d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#fc6d26"
|
||||
d="m24.507 9.5-.034-.09a11.44 11.44 0 0 0-4.56 2.051l-7.447 5.632 4.742 3.584 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#fca326"
|
||||
d="m7.707 20.677 2.56 1.935 1.555 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935-4.743-3.584-4.755 3.584z"
|
||||
/>
|
||||
<path
|
||||
fill="#fc6d26"
|
||||
d="M5.01 11.461a11.43 11.43 0 0 0-4.56-2.05L.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 4.745-3.584-7.444-5.632z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="ml-4">
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<Translated i18nKey="signInWithGitlab" namespace="idp" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { SignInWithGoogle } from "./sign-in-with-google";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("<SignInWithGoogle />", async () => {
|
||||
const messages = {
|
||||
idp: {
|
||||
signInWithGoogle: "Sign in with Google",
|
||||
},
|
||||
};
|
||||
|
||||
test("renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGoogle />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(container.firstChild).toBeDefined();
|
||||
});
|
||||
|
||||
test("displays the default text", () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGoogle />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
const signInText = screen.getByText(/Sign in with Google/i);
|
||||
expect(signInText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays the given text", () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<SignInWithGoogle name={"Google"} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
const signInText = screen.getByText(/Google/i);
|
||||
expect(signInText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { Translated } from "../translated";
|
||||
import { BaseButton, SignInWithIdentityProviderProps } from "./base-button";
|
||||
|
||||
export const SignInWithGoogle = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SignInWithIdentityProviderProps
|
||||
>(function SignInWithGoogle(props, ref) {
|
||||
const { children, name, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<BaseButton {...restProps} ref={ref}>
|
||||
<div className="flex h-12 w-12 items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
id="Capa_1"
|
||||
viewBox="0 0 150 150"
|
||||
>
|
||||
<style>
|
||||
{
|
||||
".st0{fill:#1a73e8}.st1{fill:#ea4335}.st2{fill:#4285f4}.st3{fill:#fbbc04}.st4{fill:#34a853}.st5{fill:#4caf50}.st6{fill:#1e88e5}.st7{fill:#e53935}.st8{fill:#c62828}.st9{fill:#fbc02d}.st10{fill:#1565c0}.st11{fill:#2e7d32}.st16{clip-path:url(#SVGID_2_)}.st17{fill:#188038}.st18,.st19{opacity:.2;fill:#fff;enable-background:new}.st19{opacity:.3;fill:#0d652d}.st20{clip-path:url(#SVGID_4_)}.st21{opacity:.3;fill:url(#_45_shadow_1_);enable-background:new}.st22{clip-path:url(#SVGID_6_)}.st23{fill:#fa7b17}.st24,.st25,.st26{opacity:.3;fill:#174ea6;enable-background:new}.st25,.st26{fill:#a50e0e}.st26{fill:#e37400}.st27{fill:url(#Finish_mask_1_)}.st28{fill:#fff}.st29{fill:#0c9d58}.st30,.st31{opacity:.2;fill:#004d40;enable-background:new}.st31{fill:#3e2723}.st32{fill:#ffc107}.st33{fill:#1a237e;enable-background:new}.st33,.st34{opacity:.2}.st35{fill:#1a237e}.st36{fill:url(#SVGID_7_)}.st37{fill:#fbbc05}.st38{clip-path:url(#SVGID_9_);fill:#e53935}.st39{clip-path:url(#SVGID_11_);fill:#fbc02d}.st40{clip-path:url(#SVGID_13_);fill:#e53935}.st41{clip-path:url(#SVGID_15_);fill:#fbc02d}"
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
d="M120 76.1c0-3.1-.3-6.3-.8-9.3H75.9v17.7h24.8c-1 5.7-4.3 10.7-9.2 13.9l14.8 11.5C115 101.8 120 90 120 76.1z"
|
||||
style={{
|
||||
fill: "#4280ef",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M75.9 120.9c12.4 0 22.8-4.1 30.4-11.1L91.5 98.4c-4.1 2.8-9.4 4.4-15.6 4.4-12 0-22.1-8.1-25.8-18.9L34.9 95.6c7.8 15.5 23.6 25.3 41 25.3z"
|
||||
style={{
|
||||
fill: "#34a353",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M50.1 83.8c-1.9-5.7-1.9-11.9 0-17.6L34.9 54.4c-6.5 13-6.5 28.3 0 41.2l15.2-11.8z"
|
||||
style={{
|
||||
fill: "#f6b704",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M75.9 47.3c6.5-.1 12.9 2.4 17.6 6.9L106.6 41c-8.3-7.8-19.3-12-30.7-11.9-17.4 0-33.2 9.8-41 25.3l15.2 11.8c3.7-10.9 13.8-18.9 25.8-18.9z"
|
||||
style={{
|
||||
fill: "#e54335",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span className="ml-4">
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<Translated i18nKey="signInWithGoogle" namespace="idp" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
ChangeEvent,
|
||||
DetailedHTMLProps,
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type TextInputProps = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> & {
|
||||
label: string;
|
||||
suffix?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
error?: string | ReactNode;
|
||||
success?: string | ReactNode;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (value: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const styles = (error: boolean, disabled: boolean) =>
|
||||
clsx({
|
||||
"h-[40px] mb-[2px] rounded p-[7px] bg-input-light-background dark:bg-input-dark-background transition-colors duration-300 grow": true,
|
||||
"border border-input-light-border dark:border-input-dark-border hover:border-black hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500": true,
|
||||
"focus:outline-none focus:ring-0 text-base text-black dark:text-white placeholder:italic placeholder-gray-700 dark:placeholder-gray-700": true,
|
||||
"border border-warn-light-500 dark:border-warn-dark-500 hover:border-warn-light-500 hover:dark:border-warn-dark-500 focus:border-warn-light-500 focus:dark:border-warn-dark-500":
|
||||
error,
|
||||
"pointer-events-none text-gray-500 dark:text-gray-800 border border-input-light-border dark:border-input-dark-border hover:border-light-hoverborder hover:dark:border-hoverborder cursor-default":
|
||||
disabled,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
suffix,
|
||||
required = false,
|
||||
error,
|
||||
disabled,
|
||||
success,
|
||||
onChange,
|
||||
onBlur,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<label className="relative flex flex-col text-12px text-input-light-label dark:text-input-dark-label">
|
||||
<span
|
||||
className={`mb-1 leading-3 ${
|
||||
error ? "text-warn-light-500 dark:text-warn-dark-500" : ""
|
||||
}`}
|
||||
>
|
||||
{label} {required && "*"}
|
||||
</span>
|
||||
<input
|
||||
suppressHydrationWarning
|
||||
ref={ref}
|
||||
className={styles(!!error, !!disabled)}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete={props.autoComplete ?? "off"}
|
||||
onChange={(e) => onChange && onChange(e)}
|
||||
onBlur={(e) => onBlur && onBlur(e)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{suffix && (
|
||||
<span className="absolute bottom-[22px] right-[3px] z-30 translate-y-1/2 transform rounded-sm bg-background-light-500 p-2 dark:bg-background-dark-500">
|
||||
@{suffix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="leading-14.5px h-14.5px flex flex-row items-center text-12px text-warn-light-500 dark:text-warn-dark-500">
|
||||
<span>{error ? error : " "}</span>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="text-md mt-1 flex flex-row items-center text-green-500">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<span className="ml-1">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export async function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { setLanguageCookie } from "@/lib/cookies";
|
||||
import { Lang, LANGS } from "@/lib/i18n";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const currentLocale = useLocale();
|
||||
|
||||
const [selected, setSelected] = useState(
|
||||
LANGS.find((l) => l.code === currentLocale) || LANGS[0],
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = async (language: Lang) => {
|
||||
setSelected(language);
|
||||
const newLocale = language.code;
|
||||
|
||||
await setLanguageCookie(newLocale);
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-32">
|
||||
<Listbox value={selected} onChange={handleChange}>
|
||||
<ListboxButton
|
||||
className={clsx(
|
||||
"relative block w-full rounded-lg bg-black/5 py-1.5 pl-3 pr-8 text-left text-sm/6 text-black dark:bg-white/5 dark:text-white",
|
||||
"focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25",
|
||||
)}
|
||||
>
|
||||
{selected.name}
|
||||
<ChevronDownIcon
|
||||
className="group pointer-events-none absolute right-2.5 top-2.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
anchor="bottom"
|
||||
transition
|
||||
className={clsx(
|
||||
"w-[var(--button-width)] rounded-xl border border-black/5 bg-background-light-500 p-1 [--anchor-gap:var(--spacing-1)] focus:outline-none dark:border-white/5 dark:bg-background-dark-500",
|
||||
"transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0",
|
||||
)}
|
||||
>
|
||||
{LANGS.map((lang) => (
|
||||
<ListboxOption
|
||||
key={lang.code}
|
||||
value={lang}
|
||||
className="group flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 data-[focus]:bg-black/10 dark:data-[focus]:bg-white/10"
|
||||
>
|
||||
<CheckIcon className="invisible size-4 group-data-[selected]:visible" />
|
||||
<div className="text-sm/6 text-black dark:text-white">
|
||||
{lang.name}
|
||||
</div>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function LayoutProviders({ children }: Props) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<div className={`${isDark ? "ui-dark" : "ui-light"} `}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { createNewSessionForLDAP } from "@/lib/server/idp";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {
|
||||
loginName: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
idpId: string;
|
||||
link: boolean;
|
||||
};
|
||||
|
||||
export function LDAPUsernamePasswordForm({ idpId, link }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const t = useTranslations("ldap");
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitUsernamePassword(values: Inputs) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await createNewSessionForLDAP({
|
||||
idpId: idpId,
|
||||
username: values.loginName,
|
||||
password: values.password,
|
||||
link: link,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not start LDAP flow");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full space-y-4">
|
||||
<TextInput
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
{...register("loginName", { required: t("required.username") })}
|
||||
label={t("username")}
|
||||
data-testid="username-text-input"
|
||||
/>
|
||||
|
||||
<div className={`${error && "transform-gpu animate-shake"}`}>
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="password"
|
||||
{...register("password", { required: t("required.password") })}
|
||||
label={t("password")}
|
||||
data-testid="password-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton data-testid="back-button" />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitUsernamePassword)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="submit" namespace="ldap" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { create } from "@zitadel/client";
|
||||
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
// either loginName or sessionId must be provided
|
||||
type Props = {
|
||||
host: string | null;
|
||||
loginName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
method: string;
|
||||
code?: string;
|
||||
loginSettings?: LoginSettings;
|
||||
};
|
||||
|
||||
type Inputs = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function LoginOTP({
|
||||
host,
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
method,
|
||||
code,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
const t = useTranslations("otp");
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const initialized = useRef(false);
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
code: code ? code : "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
|
||||
initialized.current = true;
|
||||
setLoading(true);
|
||||
updateSessionForOTPChallenge()
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function updateSessionForOTPChallenge() {
|
||||
let challenges;
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
if (method === "email") {
|
||||
challenges = create(RequestChallengesSchema, {
|
||||
otpEmail: {
|
||||
deliveryType: {
|
||||
case: "sendCode",
|
||||
value: host
|
||||
? {
|
||||
urlTemplate:
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
||||
(requestId ? `&requestId=${requestId}` : ""),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "sms") {
|
||||
challenges = create(RequestChallengesSchema, {
|
||||
otpSms: {},
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const response = await updateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
challenges,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not request OTP challenge");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function submitCode(values: Inputs, organization?: string) {
|
||||
setLoading(true);
|
||||
|
||||
let body: any = {
|
||||
code: values.code,
|
||||
method,
|
||||
};
|
||||
|
||||
if (organization) {
|
||||
body.organization = organization;
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
body.requestId = requestId;
|
||||
}
|
||||
|
||||
let checks;
|
||||
|
||||
if (method === "sms") {
|
||||
checks = create(ChecksSchema, {
|
||||
otpSms: { code: values.code },
|
||||
});
|
||||
}
|
||||
if (method === "email") {
|
||||
checks = create(ChecksSchema, {
|
||||
otpEmail: { code: values.code },
|
||||
});
|
||||
}
|
||||
if (method === "time-based") {
|
||||
checks = create(ChecksSchema, {
|
||||
totp: { code: values.code },
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
checks,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify OTP code");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function setCodeAndContinue(values: Inputs, organization?: string) {
|
||||
return submitCode(values, organization).then(async (response) => {
|
||||
if (response && "sessionId" in response) {
|
||||
setLoading(true);
|
||||
// Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const url =
|
||||
requestId && response.sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
sessionId: response.sessionId,
|
||||
requestId: requestId,
|
||||
organization: response.factors?.user?.organizationId,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: response.factors?.user
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: response.factors.user.loginName,
|
||||
organization: response.factors?.user?.organizationId,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
|
||||
setLoading(false);
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
{["email", "sms"].includes(method) && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<div className="flex flex-row">
|
||||
<span className="mr-auto flex-1 text-left">
|
||||
<Translated i18nKey="verify.noCodeReceived" namespace="otp" />
|
||||
</span>
|
||||
<button
|
||||
aria-label="Resend OTP Code"
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="ml-4 cursor-pointer text-primary-light-500 hover:text-primary-light-400 disabled:cursor-default disabled:text-gray-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 dark:disabled:text-gray-700"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
updateSessionForOTPChallenge()
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
data-testid="resend-button"
|
||||
>
|
||||
<Translated i18nKey="verify.resendCode" namespace="otp" />
|
||||
</button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<TextInput
|
||||
type="text"
|
||||
{...register("code", { required: t("verify.required.code") })}
|
||||
label="Code"
|
||||
autoComplete="one-time-code"
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton data-testid="back-button" />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit((e) => {
|
||||
setCodeAndContinue(e, organization);
|
||||
})}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="otp" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { sendPasskey } from "@/lib/server/passkeys";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { create, JsonObject } from "@zitadel/client";
|
||||
import {
|
||||
RequestChallengesSchema,
|
||||
UserVerificationRequirement,
|
||||
} from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
// either loginName or sessionId must be provided
|
||||
type Props = {
|
||||
loginName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
altPassword: boolean;
|
||||
login?: boolean;
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function LoginPasskey({
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
altPassword,
|
||||
organization,
|
||||
login = true,
|
||||
}: 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) {
|
||||
setError("Could not request passkey challenge");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
return submitLoginAndContinue(pK)
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function updateSessionForChallenge(
|
||||
userVerificationRequirement: number = login
|
||||
? UserVerificationRequirement.REQUIRED
|
||||
: UserVerificationRequirement.DISCOURAGED,
|
||||
) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const session = await updateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
challenges: create(RequestChallengesSchema, {
|
||||
webAuthN: {
|
||||
domain: "",
|
||||
userVerificationRequirement,
|
||||
},
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not request passkey challenge");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (session && "error" in session && session.error) {
|
||||
setError(session.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function submitLogin(data: JsonObject) {
|
||||
setLoading(true);
|
||||
const response = await sendPasskey({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
checks: {
|
||||
webAuthN: { credentialAssertionData: data },
|
||||
} as Checks,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify passkey");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
setError("An error on retrieving passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
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">
|
||||
{altPassword ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
params.append("sessionId", sessionId);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
|
||||
return router.push(
|
||||
"/password?" + params, // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
|
||||
);
|
||||
}}
|
||||
data-testid="password-button"
|
||||
>
|
||||
<Translated i18nKey="verify.usePassword" namespace="passkey" />
|
||||
</Button>
|
||||
) : (
|
||||
<BackButton />
|
||||
)}
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
const response = await updateSessionForChallenge().finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const pK =
|
||||
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
|
||||
?.publicKey;
|
||||
|
||||
if (!pK) {
|
||||
setError("Could not request passkey challenge");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
return submitLoginAndContinue(pK)
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="passkey" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Image from "next/image";
|
||||
|
||||
type Props = {
|
||||
darkSrc?: string;
|
||||
lightSrc?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export function Logo({ lightSrc, darkSrc, height = 40, width = 147.5 }: Props) {
|
||||
return (
|
||||
<>
|
||||
{darkSrc && (
|
||||
<div className="hidden dark:flex">
|
||||
<Image
|
||||
height={height}
|
||||
width={width}
|
||||
src={darkSrc}
|
||||
alt="logo"
|
||||
priority={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{lightSrc && (
|
||||
<div className="flex dark:hidden">
|
||||
<Image
|
||||
height={height}
|
||||
width={width}
|
||||
priority={true}
|
||||
src={lightSrc}
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { PasswordComplexity } from "./password-complexity";
|
||||
|
||||
const matchesTitle = `Matches`;
|
||||
const doesntMatchTitle = `Doesn't match`;
|
||||
|
||||
describe("<PasswordComplexity/>", () => {
|
||||
describe.each`
|
||||
settingsMinLength | password | expectSVGTitle
|
||||
${5} | ${"Password1!"} | ${matchesTitle}
|
||||
${30} | ${"Password1!"} | ${doesntMatchTitle}
|
||||
${0} | ${"Password1!"} | ${matchesTitle}
|
||||
${undefined} | ${"Password1!"} | ${false}
|
||||
`(
|
||||
`With settingsMinLength=$settingsMinLength, password=$password, expectSVGTitle=$expectSVGTitle`,
|
||||
({ settingsMinLength, password, expectSVGTitle }) => {
|
||||
const feedbackElementLabel = /password length/i;
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<PasswordComplexity
|
||||
password={password}
|
||||
equals
|
||||
passwordComplexitySettings={{
|
||||
minLength: settingsMinLength,
|
||||
requiresLowercase: false,
|
||||
requiresUppercase: false,
|
||||
requiresNumber: false,
|
||||
requiresSymbol: false,
|
||||
resourceOwnerType: 0, // ResourceOwnerType.RESOURCE_OWNER_TYPE_UNSPECIFIED,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
if (expectSVGTitle === false) {
|
||||
test(`should not render the feedback element`, async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(feedbackElementLabel),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
test(`Should show one SVG with title ${expectSVGTitle}`, async () => {
|
||||
await waitFor(async () => {
|
||||
const svg = within(
|
||||
screen.getByText(feedbackElementLabel)
|
||||
.parentElement as HTMLElement,
|
||||
).findByRole("img");
|
||||
expect(await svg).toHaveTextContent(expectSVGTitle);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "@/helpers/validators";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
|
||||
type Props = {
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
password: string;
|
||||
equals: boolean;
|
||||
};
|
||||
|
||||
const check = (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="las la-check mr-2 h-6 w-6 text-lg text-green-500 dark:text-green-500"
|
||||
role="img"
|
||||
>
|
||||
<title>Matches</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const cross = (
|
||||
<svg
|
||||
className="las la-times mr-2 h-6 w-6 text-lg text-warn-light-500 dark:text-warn-dark-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
>
|
||||
<title>Doesn't match</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const desc =
|
||||
"text-14px leading-4 text-input-light-label dark:text-input-dark-label";
|
||||
|
||||
export function PasswordComplexity({
|
||||
passwordComplexitySettings,
|
||||
password,
|
||||
equals,
|
||||
}: Props) {
|
||||
const hasMinLength = password?.length >= passwordComplexitySettings.minLength;
|
||||
const hasSymbol = symbolValidator(password);
|
||||
const hasNumber = numberValidator(password);
|
||||
const hasUppercase = upperCaseValidator(password);
|
||||
const hasLowercase = lowerCaseValidator(password);
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-x-8 gap-y-2">
|
||||
{passwordComplexitySettings.minLength != undefined ? (
|
||||
<div className="flex flex-row items-center" data-testid="length-check">
|
||||
{hasMinLength ? check : cross}
|
||||
<span className={desc}>
|
||||
Password length {passwordComplexitySettings.minLength.toString()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex flex-row items-center" data-testid="symbol-check">
|
||||
{hasSymbol ? check : cross}
|
||||
<span className={desc}>has Symbol</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center" data-testid="number-check">
|
||||
{hasNumber ? check : cross}
|
||||
<span className={desc}>has Number</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center" data-testid="uppercase-check">
|
||||
{hasUppercase ? check : cross}
|
||||
<span className={desc}>has uppercase</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center" data-testid="lowercase-check">
|
||||
{hasLowercase ? check : cross}
|
||||
<span className={desc}>has lowercase</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center" data-testid="equal-check">
|
||||
{equals ? check : cross}
|
||||
<span className={desc}>equals</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { resetPassword, sendPassword } from "@/lib/server/password";
|
||||
import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
loginSettings: LoginSettings | undefined;
|
||||
loginName: string;
|
||||
organization?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function PasswordForm({
|
||||
loginSettings,
|
||||
loginName,
|
||||
organization,
|
||||
requestId,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const t = useTranslations("password");
|
||||
|
||||
const [info, setInfo] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitPassword(values: Inputs) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await sendPassword({
|
||||
loginName,
|
||||
organization,
|
||||
checks: create(ChecksSchema, {
|
||||
password: { password: values.password },
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPasswordAndContinue() {
|
||||
setError("");
|
||||
setInfo("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not reset password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setInfo("Password was reset. Please check your email.");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
loginName: loginName,
|
||||
});
|
||||
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
return router.push("/password/set?" + params);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className={`${error && "transform-gpu animate-shake"}`}>
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="password"
|
||||
{...register("password", { required: t("verify.required.password") })}
|
||||
label="Password"
|
||||
data-testid="password-text-input"
|
||||
/>
|
||||
{!loginSettings?.hidePasswordReset && (
|
||||
<button
|
||||
className="text-sm transition-all hover:text-primary-light-500 dark:hover:text-primary-dark-500"
|
||||
onClick={() => resetPasswordAndContinue()}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
data-testid="reset-button"
|
||||
>
|
||||
<Translated i18nKey="verify.resetPassword" namespace="password" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loginName && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="loginName"
|
||||
autoComplete="username"
|
||||
value={loginName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
<div className="py-4">
|
||||
<Alert type={AlertType.INFO}>{info}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton data-testid="back-button" />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitPassword)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="password" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Checkbox } from "./checkbox";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
legal: LegalAndSupportSettings;
|
||||
onChange: (allAccepted: boolean) => void;
|
||||
};
|
||||
|
||||
type AcceptanceState = {
|
||||
tosAccepted: boolean;
|
||||
privacyPolicyAccepted: boolean;
|
||||
};
|
||||
|
||||
export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
|
||||
const [acceptanceState, setAcceptanceState] = useState<AcceptanceState>({
|
||||
tosAccepted: false,
|
||||
privacyPolicyAccepted: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-4 flex flex-row items-center text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500">
|
||||
<Translated i18nKey="agreeTo" namespace="register" />
|
||||
{legal?.helpLink && (
|
||||
<span>
|
||||
<Link href={legal.helpLink} target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="ml-1 h-5 w-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{legal?.tosLink && (
|
||||
<div className="mt-4 flex items-center">
|
||||
<Checkbox
|
||||
className="mr-4"
|
||||
checked={false}
|
||||
value={"privacypolicy"}
|
||||
onChangeVal={(checked: boolean) => {
|
||||
setAcceptanceState({
|
||||
...acceptanceState,
|
||||
tosAccepted: checked,
|
||||
});
|
||||
onChange(checked && acceptanceState.privacyPolicyAccepted);
|
||||
}}
|
||||
data-testid="privacy-policy-checkbox"
|
||||
/>
|
||||
|
||||
<div className="mr-4 w-[28rem]">
|
||||
<p className="text-sm text-text-light-500 dark:text-text-dark-500">
|
||||
<Link href={legal.tosLink} className="underline" target="_blank">
|
||||
<Translated i18nKey="termsOfService" namespace="register" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{legal?.privacyPolicyLink && (
|
||||
<div className="mt-4 flex items-center">
|
||||
<Checkbox
|
||||
className="mr-4"
|
||||
checked={false}
|
||||
value={"tos"}
|
||||
onChangeVal={(checked: boolean) => {
|
||||
setAcceptanceState({
|
||||
...acceptanceState,
|
||||
privacyPolicyAccepted: checked,
|
||||
});
|
||||
onChange(checked && acceptanceState.tosAccepted);
|
||||
}}
|
||||
data-testid="tos-checkbox"
|
||||
/>
|
||||
|
||||
<div className="mr-4 w-[28rem]">
|
||||
<p className="text-sm text-text-light-500 dark:text-text-dark-500">
|
||||
<Link
|
||||
href={legal.privacyPolicyLink}
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
<Translated i18nKey="privacyPolicy" namespace="register" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { registerUserAndLinkToIDP } from "@/lib/server/register";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
organization: string;
|
||||
requestId?: string;
|
||||
idpIntent: {
|
||||
idpIntentId: string;
|
||||
idpIntentToken: string;
|
||||
};
|
||||
defaultValues?: {
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
email?: string;
|
||||
};
|
||||
idpUserId: string;
|
||||
idpId: string;
|
||||
idpUserName: string;
|
||||
};
|
||||
|
||||
export function RegisterFormIDPIncomplete({
|
||||
organization,
|
||||
requestId,
|
||||
idpIntent,
|
||||
defaultValues,
|
||||
idpUserId,
|
||||
idpId,
|
||||
idpUserName,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
email: defaultValues?.email ?? "",
|
||||
firstname: defaultValues?.firstname ?? "",
|
||||
lastname: defaultValues?.lastname ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("register");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitAndRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const response = await registerUserAndLinkToIDP({
|
||||
idpId: idpId,
|
||||
idpUserName: idpUserName,
|
||||
idpUserId: idpUserId,
|
||||
email: values.email,
|
||||
firstName: values.firstname,
|
||||
lastName: values.lastname,
|
||||
organization: organization,
|
||||
requestId: requestId,
|
||||
idpIntent: idpIntent,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not register user");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="firstname"
|
||||
autoComplete="firstname"
|
||||
required
|
||||
{...register("firstname", { required: t("required.firstname") })}
|
||||
label="First name"
|
||||
error={errors.firstname?.message as string}
|
||||
data-testid="firstname-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="lastname"
|
||||
autoComplete="lastname"
|
||||
required
|
||||
{...register("lastname", { required: t("required.lastname") })}
|
||||
label="Last name"
|
||||
error={errors.lastname?.message as string}
|
||||
data-testid="lastname-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextInput
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
{...register("email", { required: t("required.email") })}
|
||||
label="E-mail"
|
||||
error={errors.email?.message as string}
|
||||
data-testid="email-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<BackButton data-testid="back-button" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitAndRegister)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="submit" namespace="register" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { registerUser } from "@/lib/server/register";
|
||||
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
|
||||
import {
|
||||
LoginSettings,
|
||||
PasskeysType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import {
|
||||
AuthenticationMethod,
|
||||
AuthenticationMethodRadio,
|
||||
methods,
|
||||
} from "./authentication-method-radio";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
legal: LegalAndSupportSettings;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
email?: string;
|
||||
organization: string;
|
||||
requestId?: string;
|
||||
loginSettings?: LoginSettings;
|
||||
idpCount: number;
|
||||
};
|
||||
|
||||
export function RegisterForm({
|
||||
legal,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
organization,
|
||||
requestId,
|
||||
loginSettings,
|
||||
idpCount = 0,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
firstName: firstname ?? "",
|
||||
lastname: lastname ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("register");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [selected, setSelected] = useState<AuthenticationMethod>(methods[0]);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitAndRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const response = await registerUser({
|
||||
email: values.email,
|
||||
firstName: values.firstname,
|
||||
lastName: values.lastname,
|
||||
organization: organization,
|
||||
requestId: requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not register user");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function submitAndContinue(
|
||||
value: Inputs,
|
||||
withPassword: boolean = false,
|
||||
) {
|
||||
const registerParams: any = value;
|
||||
|
||||
if (organization) {
|
||||
registerParams.organization = organization;
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
registerParams.requestId = requestId;
|
||||
}
|
||||
|
||||
// redirect user to /register/password if password is chosen
|
||||
if (withPassword) {
|
||||
return router.push(
|
||||
`/register/password?` + new URLSearchParams(registerParams),
|
||||
);
|
||||
} else {
|
||||
return submitAndRegister(value);
|
||||
}
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="firstname"
|
||||
autoComplete="firstname"
|
||||
required
|
||||
{...register("firstname", { required: t("required.firstname") })}
|
||||
label="First name"
|
||||
error={errors.firstname?.message as string}
|
||||
data-testid="firstname-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="lastname"
|
||||
autoComplete="lastname"
|
||||
required
|
||||
{...register("lastname", { required: t("required.lastname") })}
|
||||
label="Last name"
|
||||
error={errors.lastname?.message as string}
|
||||
data-testid="lastname-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextInput
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
{...register("email", { required: t("required.email") })}
|
||||
label="E-mail"
|
||||
error={errors.email?.message as string}
|
||||
data-testid="email-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{legal && (
|
||||
<PrivacyPolicyCheckboxes
|
||||
legal={legal}
|
||||
onChange={setTosAndPolicyAccepted}
|
||||
/>
|
||||
)}
|
||||
{/* show chooser if both methods are allowed */}
|
||||
{loginSettings &&
|
||||
loginSettings.allowUsernamePassword &&
|
||||
loginSettings.passkeysType == PasskeysType.ALLOWED && (
|
||||
<>
|
||||
<p className="ztdl-p mb-6 mt-4 block text-left">
|
||||
<Translated i18nKey="selectMethod" namespace="register" />
|
||||
</p>
|
||||
|
||||
<div className="pb-4">
|
||||
<AuthenticationMethodRadio
|
||||
selected={selected}
|
||||
selectionChanged={setSelected}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!loginSettings?.allowUsernamePassword &&
|
||||
loginSettings?.passkeysType !== PasskeysType.ALLOWED &&
|
||||
(!loginSettings?.allowExternalIdp || !idpCount) && (
|
||||
<div className="py-4">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated
|
||||
i18nKey="noMethodAvailableWarning"
|
||||
namespace="register"
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<BackButton data-testid="back-button" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
|
||||
onClick={handleSubmit((values) => {
|
||||
const usePasswordToContinue: boolean =
|
||||
loginSettings?.allowUsernamePassword &&
|
||||
loginSettings?.passkeysType == PasskeysType.ALLOWED
|
||||
? !(selected === methods[0]) // choose selection if both available
|
||||
: !!loginSettings?.allowUsernamePassword; // if password is chosen
|
||||
// set password as default if only password is allowed
|
||||
return submitAndContinue(values, usePasswordToContinue);
|
||||
})}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="submit" namespace="register" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import {
|
||||
registerPasskeyLink,
|
||||
verifyPasskeyRegistration,
|
||||
} from "@/lib/server/passkeys";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
isPrompt: boolean;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function RegisterPasskey({
|
||||
sessionId,
|
||||
isPrompt,
|
||||
organization,
|
||||
requestId,
|
||||
}: Props) {
|
||||
const { handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitVerify(
|
||||
passkeyId: string,
|
||||
passkeyName: string,
|
||||
publicKeyCredential: any,
|
||||
sessionId: string,
|
||||
) {
|
||||
setLoading(true);
|
||||
const response = await verifyPasskeyRegistration({
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
publicKeyCredential,
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify Passkey");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function submitRegisterAndContinue(): Promise<boolean | void> {
|
||||
setLoading(true);
|
||||
const resp = await registerPasskeyLink({
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not register passkey");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (!resp) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in resp && resp.error) {
|
||||
setError(resp.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("passkeyId" in resp)) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
const passkeyId = resp.passkeyId;
|
||||
const options: CredentialCreationOptions =
|
||||
(resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ??
|
||||
{};
|
||||
|
||||
if (!options.publicKey) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
options.publicKey.challenge = coerceToArrayBuffer(
|
||||
options.publicKey.challenge,
|
||||
"challenge",
|
||||
);
|
||||
options.publicKey.user.id = coerceToArrayBuffer(
|
||||
options.publicKey.user.id,
|
||||
"userid",
|
||||
);
|
||||
if (options.publicKey.excludeCredentials) {
|
||||
options.publicKey.excludeCredentials.map((cred: any) => {
|
||||
cred.id = coerceToArrayBuffer(
|
||||
cred.id as string,
|
||||
"excludeCredentials.id",
|
||||
);
|
||||
return cred;
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = await navigator.credentials.create(options);
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
!(credentials as any).response?.attestationObject ||
|
||||
!(credentials as any).response?.clientDataJSON ||
|
||||
!(credentials as any).rawId
|
||||
) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
const attestationObject = (credentials as any).response.attestationObject;
|
||||
const clientDataJSON = (credentials as any).response.clientDataJSON;
|
||||
const rawId = (credentials as any).rawId;
|
||||
|
||||
const data = {
|
||||
id: credentials.id,
|
||||
rawId: coerceToBase64Url(rawId, "rawId"),
|
||||
type: credentials.type,
|
||||
response: {
|
||||
attestationObject: coerceToBase64Url(
|
||||
attestationObject,
|
||||
"attestationObject",
|
||||
),
|
||||
clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"),
|
||||
},
|
||||
};
|
||||
|
||||
const verificationResponse = await submitVerify(
|
||||
passkeyId,
|
||||
"",
|
||||
data,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
if (!verificationResponse) {
|
||||
setError("Could not verify Passkey!");
|
||||
return;
|
||||
}
|
||||
|
||||
continueAndLogin();
|
||||
}
|
||||
|
||||
function continueAndLogin() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (organization) {
|
||||
params.set("organization", organization);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.set("requestId", requestId);
|
||||
}
|
||||
|
||||
params.set("sessionId", sessionId);
|
||||
|
||||
router.push("/passkey?" + params);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
{isPrompt ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
onClick={() => {
|
||||
continueAndLogin();
|
||||
}}
|
||||
>
|
||||
<Translated i18nKey="set.skip" namespace="passkey" />
|
||||
</Button>
|
||||
) : (
|
||||
<BackButton />
|
||||
)}
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitRegisterAndContinue)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="set.submit" namespace="passkey" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { addU2F, verifyU2F } from "@/lib/server/u2f";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
loginName?: string;
|
||||
sessionId: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
checkAfter: boolean;
|
||||
loginSettings?: LoginSettings;
|
||||
};
|
||||
|
||||
export function RegisterU2f({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
requestId,
|
||||
checkAfter,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitVerify(
|
||||
u2fId: string,
|
||||
passkeyName: string,
|
||||
publicKeyCredential: any,
|
||||
sessionId: string,
|
||||
) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const response = await verifyU2F({
|
||||
u2fId,
|
||||
passkeyName,
|
||||
publicKeyCredential,
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("An error on verifying passkey occurred");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response?.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function submitRegisterAndContinue(): Promise<boolean | void | null> {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const response = await addU2F({
|
||||
sessionId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response?.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response || !("u2fId" in response)) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
const u2fResponse = response as unknown as RegisterU2FResponse;
|
||||
|
||||
const u2fId = u2fResponse.u2fId;
|
||||
const options: CredentialCreationOptions =
|
||||
(u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ??
|
||||
{};
|
||||
|
||||
if (options.publicKey) {
|
||||
options.publicKey.challenge = coerceToArrayBuffer(
|
||||
options.publicKey.challenge,
|
||||
"challenge",
|
||||
);
|
||||
options.publicKey.user.id = coerceToArrayBuffer(
|
||||
options.publicKey.user.id,
|
||||
"userid",
|
||||
);
|
||||
if (options.publicKey.excludeCredentials) {
|
||||
options.publicKey.excludeCredentials.map((cred: any) => {
|
||||
cred.id = coerceToArrayBuffer(
|
||||
cred.id as string,
|
||||
"excludeCredentials.id",
|
||||
);
|
||||
return cred;
|
||||
});
|
||||
}
|
||||
|
||||
const resp = await navigator.credentials.create(options);
|
||||
|
||||
if (
|
||||
!resp ||
|
||||
!(resp as any).response.attestationObject ||
|
||||
!(resp as any).response.clientDataJSON ||
|
||||
!(resp as any).rawId
|
||||
) {
|
||||
setError("An error on registering passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
const attestationObject = (resp as any).response.attestationObject;
|
||||
const clientDataJSON = (resp as any).response.clientDataJSON;
|
||||
const rawId = (resp as any).rawId;
|
||||
|
||||
const data = {
|
||||
id: resp.id,
|
||||
rawId: coerceToBase64Url(rawId, "rawId"),
|
||||
type: resp.type,
|
||||
response: {
|
||||
attestationObject: coerceToBase64Url(
|
||||
attestationObject,
|
||||
"attestationObject",
|
||||
),
|
||||
clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"),
|
||||
},
|
||||
};
|
||||
|
||||
const submitResponse = await submitVerify(u2fId, "", data, sessionId);
|
||||
|
||||
if (!submitResponse) {
|
||||
setError("An error on verifying passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkAfter) {
|
||||
const paramsToContinue = new URLSearchParams({});
|
||||
|
||||
if (sessionId) {
|
||||
paramsToContinue.append("sessionId", sessionId);
|
||||
}
|
||||
if (loginName) {
|
||||
paramsToContinue.append("loginName", loginName);
|
||||
}
|
||||
if (organization) {
|
||||
paramsToContinue.append("organization", organization);
|
||||
}
|
||||
if (requestId) {
|
||||
paramsToContinue.append("requestId", requestId);
|
||||
}
|
||||
|
||||
return router.push(`/u2f?` + paramsToContinue);
|
||||
} else {
|
||||
const url =
|
||||
requestId && sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: loginName
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
if (url) {
|
||||
return router.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton data-testid="back-button" />
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading}
|
||||
onClick={submitRegisterAndContinue}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="set.submit" namespace="u2f" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function SelfServiceMenu() {
|
||||
const list: any[] = [];
|
||||
|
||||
// if (!!config.selfservice.change_password.enabled) {
|
||||
// list.push({
|
||||
// link:
|
||||
// `/me/change-password?` +
|
||||
// new URLSearchParams({
|
||||
// sessionId: sessionId,
|
||||
// }),
|
||||
// name: "Change password",
|
||||
// });
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
{list.map((menuitem, index) => {
|
||||
return (
|
||||
<SelfServiceItem
|
||||
link={menuitem.link}
|
||||
key={"self-service-" + index}
|
||||
name={menuitem.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelfServiceItem = ({ name, link }: { name: string; link: string }) => {
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={link}
|
||||
className="group flex w-full flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 py-2 transition-all hover:shadow-lg dark:bg-background-dark-400 dark:hover:bg-white/10"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { clearSession } from "@/lib/server/session";
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import moment from "moment";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "./avatar";
|
||||
import { isSessionValid } from "./session-item";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export function SessionClearItem({
|
||||
session,
|
||||
reload,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
}) {
|
||||
const currentLocale = useLocale();
|
||||
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
|
||||
|
||||
const [_loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function clearSessionId(id: string) {
|
||||
setLoading(true);
|
||||
const response = await clearSession({
|
||||
sessionId: id,
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error.message);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const { valid, verifiedAt } = isSessionValid(session);
|
||||
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
|
||||
// TODO: To we have to call this?
|
||||
useRouter();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
clearSessionId(session.id).then(() => {
|
||||
reload();
|
||||
});
|
||||
}}
|
||||
className="group flex flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 py-2 transition-all hover:shadow-lg dark:bg-background-dark-400 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
{session.factors?.user?.loginName}
|
||||
</span>
|
||||
{valid ? (
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
{verifiedAt && (
|
||||
<Translated
|
||||
i18nKey="verfiedAt"
|
||||
namespace="logout"
|
||||
data={{ time: moment(timestampDate(verifiedAt)).fromNow() }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
expired{" "}
|
||||
{session.expirationDate &&
|
||||
moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
<div className="mr-6 flex hidden items-center justify-center rounded-full bg-[#ff0000]/10 px-2 py-[2px] text-xs text-warn-light-500 transition-all group-hover:block dark:bg-[#ff0000]/10 dark:text-warn-dark-500">
|
||||
<Translated i18nKey="clear" namespace="logout" />
|
||||
</div>
|
||||
|
||||
{valid ? (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all"></div>
|
||||
) : (
|
||||
<div className="absolute right-0 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { sendLoginname } from "@/lib/server/loginname";
|
||||
import { clearSession, continueWithSession } from "@/lib/server/session";
|
||||
import { XCircleIcon } from "@heroicons/react/24/outline";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Timestamp, timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import moment from "moment";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "./avatar";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export function isSessionValid(session: Partial<Session>): {
|
||||
valid: boolean;
|
||||
verifiedAt?: Timestamp;
|
||||
} {
|
||||
const validPassword = session?.factors?.password?.verifiedAt;
|
||||
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
|
||||
const validIDP = session?.factors?.intent?.verifiedAt;
|
||||
|
||||
const stillValid = session.expirationDate
|
||||
? timestampDate(session.expirationDate) > new Date()
|
||||
: true;
|
||||
|
||||
const verifiedAt = validPassword || validPasskey || validIDP;
|
||||
const valid = !!((validPassword || validPasskey || validIDP) && stillValid);
|
||||
|
||||
return { valid, verifiedAt };
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
reload,
|
||||
requestId,
|
||||
}: {
|
||||
session: Session;
|
||||
reload: () => void;
|
||||
requestId?: string;
|
||||
}) {
|
||||
const currentLocale = useLocale();
|
||||
moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale);
|
||||
|
||||
const [_loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function clearSessionId(id: string) {
|
||||
setLoading(true);
|
||||
const response = await clearSession({
|
||||
sessionId: id,
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error.message);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const { valid, verifiedAt } = isSessionValid(session);
|
||||
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (valid && session?.factors?.user) {
|
||||
const resp = await continueWithSession({
|
||||
...session,
|
||||
requestId: requestId,
|
||||
});
|
||||
|
||||
if (resp?.redirect) {
|
||||
return router.push(resp.redirect);
|
||||
}
|
||||
} else if (session.factors?.user) {
|
||||
setLoading(true);
|
||||
const res = await sendLoginname({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
organization: session.factors.user.organizationId,
|
||||
requestId: requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("An internal error occurred");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (res && "redirect" in res && res.redirect) {
|
||||
return router.push(res.redirect);
|
||||
}
|
||||
|
||||
if (res && "error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="group flex flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 py-2 transition-all hover:shadow-lg dark:bg-background-dark-400 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Avatar
|
||||
size="small"
|
||||
loginName={session.factors?.user?.loginName as string}
|
||||
name={session.factors?.user?.displayName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
{session.factors?.user?.loginName}
|
||||
</span>
|
||||
{valid ? (
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
<Translated i18nKey="verified" namespace="accounts" />{" "}
|
||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-ellipsis text-xs opacity-80">
|
||||
<Translated i18nKey="expired" namespace="accounts" />{" "}
|
||||
{session.expirationDate &&
|
||||
moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<div className="relative flex flex-row items-center">
|
||||
{valid ? (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-green-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
) : (
|
||||
<div className="absolute right-6 mx-2 h-2 w-2 transform rounded-full bg-red-500 transition-all group-hover:right-6 sm:right-0"></div>
|
||||
)}
|
||||
|
||||
<XCircleIcon
|
||||
className="h-5 w-5 opacity-50 transition-all hover:opacity-100 group-hover:block sm:hidden"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearSessionId(session.id).then(() => {
|
||||
reload();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{valid && session.expirationDate && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="z-50 select-none rounded-md border bg-background-light-500 px-3 py-2 text-xs text-black shadow-xl dark:border-white/20 dark:bg-background-dark-500 dark:text-white"
|
||||
sideOffset={5}
|
||||
>
|
||||
Expires {moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
<Tooltip.Arrow className="fill-white dark:fill-white/20" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { clearSession } from "@/lib/server/session";
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import { SessionClearItem } from "./session-clear-item";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
sessions: Session[];
|
||||
postLogoutRedirectUri?: string;
|
||||
logoutHint?: string;
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function SessionsClearList({
|
||||
sessions,
|
||||
logoutHint,
|
||||
postLogoutRedirectUri,
|
||||
organization,
|
||||
}: Props) {
|
||||
const [list, setList] = useState<Session[]>(sessions);
|
||||
const router = useRouter();
|
||||
|
||||
async function clearHintedSession() {
|
||||
console.log("Clearing session for login hint:", logoutHint);
|
||||
// If a login hint is provided, we logout that specific session
|
||||
const sessionIdToBeCleared = sessions.find((session) => {
|
||||
return session.factors?.user?.loginName === logoutHint;
|
||||
})?.id;
|
||||
|
||||
if (sessionIdToBeCleared) {
|
||||
const clearSessionResponse = await clearSession({
|
||||
sessionId: sessionIdToBeCleared,
|
||||
}).catch((error) => {
|
||||
console.error("Error clearing session:", error);
|
||||
return;
|
||||
});
|
||||
|
||||
if (!clearSessionResponse) {
|
||||
console.error("Failed to clear session for login hint:", logoutHint);
|
||||
}
|
||||
|
||||
if (postLogoutRedirectUri) {
|
||||
return redirect(postLogoutRedirectUri);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (organization) {
|
||||
params.set("organization", organization);
|
||||
}
|
||||
|
||||
return router.push("/logout/success?" + params);
|
||||
} else {
|
||||
console.warn(`No session found for login hint: ${logoutHint}`);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (logoutHint) {
|
||||
clearHintedSession();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return sessions ? (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{list
|
||||
.filter((session) => session?.factors?.user?.loginName)
|
||||
// sort by change date descending
|
||||
.sort((a, b) => {
|
||||
const dateA = a.changeDate
|
||||
? timestampDate(a.changeDate).getTime()
|
||||
: 0;
|
||||
const dateB = b.changeDate
|
||||
? timestampDate(b.changeDate).getTime()
|
||||
: 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
// TODO: add sorting to move invalid sessions to the bottom
|
||||
.map((session, index) => {
|
||||
return (
|
||||
<SessionClearItem
|
||||
session={session}
|
||||
reload={() => {
|
||||
setList(list.filter((s) => s.id !== session.id));
|
||||
if (postLogoutRedirectUri) {
|
||||
router.push(postLogoutRedirectUri);
|
||||
}
|
||||
}}
|
||||
key={"session-" + index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="noResults" namespace="logout" />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<Translated i18nKey="noResults" namespace="logout" />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { SessionItem } from "./session-item";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Props = {
|
||||
sessions: Session[];
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function SessionsList({ sessions, requestId }: Props) {
|
||||
const [list, setList] = useState<Session[]>(sessions);
|
||||
return sessions ? (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{list
|
||||
.filter((session) => session?.factors?.user?.loginName)
|
||||
// sort by change date descending
|
||||
.sort((a, b) => {
|
||||
const dateA = a.changeDate
|
||||
? timestampDate(a.changeDate).getTime()
|
||||
: 0;
|
||||
const dateB = b.changeDate
|
||||
? timestampDate(b.changeDate).getTime()
|
||||
: 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
// TODO: add sorting to move invalid sessions to the bottom
|
||||
.map((session, index) => {
|
||||
return (
|
||||
<SessionItem
|
||||
session={session}
|
||||
requestId={requestId}
|
||||
reload={() => {
|
||||
setList(list.filter((s) => s.id !== session.id));
|
||||
}}
|
||||
key={"session-" + index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<Translated i18nKey="noResults" namespace="accounts" />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "@/helpers/validators";
|
||||
import {
|
||||
changePassword,
|
||||
resetPassword,
|
||||
sendPassword,
|
||||
} from "@/lib/server/password";
|
||||
import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert, AlertType } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { PasswordComplexity } from "./password-complexity";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
code: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
code?: string;
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
loginName: string;
|
||||
userId: string;
|
||||
organization?: string;
|
||||
requestId?: string;
|
||||
codeRequired: boolean;
|
||||
};
|
||||
|
||||
export function SetPasswordForm({
|
||||
passwordComplexitySettings,
|
||||
organization,
|
||||
requestId,
|
||||
loginName,
|
||||
userId,
|
||||
code,
|
||||
codeRequired,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
code: code ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("password");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function resendCode() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not reset password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPassword(values: Inputs) {
|
||||
setLoading(true);
|
||||
let payload: { userId: string; password: string; code?: string } = {
|
||||
userId: userId,
|
||||
password: values.password,
|
||||
};
|
||||
|
||||
// this is not required for initial password setup
|
||||
if (codeRequired) {
|
||||
payload = { ...payload, code: values.code };
|
||||
}
|
||||
|
||||
const changeResponse = await changePassword(payload)
|
||||
.catch(() => {
|
||||
setError("Could not set password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (changeResponse && "error" in changeResponse) {
|
||||
setError(changeResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changeResponse) {
|
||||
setError("Could not set password");
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for a second to avoid eventual consistency issues with an initial password being set
|
||||
|
||||
const passwordResponse = await sendPassword({
|
||||
loginName,
|
||||
organization,
|
||||
checks: create(ChecksSchema, {
|
||||
password: { password: values.password },
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (
|
||||
passwordResponse &&
|
||||
"error" in passwordResponse &&
|
||||
passwordResponse.error
|
||||
) {
|
||||
setError(passwordResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
passwordResponse &&
|
||||
"redirect" in passwordResponse &&
|
||||
passwordResponse.redirect
|
||||
) {
|
||||
return router.push(passwordResponse.redirect);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const watchPassword = watch("password", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
const hasMinLength =
|
||||
passwordComplexitySettings &&
|
||||
watchPassword?.length >= passwordComplexitySettings.minLength;
|
||||
const hasSymbol = symbolValidator(watchPassword);
|
||||
const hasNumber = numberValidator(watchPassword);
|
||||
const hasUppercase = upperCaseValidator(watchPassword);
|
||||
const hasLowercase = lowerCaseValidator(watchPassword);
|
||||
|
||||
const policyIsValid =
|
||||
passwordComplexitySettings &&
|
||||
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
|
||||
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
|
||||
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
|
||||
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
|
||||
hasMinLength;
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
|
||||
{codeRequired && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<div className="flex flex-row">
|
||||
<span className="mr-auto flex-1 text-left">
|
||||
<Translated i18nKey="set.noCodeReceived" namespace="password" />
|
||||
</span>
|
||||
<button
|
||||
aria-label="Resend OTP Code"
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="ml-4 cursor-pointer text-primary-light-500 hover:text-primary-light-400 disabled:cursor-default disabled:text-gray-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 dark:disabled:text-gray-700"
|
||||
onClick={() => {
|
||||
resendCode();
|
||||
}}
|
||||
data-testid="resend-button"
|
||||
>
|
||||
<Translated i18nKey="set.resend" namespace="password" />
|
||||
</button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{codeRequired && (
|
||||
<div>
|
||||
<TextInput
|
||||
type="text"
|
||||
required
|
||||
{...register("code", {
|
||||
required: t("set.required.code"),
|
||||
})}
|
||||
label="Code"
|
||||
autoComplete="one-time-code"
|
||||
error={errors.code?.message as string}
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
{...register("password", {
|
||||
required: t("set.required.newPassword"),
|
||||
})}
|
||||
label="New Password"
|
||||
error={errors.password?.message as string}
|
||||
data-testid="password-set-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
{...register("confirmPassword", {
|
||||
required: t("set.required.confirmPassword"),
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
data-testid="password-set-confirm-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordComplexitySettings && (
|
||||
<PasswordComplexity
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
password={watchPassword}
|
||||
equals={!!watchPassword && watchPassword === watchConfirmPassword}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<BackButton data-testid="back-button" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={
|
||||
loading ||
|
||||
!policyIsValid ||
|
||||
!formState.isValid ||
|
||||
watchPassword !== watchConfirmPassword
|
||||
}
|
||||
onClick={handleSubmit(submitPassword)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="set.submit" namespace="password" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "@/helpers/validators";
|
||||
import { registerUser } from "@/lib/server/register";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { PasswordComplexity } from "./password-complexity";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
organization: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function SetRegisterPasswordForm({
|
||||
passwordComplexitySettings,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
organization,
|
||||
requestId,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
firstname: firstname ?? "",
|
||||
lastname: lastname ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("register");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const response = await registerUser({
|
||||
email: email,
|
||||
firstName: firstname,
|
||||
lastName: lastname,
|
||||
organization: organization,
|
||||
requestId: requestId,
|
||||
password: values.password,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not register user");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const watchPassword = watch("password", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
const hasMinLength =
|
||||
passwordComplexitySettings &&
|
||||
watchPassword?.length >= passwordComplexitySettings.minLength;
|
||||
const hasSymbol = symbolValidator(watchPassword);
|
||||
const hasNumber = numberValidator(watchPassword);
|
||||
const hasUppercase = upperCaseValidator(watchPassword);
|
||||
const hasLowercase = lowerCaseValidator(watchPassword);
|
||||
|
||||
const policyIsValid =
|
||||
passwordComplexitySettings &&
|
||||
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
|
||||
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
|
||||
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
|
||||
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
|
||||
hasMinLength;
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
{...register("password", {
|
||||
required: t("password.required.password"),
|
||||
})}
|
||||
label="Password"
|
||||
error={errors.password?.message as string}
|
||||
data-testid="password-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
{...register("confirmPassword", {
|
||||
required: t("password.required.confirmPassword"),
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
data-testid="password-confirm-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordComplexitySettings && (
|
||||
<PasswordComplexity
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
password={watchPassword}
|
||||
equals={!!watchPassword && watchPassword === watchConfirmPassword}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<BackButton data-testid="back-button" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={
|
||||
loading ||
|
||||
!policyIsValid ||
|
||||
!formState.isValid ||
|
||||
watchPassword !== watchConfirmPassword
|
||||
}
|
||||
onClick={handleSubmit(submitRegister)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="password.submit" namespace="register" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { idpTypeToSlug } from "@/lib/idp";
|
||||
import { redirectToIdp } from "@/lib/server/idp";
|
||||
import {
|
||||
IdentityProvider,
|
||||
IdentityProviderType,
|
||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { ReactNode, useActionState } from "react";
|
||||
import { Alert } from "./alert";
|
||||
import { SignInWithIdentityProviderProps } from "./idps/base-button";
|
||||
import { SignInWithApple } from "./idps/sign-in-with-apple";
|
||||
import { SignInWithAzureAd } from "./idps/sign-in-with-azure-ad";
|
||||
import { SignInWithGeneric } from "./idps/sign-in-with-generic";
|
||||
import { SignInWithGithub } from "./idps/sign-in-with-github";
|
||||
import { SignInWithGitlab } from "./idps/sign-in-with-gitlab";
|
||||
import { SignInWithGoogle } from "./idps/sign-in-with-google";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
export interface SignInWithIDPProps {
|
||||
children?: ReactNode;
|
||||
identityProviders: IdentityProvider[];
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
linkOnly?: boolean;
|
||||
}
|
||||
|
||||
export function SignInWithIdp({
|
||||
identityProviders,
|
||||
requestId,
|
||||
organization,
|
||||
linkOnly,
|
||||
}: Readonly<SignInWithIDPProps>) {
|
||||
const [state, action, _isPending] = useActionState(redirectToIdp, {});
|
||||
|
||||
const renderIDPButton = (idp: IdentityProvider, index: number) => {
|
||||
const { id, name, type } = idp;
|
||||
|
||||
const components: Partial<
|
||||
Record<
|
||||
IdentityProviderType,
|
||||
(props: SignInWithIdentityProviderProps) => ReactNode
|
||||
>
|
||||
> = {
|
||||
[IdentityProviderType.APPLE]: SignInWithApple,
|
||||
[IdentityProviderType.OAUTH]: SignInWithGeneric,
|
||||
[IdentityProviderType.OIDC]: SignInWithGeneric,
|
||||
[IdentityProviderType.GITHUB]: SignInWithGithub,
|
||||
[IdentityProviderType.GITHUB_ES]: SignInWithGithub,
|
||||
[IdentityProviderType.AZURE_AD]: SignInWithAzureAd,
|
||||
[IdentityProviderType.GOOGLE]: (props) => (
|
||||
<SignInWithGoogle {...props} e2e="google" />
|
||||
),
|
||||
[IdentityProviderType.GITLAB]: SignInWithGitlab,
|
||||
[IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab,
|
||||
[IdentityProviderType.SAML]: SignInWithGeneric,
|
||||
[IdentityProviderType.LDAP]: SignInWithGeneric,
|
||||
[IdentityProviderType.JWT]: SignInWithGeneric,
|
||||
};
|
||||
|
||||
const Component = components[type];
|
||||
return Component ? (
|
||||
<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 w-full flex-col space-y-2 text-sm">
|
||||
<p className="ztdl-p text-center">
|
||||
<Translated i18nKey="orSignInWith" namespace="idp" />
|
||||
</p>
|
||||
{!!identityProviders.length && identityProviders?.map(renderIDPButton)}
|
||||
{state?.error && (
|
||||
<div className="py-4">
|
||||
<Alert>{state?.error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SignInWithIdp.displayName = "SignInWithIDP";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => (
|
||||
<div
|
||||
className={clsx("rounded-2xl bg-gray-900/80 p-4", {
|
||||
"relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent":
|
||||
isLoading,
|
||||
})}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="h-14 rounded-lg bg-gray-700" />
|
||||
<div className="h-3 w-11/12 rounded-lg bg-gray-700" />
|
||||
<div className="h-3 w-8/12 rounded-lg bg-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Skeleton({ children }: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="skeleton flex flex-row items-center justify-center rounded-lg bg-background-light-600 px-8 py-12 dark:bg-background-dark-600">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FC } from "react";
|
||||
|
||||
export const Spinner: FC<{ className?: string }> = ({ className = "" }) => {
|
||||
return (
|
||||
<svg
|
||||
role="status"
|
||||
className={`${className} inline-block animate-spin fill-primary-light-500 text-black/10 dark:fill-primary-dark-500 dark:text-white/10`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
evenPadding?: boolean;
|
||||
};
|
||||
|
||||
const getBadgeClasses = (state: BadgeState, evenPadding: boolean) =>
|
||||
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,
|
||||
"p-[2px]": evenPadding,
|
||||
});
|
||||
|
||||
export function StateBadge({
|
||||
state = BadgeState.Success,
|
||||
evenPadding = false,
|
||||
children,
|
||||
}: StateBadgeProps) {
|
||||
return (
|
||||
<span className={`${getBadgeClasses(state, evenPadding)}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Tab } from "@/components/tab";
|
||||
|
||||
export type Item = {
|
||||
text: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export const TabGroup = ({ path, items }: { path: string; items: Item[] }) => {
|
||||
return (
|
||||
<div className="-mt-2 flex flex-wrap items-center">
|
||||
{items.map((item) => (
|
||||
<Tab key={path + item.slug} item={item} path={path} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { Item } from "@/components/tab-group";
|
||||
import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
||||
export const Tab = ({
|
||||
path,
|
||||
item: { slug, text },
|
||||
}: {
|
||||
path: string;
|
||||
item: Item;
|
||||
}) => {
|
||||
const segment = useSelectedLayoutSegment();
|
||||
const href = slug ? path + "/" + slug : path;
|
||||
const isActive =
|
||||
// Example home pages e.g. `/layouts`
|
||||
(!slug && segment === null) ||
|
||||
// Nested pages e.g. `/layouts/electronics`
|
||||
segment === slug;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={clsx("mr-2 mt-2 rounded-lg px-3 py-1 text-sm font-medium", {
|
||||
"bg-gray-700 text-gray-100 hover:bg-gray-500 hover:text-white":
|
||||
!isActive,
|
||||
"bg-blue-500 text-white": isActive,
|
||||
})}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { ThemeProvider as ThemeP } from "next-themes";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeP
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
storageKey="cp-theme"
|
||||
value={{ dark: "dark" }}
|
||||
>
|
||||
{children}
|
||||
</ThemeP>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { setTheme } from "@/helpers/colors";
|
||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
branding: BrandingSettings | undefined;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ThemeWrapper = ({ children, branding }: Props) => {
|
||||
useEffect(() => {
|
||||
setTheme(document, branding);
|
||||
}, [branding]);
|
||||
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Theme() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState<boolean>(false);
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative grid h-fit grid-cols-2 rounded-full border border-divider-light p-1 dark:border-divider-dark`}
|
||||
>
|
||||
<button
|
||||
className={`flex h-8 w-8 flex-row items-center justify-center rounded-full transition-all hover:opacity-100 ${
|
||||
isDark ? "bg-black/10 dark:bg-white/10" : "opacity-60"
|
||||
}`}
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 flex-shrink-0 rounded-full text-xl" />
|
||||
</button>
|
||||
<button
|
||||
className={`flex h-8 w-8 flex-row items-center justify-center rounded-full transition-all hover:opacity-100 ${
|
||||
!isDark ? "bg-black/10 dark:bg-white/10" : "opacity-60"
|
||||
}`}
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<SunIcon className="h-6 w-6 flex-shrink-0 rounded-full text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { getNextUrl } from "@/lib/client";
|
||||
import { verifyTOTP } from "@/lib/server/verify";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { CopyToClipboard } from "./copy-to-clipboard";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
uri: string;
|
||||
secret: string;
|
||||
loginName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
checkAfter?: boolean;
|
||||
loginSettings?: LoginSettings;
|
||||
};
|
||||
export function TotpRegister({
|
||||
uri,
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
organization,
|
||||
checkAfter,
|
||||
loginSettings,
|
||||
}: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("otp");
|
||||
|
||||
async function continueWithCode(values: Inputs) {
|
||||
setLoading(true);
|
||||
return verifyTOTP(values.code, loginName, organization)
|
||||
.then(async () => {
|
||||
// if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login)
|
||||
if (checkAfter) {
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
if (loginName) {
|
||||
params.append("loginName", loginName);
|
||||
}
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
if (organization) {
|
||||
params.append("organization", organization);
|
||||
}
|
||||
|
||||
return router.push(`/otp/time-based?` + params);
|
||||
} else {
|
||||
const url =
|
||||
requestId && sessionId
|
||||
? await getNextUrl(
|
||||
{
|
||||
sessionId: sessionId,
|
||||
requestId: requestId,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: loginName
|
||||
? await getNextUrl(
|
||||
{
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
},
|
||||
loginSettings?.defaultRedirectUri,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (url) {
|
||||
return router.push(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e.message);
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{uri && (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
className="my-4 h-40 w-40 rounded-md bg-white p-2"
|
||||
value={uri}
|
||||
/>
|
||||
<div className="my-2 mb-4 flex w-96 rounded-lg border border-divider-light px-4 py-2 pr-2 text-sm dark:border-divider-dark">
|
||||
<Link href={uri} target="_blank" className="flex-1 overflow-x-auto">
|
||||
{uri}
|
||||
</Link>
|
||||
|
||||
<CopyToClipboard value={uri}></CopyToClipboard>
|
||||
</div>
|
||||
<form className="w-full">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="text"
|
||||
{...register("code", { required: t("set.required.code") })}
|
||||
label="Code"
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(continueWithCode)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="set.submit" namespace="otp" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function Translated({
|
||||
i18nKey,
|
||||
namespace,
|
||||
data,
|
||||
...props
|
||||
}: {
|
||||
i18nKey: string;
|
||||
children?: React.ReactNode;
|
||||
namespace?: string;
|
||||
data?: any;
|
||||
} & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
const t = useTranslations(namespace);
|
||||
const helperKey = `${namespace ? `${namespace}.` : ""}${i18nKey}`;
|
||||
|
||||
return (
|
||||
<span data-i18n-key={helperKey} {...props}>
|
||||
{t(i18nKey, data)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
loginName?: string;
|
||||
displayName?: string;
|
||||
showDropdown: boolean;
|
||||
searchParams?: Record<string | number | symbol, string | undefined>;
|
||||
};
|
||||
|
||||
export function UserAvatar({
|
||||
loginName,
|
||||
displayName,
|
||||
showDropdown,
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const params = new URLSearchParams({});
|
||||
|
||||
if (searchParams?.sessionId) {
|
||||
params.set("sessionId", searchParams.sessionId);
|
||||
}
|
||||
|
||||
if (searchParams?.organization) {
|
||||
params.set("organization", searchParams.organization);
|
||||
}
|
||||
|
||||
if (searchParams?.requestId) {
|
||||
params.set("requestId", searchParams.requestId);
|
||||
}
|
||||
|
||||
if (searchParams?.loginName) {
|
||||
params.set("loginName", searchParams.loginName);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
|
||||
<div>
|
||||
<Avatar
|
||||
size="small"
|
||||
name={displayName ?? loginName ?? ""}
|
||||
loginName={loginName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-4 max-w-[250px] overflow-hidden text-ellipsis pr-4 text-14px">
|
||||
{loginName}
|
||||
</span>
|
||||
<span className="flex-grow"></span>
|
||||
{showDropdown && (
|
||||
<Link
|
||||
href={"/accounts?" + params}
|
||||
className="ml-4 mr-1 flex items-center justify-center rounded-full p-1 transition-all hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { sendLoginname } from "@/lib/server/loginname";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Inputs = {
|
||||
loginName: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
loginName: string | undefined;
|
||||
requestId: string | undefined;
|
||||
loginSettings: LoginSettings | undefined;
|
||||
organization?: string;
|
||||
suffix?: string;
|
||||
submit: boolean;
|
||||
allowRegister: boolean;
|
||||
};
|
||||
|
||||
export function UsernameForm({
|
||||
loginName,
|
||||
requestId,
|
||||
organization,
|
||||
suffix,
|
||||
loginSettings,
|
||||
submit,
|
||||
allowRegister,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
loginName: loginName ? loginName : "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("loginname");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
async function submitLoginName(values: Inputs, organization?: string) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await sendLoginname({
|
||||
loginName: values.loginName,
|
||||
organization,
|
||||
requestId,
|
||||
suffix,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("An internal error occurred");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (res && "redirect" in res && res.redirect) {
|
||||
return router.push(res.redirect);
|
||||
}
|
||||
|
||||
if (res && "error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (submit && loginName) {
|
||||
// When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid.
|
||||
submitLoginName({ loginName }, organization);
|
||||
}
|
||||
}, []);
|
||||
|
||||
let inputLabel = "Loginname";
|
||||
if (
|
||||
loginSettings?.disableLoginWithEmail &&
|
||||
loginSettings?.disableLoginWithPhone
|
||||
) {
|
||||
inputLabel = "Username";
|
||||
} else if (loginSettings?.disableLoginWithEmail) {
|
||||
inputLabel = "Username or phone number";
|
||||
} else if (loginSettings?.disableLoginWithPhone) {
|
||||
inputLabel = "Username or email";
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
{...register("loginName", { required: t("required.loginName") })}
|
||||
label={inputLabel}
|
||||
data-testid="username-text-input"
|
||||
suffix={suffix}
|
||||
/>
|
||||
{allowRegister && (
|
||||
<button
|
||||
className="text-sm transition-all hover:text-primary-light-500 dark:hover:text-primary-dark-500"
|
||||
onClick={() => {
|
||||
const registerParams = new URLSearchParams();
|
||||
if (organization) {
|
||||
registerParams.append("organization", organization);
|
||||
}
|
||||
if (requestId) {
|
||||
registerParams.append("requestId", requestId);
|
||||
}
|
||||
|
||||
router.push("/register?" + registerParams);
|
||||
}}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
data-testid="register-button"
|
||||
>
|
||||
<Translated i18nKey="register" namespace="loginname" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex w-full flex-row items-center">
|
||||
<BackButton data-testid="back-button" />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
data-testid="submit-button"
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit((e) => submitLoginName(e, organization))}
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="submit" namespace="loginname" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
import { TextInput } from "./input";
|
||||
import { Spinner } from "./spinner";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
type Inputs = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
loginName?: string;
|
||||
organization?: string;
|
||||
code?: string;
|
||||
isInvite: boolean;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function VerifyForm({
|
||||
userId,
|
||||
loginName,
|
||||
organization,
|
||||
requestId,
|
||||
code,
|
||||
isInvite,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
code: code ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const t = useTranslations("verify");
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function resendCode() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await resendVerification({
|
||||
userId,
|
||||
isInvite: isInvite,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not resend email");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const fcn = useCallback(
|
||||
async function submitCodeAndContinue(
|
||||
value: Inputs,
|
||||
): Promise<boolean | void> {
|
||||
setLoading(true);
|
||||
|
||||
const response = await sendVerification({
|
||||
code: value.code,
|
||||
userId,
|
||||
isInvite: isInvite,
|
||||
loginName: loginName,
|
||||
organization: organization,
|
||||
requestId: requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify user");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response?.redirect) {
|
||||
return router.push(response?.redirect);
|
||||
}
|
||||
},
|
||||
[isInvite, userId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
fcn({ code });
|
||||
}
|
||||
}, [code, fcn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="w-full">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<div className="flex flex-row">
|
||||
<span className="mr-auto flex-1 text-left">
|
||||
<Translated i18nKey="verify.noCodeReceived" namespace="verify" />
|
||||
</span>
|
||||
<button
|
||||
aria-label="Resend Code"
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="ml-4 cursor-pointer text-primary-light-500 hover:text-primary-light-400 disabled:cursor-default disabled:text-gray-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 dark:disabled:text-gray-700"
|
||||
onClick={() => {
|
||||
resendCode();
|
||||
}}
|
||||
data-testid="resend-button"
|
||||
>
|
||||
<Translated i18nKey="verify.resendCode" namespace="verify" />
|
||||
</button>
|
||||
</div>
|
||||
</Alert>
|
||||
<div className="mt-4">
|
||||
<TextInput
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
{...register("code", { required: t("verify.required.code") })}
|
||||
label="Code"
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(fcn)}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
||||
<Translated i18nKey="verify.submit" namespace="verify" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FC } from "react";
|
||||
|
||||
export const ZitadelLogoDark: FC = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fillRule="evenodd"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="2"
|
||||
height="100%"
|
||||
width="100%"
|
||||
clipRule="evenodd"
|
||||
viewBox="0 0 467 467"
|
||||
>
|
||||
<g transform="matrix(.56485 0 0 .65932 -1282.85 0)">
|
||||
<path
|
||||
fill="none"
|
||||
d="M2271.15 0H3097.9230000000002V708.241H2271.15z"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1493.5 1056.38V1037h3v24.62l-70.48-41.24 70.48-40.988V1004h-3v-19.392l-61.52 35.782 61.52 35.99z"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5923.46 -2258.26)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear1)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.04293 -.28297 .16022 -.07582 12884.5 137.392)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear2)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(.16022 .07582 -.04293 .28297 12878.9 10.875)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear3)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.11729 .20715 -.11729 -.20715 12943.8 65.7)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear4)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.16022 -.07582 .04293 -.28297 12917.4 132.195)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear5)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.11729 .20715 .11729 .20715 12897.8 5.875)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear6)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.04293 -.28297 -.16022 .07582 12936.8 97.644)"
|
||||
></path>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5928.43 -2257.12)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5884.5 -2116.69)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5855.22 -2023.06)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -6234.47 -2112.14)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5957.71 -2350.75)"
|
||||
></circle>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1499.26 757.787s-1.89-1.298-2.26-2.587c-.29-1.018-.43-4.538-.46-5.2-.13-2.697 2.67-4.356 2.67-4.356l-9.2.191s3.14-.122 3.45 4.165c.05.661-.23 3.476-.46 5.2-.09 1.247-1.8 2.468-1.8 2.468l8.06.119z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5477.99 -831.33)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1495 760v-16"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5404.79 -597.271)"
|
||||
></path>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1498.27 757.077s-1.56-.617-1.62-2.277c0-1.142-.01-1.519 0-2.784-.03-.682-.06-1.408 0-2.067.13-3.113 1.85-3.793 1.85-3.793l-7.04-.225s1.91.788 2.19 3.899c.06.659.04 1.698 0 2.379a88.86 88.86 0 000 2.309c.03 1.816-1.07 2.309-1.07 2.309l5.69.25z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5404.79 -597.271)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1496.17 759.473l59.37-39.459"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5770.62 -677.495)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1500.86 762.056s-1-1.656 2.23-4.6c1.82-1.659 4.24-3.305 6.89-5.201 4.84-3.465 10.7-7.315 16.54-11.206 4.93-3.283 9.86-6.57 14.3-9.369 3.7-2.331 7.03-4.384 9.72-5.88.53-.294 1.06-.471 1.51-.771 2.68-1.772 4.8-.061 4.8-.061l-4.62-8.686s-.24 3.172-2.23 4.715c-.43.336-.85.744-1.33 1.123-2.47 1.933-5.68 4.224-9.28 6.747-4.33 3.031-9.26 6.299-14.2 9.571-5.84 3.876-11.67 7.796-16.7 10.891-2.75 1.694-5.21 3.248-7.36 4.269-3.14 1.488-5.85.019-5.85.019l5.58 8.439z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5770.62 -677.495)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1496.17 759.473l59.37-39.459"
|
||||
transform="matrix(4.96737 -1.14029 -1.16463 -3.72366 -3997 4993.28)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1496.1 754.362s1.1 1.245 5.03-.764c2.12-1.089 4.61-2.575 7.36-4.269 5.03-3.095 10.86-7.015 16.7-10.891 4.94-3.272 9.75-6.606 14.08-9.636 3.6-2.523 10.09-6.743 10.54-7.052 2.94-2.02 2.37-3.554 2.37-3.554l3.1 5.956s-1.51-1.247-3.91.529c-.44.325-6.85 4.668-10.55 6.999-4.44 2.799-9.37 6.086-14.3 9.369-5.84 3.891-11.7 7.741-16.54 11.206-2.65 1.896-5.09 3.516-6.89 5.201-3.62 3.385-1.83 5.827-1.83 5.827l-5.16-8.921z"
|
||||
transform="matrix(4.96737 -1.14029 -1.16463 -3.72366 -3997 4993.28)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="_Linear1"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235) rotate(-75 -.938 .578)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear2"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(160.7235) rotate(-15 4.962 -1.993)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear3"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="rotate(-135 173.811 54.311) scale(160.724)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear4"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235) rotate(-15 -4.103 4.77)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear5"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.724 160.724) rotate(45 -1.58 -1.166)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear6"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235 160.7235) rotate(-75 .592 1.434)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FC } from "react";
|
||||
|
||||
export const ZitadelLogoLight: FC = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fillRule="evenodd"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="2"
|
||||
height="100%"
|
||||
width="100%"
|
||||
clipRule="evenodd"
|
||||
viewBox="0 0 467 467"
|
||||
>
|
||||
<g transform="matrix(.56485 0 0 .65932 -1282.85 0)">
|
||||
<path
|
||||
fill="none"
|
||||
d="M2271.15 0H3097.9230000000002V708.241H2271.15z"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1493.5 1056.38V1037h3v24.62l-70.48-41.24 70.48-40.988V1004h-3v-19.392l-61.52 35.782 61.52 35.99z"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5923.46 -2258.26)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear1)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.04293 -.28297 .16022 -.07582 12884.5 137.392)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear2)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(.16022 .07582 -.04293 .28297 12878.9 10.875)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear3)"
|
||||
d="M212.517 110h-12.125L190 92l-10.392 18h-12.125L190 71l22.517 39z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.11729 .20715 -.11729 -.20715 12943.8 65.7)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear4)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.16022 -.07582 .04293 -.28297 12917.4 132.195)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear5)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.11729 .20715 .11729 .20715 12897.8 5.875)"
|
||||
></path>
|
||||
<path
|
||||
fill="url(#_Linear6)"
|
||||
d="M139.622 117L149 142h-18.756l9.378-25z"
|
||||
transform="matrix(31.0036 0 0 15.0393 -397275 -666.457) matrix(-.04293 -.28297 -.16022 .07582 12936.8 97.644)"
|
||||
></path>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5928.43 -2257.12)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5884.5 -2116.69)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5855.22 -2023.06)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -6234.47 -2112.14)"
|
||||
></circle>
|
||||
<circle
|
||||
cx="1496"
|
||||
cy="1004"
|
||||
r="7"
|
||||
fill="#fff"
|
||||
transform="matrix(4.96737 -1.14029 1.331 4.25561 -5957.71 -2350.75)"
|
||||
></circle>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1499.26 757.787s-1.89-1.298-2.26-2.587c-.29-1.018-.43-4.538-.46-5.2-.13-2.697 2.67-4.356 2.67-4.356l-9.2.191s3.14-.122 3.45 4.165c.05.661-.23 3.476-.46 5.2-.09 1.247-1.8 2.468-1.8 2.468l8.06.119z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5477.99 -831.33)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1495 760v-16"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5404.79 -597.271)"
|
||||
></path>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1498.27 757.077s-1.56-.617-1.62-2.277c0-1.142-.01-1.519 0-2.784-.03-.682-.06-1.408 0-2.067.13-3.113 1.85-3.793 1.85-3.793l-7.04-.225s1.91.788 2.19 3.899c.06.659.04 1.698 0 2.379a88.86 88.86 0 000 2.309c.03 1.816-1.07 2.309-1.07 2.309l5.69.25z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5404.79 -597.271)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1496.17 759.473l59.37-39.459"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5770.62 -677.495)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1500.86 762.056s-1-1.656 2.23-4.6c1.82-1.659 4.24-3.305 6.89-5.201 4.84-3.465 10.7-7.315 16.54-11.206 4.93-3.283 9.86-6.57 14.3-9.369 3.7-2.331 7.03-4.384 9.72-5.88.53-.294 1.06-.471 1.51-.771 2.68-1.772 4.8-.061 4.8-.061l-4.62-8.686s-.24 3.172-2.23 4.715c-.43.336-.85.744-1.33 1.123-2.47 1.933-5.68 4.224-9.28 6.747-4.33 3.031-9.26 6.299-14.2 9.571-5.84 3.876-11.67 7.796-16.7 10.891-2.75 1.694-5.21 3.248-7.36 4.269-3.14 1.488-5.85.019-5.85.019l5.58 8.439z"
|
||||
transform="matrix(4.96737 -1.14029 1.16463 3.72366 -5770.62 -677.495)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
d="M1496.17 759.473l59.37-39.459"
|
||||
transform="matrix(4.96737 -1.14029 -1.16463 -3.72366 -3997 4993.28)"
|
||||
></path>
|
||||
</g>
|
||||
<g>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M1496.1 754.362s1.1 1.245 5.03-.764c2.12-1.089 4.61-2.575 7.36-4.269 5.03-3.095 10.86-7.015 16.7-10.891 4.94-3.272 9.75-6.606 14.08-9.636 3.6-2.523 10.09-6.743 10.54-7.052 2.94-2.02 2.37-3.554 2.37-3.554l3.1 5.956s-1.51-1.247-3.91.529c-.44.325-6.85 4.668-10.55 6.999-4.44 2.799-9.37 6.086-14.3 9.369-5.84 3.891-11.7 7.741-16.54 11.206-2.65 1.896-5.09 3.516-6.89 5.201-3.62 3.385-1.83 5.827-1.83 5.827l-5.16-8.921z"
|
||||
transform="matrix(4.96737 -1.14029 -1.16463 -3.72366 -3997 4993.28)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="_Linear1"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235) rotate(-75 -.938 .578)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear2"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(160.7235) rotate(-15 4.962 -1.993)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear3"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="rotate(-135 173.811 54.311) scale(160.724)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear4"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235) rotate(-15 -4.103 4.77)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear5"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.724 160.724) rotate(45 -1.58 -1.166)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear6"
|
||||
x1="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
y2="0"
|
||||
gradientTransform="scale(-160.7235 160.7235) rotate(-75 .592 1.434)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FF8F00"></stop>
|
||||
<stop offset="1" stopColor="#FE00FF"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import Image from "next/image";
|
||||
type Props = {
|
||||
height?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export function ZitadelLogo({ height = 40, width = 147.5 }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden dark:flex">
|
||||
{/* <ZitadelLogoLight /> */}
|
||||
|
||||
<Image
|
||||
height={height}
|
||||
width={width}
|
||||
src="/zitadel-logo-light.svg"
|
||||
alt="zitadel logo"
|
||||
priority={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex dark:hidden">
|
||||
<Image
|
||||
height={height}
|
||||
width={width}
|
||||
priority={true}
|
||||
src="/zitadel-logo-dark.svg"
|
||||
alt="zitadel logo"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user