device page, code form

This commit is contained in:
Max Peintner
2025-04-28 14:57:08 +02:00
parent 830c2795e9
commit e2718483cc
8 changed files with 283 additions and 8 deletions

View File

@@ -187,6 +187,18 @@
"allSetup": "You have already setup an authenticator!",
"linkWithIDP": "or link with an Identity Provider"
},
"device": {
"usercode": {
"title": "Device code",
"description": "Enter the code provided in the verification email.",
"submit": "Continue"
},
"request": {
"title": "would like to connect:",
"description": "By clicking Allow, you allow this app and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.",
"submit": "Allow"
}
},
"error": {
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
"sessionExpired": "Your current session has expired. Please login again.",

View File

@@ -0,0 +1,86 @@
import { DeviceCodeForm } from "@/components/device-code-form";
import { DynamicTheme } from "@/components/dynamic-theme";
import { getServiceUrlFromHeaders } from "@/lib/service";
import {
getBrandingSettings,
getDefaultOrg,
getDeviceAuthorizationRequest,
} from "@/lib/zitadel";
import { DeviceAuthorizationRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "device" });
const loginName = searchParams?.loginName;
const userCode = searchParams?.user_code;
const organization = searchParams?.organization;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
defaultOrganization = org.id;
}
}
let deviceAuthRequest: DeviceAuthorizationRequest | null = null;
if (userCode) {
const deviceAuthorizationRequestResponse =
await getDeviceAuthorizationRequest({
serviceUrl,
userCode,
});
if (deviceAuthorizationRequestResponse.deviceAuthorizationRequest) {
deviceAuthRequest =
deviceAuthorizationRequestResponse.deviceAuthorizationRequest;
}
}
const branding = await getBrandingSettings({
serviceUrl,
organization: organization ?? defaultOrganization,
});
return (
<DynamicTheme branding={branding} appName={deviceAuthRequest?.appName}>
<div className="flex flex-col items-center space-y-4">
{!userCode && (
<>
<h1>{t("usercode.title")}</h1>
<p className="ztdl-p">{t("usercode.description")}</p>
<DeviceCodeForm
// loginSettings={contextLoginSettings}
></DeviceCodeForm>
</>
)}
{deviceAuthRequest && (
<div>
<h1>
{deviceAuthRequest.appName}
<br />
{t("request.title")}
</h1>
<p className="ztdl-p text-left text-xs mt-4">
{t("request.description")}
</p>
{/* {JSON.stringify(deviceAuthRequest)} */}
</div>
)}
</div>
</DynamicTheme>
);
}

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={`w-[100px] h-[100px] flex justify-center items-center cursor-default pointer-events-none group-focus:outline-none group-focus:ring-2 transition-colors duration-200 dark:group-focus:ring-offset-blue bg-primary-light-500 text-primary-light-contrast-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-500 group-focus:ring-primary-light-200 dark:group-focus:ring-primary-dark-400 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 dark:text-blue rounded-full ${
shadow ? "shadow" : ""
}`}
style={resolvedTheme === "light" ? avatarStyleLight : avatarStyleDark}
>
{imageUrl ? (
<Image
height={48}
width={48}
alt="avatar"
className="w-full h-full border border-divider-light dark:border-divider-dark rounded-full"
src={imageUrl}
/>
) : (
<span className={`uppercase text-3xl`}>{credentials}</span>
)}
</div>
);
}

View File

@@ -12,7 +12,7 @@ interface AvatarProps {
shadow?: boolean;
}
function getInitials(name: string, loginName: string) {
export function getInitials(name: string, loginName: string) {
let credentials = "";
if (name) {
const split = name.split(" ");

View File

@@ -0,0 +1,93 @@
"use client";
import { Alert } from "@/components/alert";
import { getDeviceAuthorizationRequest } from "@/lib/server/oidc";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs = {
userCode: string;
};
export function DeviceCodeForm() {
const t = useTranslations("verify");
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
userCode: "",
},
});
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 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);
}
}
return (
<>
<form className="w-full">
<div className="mt-4">
<TextInput
type="text"
autoComplete="one-time-code"
{...register("userCode", { required: "This field is required" })}
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="h-5 w-5 mr-2" />}
{t("verify.submit")}
</Button>
</div>
</form>
</>
);
}

View File

@@ -3,27 +3,34 @@
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 dark:bg-background-dark-500 px-8 py-12">
<div className="mx-auto flex flex-col items-center space-y-4">
<div className="relative">
<div className="relative flex flex-row items-center justify-center gap-8">
{branding && (
<>
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={150}
width={150}
height={appName ? 100 : 150}
width={appName ? 100 : 150}
/>
{appName && <AppAvatar appName={appName} />}
</>
)}
</div>

View File

@@ -0,0 +1,15 @@
"use server";
import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel";
import { headers } from "next/headers";
import { getServiceUrlFromHeaders } from "../service";
export async function getDeviceAuthorizationRequest(userCode: string) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
return zitadelGetDeviceAuthorizationRequest({
serviceUrl,
userCode,
});
}

View File

@@ -929,6 +929,20 @@ export async function getAuthRequest({
});
}
export async function getDeviceAuthorizationRequest({
serviceUrl,
userCode,
}: {
serviceUrl: string;
userCode: string;
}) {
const oidcService = await createServiceForHost(OIDCService, serviceUrl);
return oidcService.getDeviceAuthorizationRequest({
userCode,
});
}
export async function createCallback({
serviceUrl,
req,