mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-13 13:01:38 +00:00
device page, code form
This commit is contained in:
@@ -187,6 +187,18 @@
|
|||||||
"allSetup": "You have already setup an authenticator!",
|
"allSetup": "You have already setup an authenticator!",
|
||||||
"linkWithIDP": "or link with an Identity Provider"
|
"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": {
|
"error": {
|
||||||
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
|
"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.",
|
"sessionExpired": "Your current session has expired. Please login again.",
|
||||||
|
86
apps/login/src/app/(login)/device/page.tsx
Normal file
86
apps/login/src/app/(login)/device/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
48
apps/login/src/components/app-avatar.tsx
Normal file
48
apps/login/src/components/app-avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -12,7 +12,7 @@ interface AvatarProps {
|
|||||||
shadow?: boolean;
|
shadow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name: string, loginName: string) {
|
export function getInitials(name: string, loginName: string) {
|
||||||
let credentials = "";
|
let credentials = "";
|
||||||
if (name) {
|
if (name) {
|
||||||
const split = name.split(" ");
|
const split = name.split(" ");
|
||||||
|
93
apps/login/src/components/device-code-form.tsx
Normal file
93
apps/login/src/components/device-code-form.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -3,27 +3,34 @@
|
|||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { AppAvatar } from "./app-avatar";
|
||||||
import { ThemeWrapper } from "./theme-wrapper";
|
import { ThemeWrapper } from "./theme-wrapper";
|
||||||
|
|
||||||
export function DynamicTheme({
|
export function DynamicTheme({
|
||||||
branding,
|
branding,
|
||||||
children,
|
children,
|
||||||
|
appName,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
branding?: BrandingSettings;
|
branding?: BrandingSettings;
|
||||||
|
appName?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ThemeWrapper branding={branding}>
|
<ThemeWrapper branding={branding}>
|
||||||
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
|
<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="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 && (
|
{branding && (
|
||||||
<Logo
|
<>
|
||||||
lightSrc={branding.lightTheme?.logoUrl}
|
<Logo
|
||||||
darkSrc={branding.darkTheme?.logoUrl}
|
lightSrc={branding.lightTheme?.logoUrl}
|
||||||
height={150}
|
darkSrc={branding.darkTheme?.logoUrl}
|
||||||
width={150}
|
height={appName ? 100 : 150}
|
||||||
/>
|
width={appName ? 100 : 150}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{appName && <AppAvatar appName={appName} />}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
15
apps/login/src/lib/server/oidc.ts
Normal file
15
apps/login/src/lib/server/oidc.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
@@ -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({
|
export async function createCallback({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
req,
|
req,
|
||||||
|
Reference in New Issue
Block a user