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:
Elio Bischof
2025-07-24 14:22:32 +02:00
committed by GitHub
parent af66c9844a
commit b10455b51f
430 changed files with 2869 additions and 4108 deletions
+61
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+234
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
};
+73
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
};
+67
View File
@@ -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>
);
});
+99
View File
@@ -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>
);
}
+287
View File
@@ -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>
);
}
+280
View File
@@ -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>
);
}
+37
View File
@@ -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&apos;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>
);
}
+179
View File
@@ -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>
);
}
+230
View File
@@ -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>
);
}
+225
View File
@@ -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>
);
}
+174
View File
@@ -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>
);
+9
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
};
+39
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
};
+35
View File
@@ -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>;
};
+44
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+171
View File
@@ -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>
</>
);
}