From e2718483cc8ccd52dd5316acdf554a51d4922d8f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 28 Apr 2025 14:57:08 +0200 Subject: [PATCH] device page, code form --- apps/login/locales/en.json | 12 +++ apps/login/src/app/(login)/device/page.tsx | 86 +++++++++++++++++ apps/login/src/components/app-avatar.tsx | 48 ++++++++++ apps/login/src/components/avatar.tsx | 2 +- .../login/src/components/device-code-form.tsx | 93 +++++++++++++++++++ apps/login/src/components/dynamic-theme.tsx | 21 +++-- apps/login/src/lib/server/oidc.ts | 15 +++ apps/login/src/lib/zitadel.ts | 14 +++ 8 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 apps/login/src/app/(login)/device/page.tsx create mode 100644 apps/login/src/components/app-avatar.tsx create mode 100644 apps/login/src/components/device-code-form.tsx create mode 100644 apps/login/src/lib/server/oidc.ts diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 36776ccbd9..cb5011f59a 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -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.", diff --git a/apps/login/src/app/(login)/device/page.tsx b/apps/login/src/app/(login)/device/page.tsx new file mode 100644 index 0000000000..3c533271c0 --- /dev/null +++ b/apps/login/src/app/(login)/device/page.tsx @@ -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>; +}) { + 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 ( + +
+ {!userCode && ( + <> +

{t("usercode.title")}

+

{t("usercode.description")}

+ + + )} + + {deviceAuthRequest && ( +
+

+ {deviceAuthRequest.appName} +
+ {t("request.title")} +

+

+ {t("request.description")} +

+ {/* {JSON.stringify(deviceAuthRequest)} */} +
+ )} +
+
+ ); +} diff --git a/apps/login/src/components/app-avatar.tsx b/apps/login/src/components/app-avatar.tsx new file mode 100644 index 0000000000..defe388438 --- /dev/null +++ b/apps/login/src/components/app-avatar.tsx @@ -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 ( +
+ {imageUrl ? ( + avatar + ) : ( + {credentials} + )} +
+ ); +} diff --git a/apps/login/src/components/avatar.tsx b/apps/login/src/components/avatar.tsx index 3f340e09b7..2300659875 100644 --- a/apps/login/src/components/avatar.tsx +++ b/apps/login/src/components/avatar.tsx @@ -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(" "); diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx new file mode 100644 index 0000000000..5747e52f3c --- /dev/null +++ b/apps/login/src/components/device-code-form.tsx @@ -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({ + mode: "onBlur", + defaultValues: { + userCode: "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function submitCodeAndContinue(value: Inputs): Promise { + 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 ( + <> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/apps/login/src/components/dynamic-theme.tsx b/apps/login/src/components/dynamic-theme.tsx index 7d0fecb558..d50bc082ea 100644 --- a/apps/login/src/components/dynamic-theme.tsx +++ b/apps/login/src/components/dynamic-theme.tsx @@ -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 (
-
+
{branding && ( - + <> + + + {appName && } + )}
diff --git a/apps/login/src/lib/server/oidc.ts b/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..4ae01b4a47 --- /dev/null +++ b/apps/login/src/lib/server/oidc.ts @@ -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, + }); +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0511eaaf0d..aee182dc41 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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,