mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 13:35:07 +00:00
Merge pull request #29 from zitadel/passkey-registration
feat(passkeys): register
This commit is contained in:
@@ -51,7 +51,7 @@ docker compose --file ./acceptance/docker-compose.yaml pull
|
||||
docker compose --file ./acceptance/docker-compose.yaml run setup
|
||||
|
||||
# Configure your shell to use the environment variables written to ./apps/login/.env.acceptance
|
||||
source ./apps/login/.env.acceptance
|
||||
export $(cat ./apps/login/.env.acceptance | xargs)
|
||||
```
|
||||
|
||||
### Developing Against Your ZITADEL Cloud Instance
|
||||
|
||||
@@ -1,49 +1,54 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
zitadel:
|
||||
user: '${ZITADEL_DEV_UID}'
|
||||
image: '${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}'
|
||||
user: "${ZITADEL_DEV_UID}"
|
||||
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}"
|
||||
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./machinekey:/machinekey
|
||||
- ./zitadel.yaml:/zitadel.yaml
|
||||
depends_on:
|
||||
db:
|
||||
condition: 'service_healthy'
|
||||
condition: "service_healthy"
|
||||
|
||||
db:
|
||||
image: 'cockroachdb/cockroach:v22.2.2'
|
||||
command: 'start-single-node --insecure --http-addr :9090'
|
||||
image: "cockroachdb/cockroach:v22.2.2"
|
||||
command: "start-single-node --insecure --http-addr :9090"
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9090/health?ready=1']
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9090/health?ready=1"]
|
||||
interval: "10s"
|
||||
timeout: "30s"
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
start_period: "20s"
|
||||
ports:
|
||||
- "26257:26257"
|
||||
- "9090:9090"
|
||||
|
||||
wait_for_zitadel:
|
||||
image: curlimages/curl:8.00.1
|
||||
command: [ "/bin/sh", "-c", "i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 30 ] && exit 1 || exit 0" ]
|
||||
command:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 30 ] && exit 1 || exit 0",
|
||||
]
|
||||
depends_on:
|
||||
- zitadel
|
||||
|
||||
setup:
|
||||
user: '${ZITADEL_DEV_UID}'
|
||||
user: "${ZITADEL_DEV_UID}"
|
||||
container_name: setup
|
||||
build: .
|
||||
environment:
|
||||
KEY: /key/zitadel-admin-sa.json
|
||||
SERVICE: http://zitadel:8080
|
||||
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
|
||||
WRITE_ENVIRONMENT_FILE: /apps/login/.env.acceptance
|
||||
volumes:
|
||||
- "./machinekey:/key"
|
||||
- "../apps/login:/apps/login"
|
||||
- "./machinekey:/key"
|
||||
- "../apps/login:/apps/login"
|
||||
depends_on:
|
||||
wait_for_zitadel:
|
||||
condition: 'service_completed_successfully'
|
||||
condition: "service_completed_successfully"
|
||||
|
||||
@@ -22,7 +22,6 @@ async function loadSessions(): Promise<Session[]> {
|
||||
|
||||
export default async function Page() {
|
||||
let sessions = await loadSessions();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Accounts</h1>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Error({ error, reset }: any) {
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Boundary labels={["Home page Error UI"]} color="red">
|
||||
<Boundary labels={["Login Error"]} color="red">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-red-500 dark:text-red-500">
|
||||
<strong className="font-bold">Error:</strong> {error?.message}
|
||||
|
||||
72
apps/login/app/(login)/passkey/add/page.tsx
Normal file
72
apps/login/app/(login)/passkey/add/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getSession, server } from "#/lib/zitadel";
|
||||
import Alert, { AlertType } from "#/ui/Alert";
|
||||
import RegisterPasskey from "#/ui/RegisterPasskey";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { loginName, prompt } = searchParams;
|
||||
|
||||
const sessionFactors = await loadSession(loginName);
|
||||
|
||||
async function loadSession(loginName?: string) {
|
||||
const recent = await getMostRecentCookieWithLoginname(loginName);
|
||||
return getSession(server, recent.id, recent.token).then((response) => {
|
||||
if (response?.session) {
|
||||
return response.session;
|
||||
}
|
||||
});
|
||||
}
|
||||
const title = !!prompt
|
||||
? "Authenticate with a passkey"
|
||||
: "Use your passkey to confirm it's really you";
|
||||
const description = !!prompt
|
||||
? "When set up, you will be able to authenticate without a password."
|
||||
: "Your device will ask for your fingerprint, face, or screen lock";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>{title}</h1>
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
></UserAvatar>
|
||||
)}
|
||||
<p className="ztdl-p mb-6 block">{description}</p>
|
||||
|
||||
<Alert type={AlertType.INFO}>
|
||||
<span>
|
||||
A passkey is an authentication method on a device like your
|
||||
fingerprint, Apple FaceID or similar.
|
||||
<a
|
||||
className="text-primary-light-500 dark:text-primary-dark-500 hover:text-primary-light-300 hover:dark:text-primary-dark-300"
|
||||
target="_blank"
|
||||
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
|
||||
>
|
||||
Passwordless Authentication
|
||||
</a>
|
||||
</span>
|
||||
</Alert>
|
||||
|
||||
{!sessionFactors && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
Could not get the context of the user. Make sure to enter the
|
||||
username first or provide a loginName as searchParam.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors?.id && (
|
||||
<RegisterPasskey sessionId={sessionFactors.id} isPrompt={!!prompt} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/login/app/(login)/passkey/page.tsx
Normal file
46
apps/login/app/(login)/passkey/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getSession, server } from "#/lib/zitadel";
|
||||
import Alert from "#/ui/Alert";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { loginName } = searchParams;
|
||||
const sessionFactors = await loadSession(loginName);
|
||||
|
||||
async function loadSession(loginName?: string) {
|
||||
const recent = await getMostRecentCookieWithLoginname(loginName);
|
||||
|
||||
return getSession(server, recent.id, recent.token).then((response) => {
|
||||
if (response?.session) {
|
||||
return response.session;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Login with Passkey</h1>
|
||||
<p className="ztdl-p mb-6 block">Authenticate with your passkey device</p>
|
||||
{!sessionFactors && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
Could not get the context of the user. Make sure to enter the
|
||||
username first or provide a loginName as searchParam.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Password</h1>
|
||||
<p className="ztdl-p mb-6 block">Enter your password.</p>
|
||||
|
||||
<UserAvatar
|
||||
showDropdown
|
||||
displayName="Max Peintner"
|
||||
loginName="max@zitadel.com"
|
||||
></UserAvatar>
|
||||
|
||||
<div className="w-full">
|
||||
<TextInput type="password" label="Password" />
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button variant={ButtonVariants.Primary}>continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default async function Page({
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName ?? ""}
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
></UserAvatar>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ClientError } from "nice-grpc";
|
||||
|
||||
type Props = {
|
||||
userId?: string;
|
||||
isMe?: boolean;
|
||||
userState?: any; // UserState;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [passwordLoading, setPasswordLoading] = useState<boolean>(false);
|
||||
const [policyValid, setPolicyValid] = useState<boolean>(false);
|
||||
|
||||
type Inputs = {
|
||||
password?: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const { register, handleSubmit, watch, reset, formState } = useForm<Inputs>({
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
shouldUseNativeValidation: true,
|
||||
});
|
||||
|
||||
const { errors, isValid } = formState;
|
||||
|
||||
const watchNewPassword = watch("newPassword", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
async function updatePassword(value: Inputs) {
|
||||
setPasswordLoading(true);
|
||||
|
||||
// const authData: UpdateMyPasswordRequest = {
|
||||
// oldPassword: value.password ?? '',
|
||||
// newPassword: value.newPassword,
|
||||
// };
|
||||
|
||||
const response = await fetch(
|
||||
`/api/user/password/me` +
|
||||
`?${new URLSearchParams({
|
||||
resend: `false`,
|
||||
})}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setPasswordLoading(false);
|
||||
// toast('password.set');
|
||||
// TODO: success info
|
||||
reset();
|
||||
} else {
|
||||
const error = (await response.json()) as ClientError;
|
||||
// toast.error(error.details);
|
||||
// TODO: show error
|
||||
}
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
|
||||
async function sendHumanResetPasswordNotification(userId: string) {
|
||||
// const mgmtData: SendHumanResetPasswordNotificationRequest = {
|
||||
// type: SendHumanResetPasswordNotificationRequest_Type.TYPE_EMAIL,
|
||||
// userId: userId,
|
||||
// };
|
||||
|
||||
const response = await fetch(`/api/user/password/resetlink/${userId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// TODO: success info
|
||||
// toast(t('sendPasswordResetLinkSent'));
|
||||
} else {
|
||||
const error = await response.json();
|
||||
// TODO: show error
|
||||
// toast.error((error as ClientError).details);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-center">Set Password</h1>
|
||||
<p className="text-center my-4 mb-6 text-14px text-input-light-label dark:text-input-dark-label">
|
||||
Enter your new Password according to the requirements listed.
|
||||
</p>
|
||||
<form>
|
||||
<div>
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("password", { required: true })}
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("newPassword", { required: true })}
|
||||
label="New Password"
|
||||
error={errors.newPassword?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 mb-4">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("confirmPassword", {
|
||||
required: true,
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Password</h1>
|
||||
<p className="ztdl-p mb-6 block">Enter your password.</p>
|
||||
|
||||
<UserAvatar
|
||||
showDropdown
|
||||
displayName="Max Peintner"
|
||||
loginName="max@zitadel.com"
|
||||
></UserAvatar>
|
||||
|
||||
<div className="w-full">
|
||||
<TextInput type="password" label="Password" />
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button variant={ButtonVariants.Primary}>continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Password</h1>
|
||||
<p className="ztdl-p mb-6 block">Enter your password.</p>
|
||||
|
||||
<UserAvatar
|
||||
showDropdown
|
||||
displayName="Max Peintner"
|
||||
loginName="max@zitadel.com"
|
||||
></UserAvatar>
|
||||
|
||||
<div className="w-full">
|
||||
<TextInput type="password" label="Password" />
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button variant={ButtonVariants.Primary}>continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,24 +3,46 @@ import {
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
import RegisterFormWithoutPassword from "#/ui/RegisterFormWithoutPassword";
|
||||
import SetPasswordForm from "#/ui/SetPasswordForm";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { firstname, lastname, email } = searchParams;
|
||||
|
||||
const setPassword = !!(firstname && lastname && email);
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
return setPassword ? (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Set Password</h1>
|
||||
<p className="ztdl-p">Set the password for your account</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<SetPasswordForm
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
email={email}
|
||||
firstname={firstname}
|
||||
lastname={lastname}
|
||||
></SetPasswordForm>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
<RegisterFormWithoutPassword
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
></RegisterFormWithoutPassword>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
export default async function Page({ searchParams }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Registration successful</h1>
|
||||
<p className="ztdl-p">You are registered.</p>
|
||||
|
||||
{`userId: ${searchParams["userid"]}`}
|
||||
<Link href="/register">
|
||||
<Button variant={ButtonVariants.Primary}>back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/login/app/(login)/signup/page.tsx
Normal file
27
apps/login/app/(login)/signup/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
getLegalAndSupportSettings,
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/login/app/passkeys/route.ts
Normal file
48
apps/login/app/passkeys/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
createPasskeyRegistrationLink,
|
||||
getSession,
|
||||
registerPasskey,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import { getSessionCookieById } from "#/utils/cookies";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (body) {
|
||||
const { sessionId } = body;
|
||||
|
||||
const sessionCookie = await getSessionCookieById(sessionId);
|
||||
|
||||
const session = await getSession(
|
||||
server,
|
||||
sessionCookie.id,
|
||||
sessionCookie.token
|
||||
);
|
||||
|
||||
const domain: string = request.nextUrl.hostname;
|
||||
|
||||
const userId = session?.session?.factors?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
return createPasskeyRegistrationLink(userId)
|
||||
.then((resp) => {
|
||||
const code = resp.code;
|
||||
return registerPasskey(userId, code, domain).then((resp) => {
|
||||
return NextResponse.json(resp);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("error on creating passkey registration link");
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ details: "could not get session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({}, { status: 400 });
|
||||
}
|
||||
}
|
||||
49
apps/login/app/passkeys/verify/route.ts
Normal file
49
apps/login/app/passkeys/verify/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getSession, server, verifyPasskeyRegistration } from "#/lib/zitadel";
|
||||
import { getSessionCookieById } from "#/utils/cookies";
|
||||
import { NextRequest, NextResponse, userAgent } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (body) {
|
||||
let { passkeyId, passkeyName, publicKeyCredential, sessionId } = body;
|
||||
|
||||
if (!!!passkeyName) {
|
||||
const { browser, device, os } = userAgent(request);
|
||||
passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${
|
||||
device.vendor || device.model ? ", " : ""
|
||||
}${os.name}${os.name ? ", " : ""}${browser.name}`;
|
||||
}
|
||||
const sessionCookie = await getSessionCookieById(sessionId);
|
||||
|
||||
const session = await getSession(
|
||||
server,
|
||||
sessionCookie.id,
|
||||
sessionCookie.token
|
||||
);
|
||||
|
||||
const userId = session?.session?.factors?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
return verifyPasskeyRegistration(
|
||||
server,
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
publicKeyCredential,
|
||||
userId
|
||||
)
|
||||
.then((resp) => {
|
||||
return NextResponse.json(resp);
|
||||
})
|
||||
.catch((error) => {
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ details: "could not get session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({}, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,18 @@ export async function POST(request: NextRequest) {
|
||||
if (body) {
|
||||
const { email, password, firstName, lastName } = body;
|
||||
|
||||
const userId = await addHumanUser(server, {
|
||||
return addHumanUser(server, {
|
||||
email: email,
|
||||
firstName,
|
||||
lastName,
|
||||
password: password,
|
||||
});
|
||||
return NextResponse.json({ userId });
|
||||
password: password ? password : undefined,
|
||||
})
|
||||
.then((userId) => {
|
||||
return NextResponse.json({ userId });
|
||||
})
|
||||
.catch((error) => {
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.error();
|
||||
}
|
||||
|
||||
@@ -18,9 +18,17 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (body) {
|
||||
const { loginName } = body;
|
||||
const { loginName, password } = body;
|
||||
|
||||
const domain: string = request.nextUrl.hostname;
|
||||
|
||||
const createdSession = await createSession(
|
||||
server,
|
||||
loginName,
|
||||
password,
|
||||
domain
|
||||
);
|
||||
|
||||
const createdSession = await createSession(server, loginName);
|
||||
if (createdSession) {
|
||||
return getSession(
|
||||
server,
|
||||
@@ -35,7 +43,10 @@ export async function POST(request: NextRequest) {
|
||||
loginName: response.session?.factors?.user?.loginName ?? "",
|
||||
};
|
||||
return addSessionToCookie(sessionCookie).then(() => {
|
||||
return NextResponse.json({ factors: response?.session?.factors });
|
||||
return NextResponse.json({
|
||||
sessionId: createdSession.sessionId,
|
||||
factors: response?.session?.factors,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -23,36 +23,11 @@ export const demos: { name: string; items: Item[] }[] = [
|
||||
slug: "accounts",
|
||||
description: "List active and inactive sessions",
|
||||
},
|
||||
// {
|
||||
// name: "Set Password",
|
||||
// slug: "password/set",
|
||||
// description: "The page to set a users password",
|
||||
// },
|
||||
// {
|
||||
// name: "MFA",
|
||||
// slug: "mfa",
|
||||
// description: "The page to request a users mfa method",
|
||||
// },
|
||||
// {
|
||||
// name: "MFA Set",
|
||||
// slug: "mfa/set",
|
||||
// description: "The page to set a users mfa method",
|
||||
// },
|
||||
// {
|
||||
// name: "MFA Create",
|
||||
// slug: "mfa/create",
|
||||
// description: "The page to create a users mfa method",
|
||||
// },
|
||||
// {
|
||||
// name: "Passwordless",
|
||||
// slug: "passwordless",
|
||||
// description: "The page to login a user with his passwordless device",
|
||||
// },
|
||||
// {
|
||||
// name: "Passwordless Create",
|
||||
// slug: "passwordless/create",
|
||||
// description: "The page to add a users passwordless device",
|
||||
// },
|
||||
{
|
||||
name: "Passkey Registration",
|
||||
slug: "passkey/add",
|
||||
description: "The page to add a users passkey device",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
VerifyEmailResponse,
|
||||
SetSessionResponse,
|
||||
DeleteSessionResponse,
|
||||
VerifyPasskeyRegistrationResponse,
|
||||
} from "@zitadel/server";
|
||||
|
||||
export const zitadelConfig: ZitadelServerOptions = {
|
||||
@@ -35,7 +36,7 @@ if (!getServers().length) {
|
||||
server = initializeServer(zitadelConfig);
|
||||
}
|
||||
|
||||
export function getBrandingSettings(
|
||||
export async function getBrandingSettings(
|
||||
server: ZitadelServer
|
||||
): Promise<BrandingSettings | undefined> {
|
||||
const settingsService = settings.getSettings(server);
|
||||
@@ -44,7 +45,7 @@ export function getBrandingSettings(
|
||||
.then((resp: GetBrandingSettingsResponse) => resp.settings);
|
||||
}
|
||||
|
||||
export function getGeneralSettings(
|
||||
export async function getGeneralSettings(
|
||||
server: ZitadelServer
|
||||
): Promise<string[] | undefined> {
|
||||
const settingsService = settings.getSettings(server);
|
||||
@@ -53,7 +54,7 @@ export function getGeneralSettings(
|
||||
.then((resp: GetGeneralSettingsResponse) => resp.supportedLanguages);
|
||||
}
|
||||
|
||||
export function getLegalAndSupportSettings(
|
||||
export async function getLegalAndSupportSettings(
|
||||
server: ZitadelServer
|
||||
): Promise<LegalAndSupportSettings | undefined> {
|
||||
const settingsService = settings.getSettings(server);
|
||||
@@ -64,7 +65,7 @@ export function getLegalAndSupportSettings(
|
||||
});
|
||||
}
|
||||
|
||||
export function getPasswordComplexitySettings(
|
||||
export async function getPasswordComplexitySettings(
|
||||
server: ZitadelServer
|
||||
): Promise<PasswordComplexitySettings | undefined> {
|
||||
const settingsService = settings.getSettings(server);
|
||||
@@ -74,15 +75,22 @@ export function getPasswordComplexitySettings(
|
||||
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
|
||||
}
|
||||
|
||||
export function createSession(
|
||||
export async function createSession(
|
||||
server: ZitadelServer,
|
||||
loginName: string
|
||||
loginName: string,
|
||||
password: string | undefined,
|
||||
domain: string
|
||||
): Promise<CreateSessionResponse | undefined> {
|
||||
const sessionService = session.getSession(server);
|
||||
return sessionService.createSession({ checks: { user: { loginName } } }, {});
|
||||
return password
|
||||
? sessionService.createSession(
|
||||
{ checks: { user: { loginName }, password: { password } }, domain },
|
||||
{}
|
||||
)
|
||||
: sessionService.createSession({ checks: { user: { loginName } } }, {});
|
||||
}
|
||||
|
||||
export function setSession(
|
||||
export async function setSession(
|
||||
server: ZitadelServer,
|
||||
sessionId: string,
|
||||
sessionToken: string,
|
||||
@@ -95,7 +103,7 @@ export function setSession(
|
||||
);
|
||||
}
|
||||
|
||||
export function getSession(
|
||||
export async function getSession(
|
||||
server: ZitadelServer,
|
||||
sessionId: string,
|
||||
sessionToken: string
|
||||
@@ -104,7 +112,7 @@ export function getSession(
|
||||
return sessionService.getSession({ sessionId, sessionToken }, {});
|
||||
}
|
||||
|
||||
export function deleteSession(
|
||||
export async function deleteSession(
|
||||
server: ZitadelServer,
|
||||
sessionId: string,
|
||||
sessionToken: string
|
||||
@@ -113,7 +121,7 @@ export function deleteSession(
|
||||
return sessionService.deleteSession({ sessionId, sessionToken }, {});
|
||||
}
|
||||
|
||||
export function listSessions(
|
||||
export async function listSessions(
|
||||
server: ZitadelServer,
|
||||
ids: string[]
|
||||
): Promise<ListSessionsResponse | undefined> {
|
||||
@@ -127,22 +135,28 @@ export type AddHumanUserData = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password: string | undefined;
|
||||
};
|
||||
|
||||
export function addHumanUser(
|
||||
export async function addHumanUser(
|
||||
server: ZitadelServer,
|
||||
{ email, firstName, lastName, password }: AddHumanUserData
|
||||
): Promise<string> {
|
||||
const mgmt = user.getUser(server);
|
||||
|
||||
const payload = {
|
||||
email: { email },
|
||||
username: email,
|
||||
profile: { firstName, lastName },
|
||||
};
|
||||
return mgmt
|
||||
.addHumanUser(
|
||||
{
|
||||
email: { email },
|
||||
username: email,
|
||||
profile: { firstName, lastName },
|
||||
password: { password },
|
||||
},
|
||||
password
|
||||
? {
|
||||
...payload,
|
||||
password: { password },
|
||||
}
|
||||
: payload,
|
||||
{}
|
||||
)
|
||||
.then((resp: AddHumanUserResponse) => {
|
||||
@@ -150,7 +164,7 @@ export function addHumanUser(
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyEmail(
|
||||
export async function verifyEmail(
|
||||
server: ZitadelServer,
|
||||
userId: string,
|
||||
verificationCode: string
|
||||
@@ -171,7 +185,10 @@ export function verifyEmail(
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export function setEmail(server: ZitadelServer, userId: string): Promise<any> {
|
||||
export async function setEmail(
|
||||
server: ZitadelServer,
|
||||
userId: string
|
||||
): Promise<any> {
|
||||
const userservice = user.getUser(server);
|
||||
return userservice.setEmail(
|
||||
{
|
||||
@@ -181,4 +198,71 @@ export function setEmail(server: ZitadelServer, userId: string): Promise<any> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param server
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export async function createPasskeyRegistrationLink(
|
||||
userId: string
|
||||
): Promise<any> {
|
||||
const userservice = user.getUser(server);
|
||||
|
||||
return userservice.createPasskeyRegistrationLink({
|
||||
userId,
|
||||
returnCode: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param server
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export async function verifyPasskeyRegistration(
|
||||
server: ZitadelServer,
|
||||
passkeyId: string,
|
||||
passkeyName: string,
|
||||
publicKeyCredential:
|
||||
| {
|
||||
[key: string]: any;
|
||||
}
|
||||
| undefined,
|
||||
userId: string
|
||||
): Promise<VerifyPasskeyRegistrationResponse> {
|
||||
const userservice = user.getUser(server);
|
||||
return userservice.verifyPasskeyRegistration(
|
||||
{
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
|
||||
publicKeyCredential,
|
||||
userId,
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param server
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export async function registerPasskey(
|
||||
userId: string,
|
||||
code: { id: string; code: string },
|
||||
domain: string
|
||||
): Promise<any> {
|
||||
const userservice = user.getUser(server);
|
||||
return userservice.registerPasskey({
|
||||
userId,
|
||||
code,
|
||||
domain,
|
||||
// authenticator:
|
||||
});
|
||||
}
|
||||
|
||||
export { server };
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@layer base {
|
||||
h1,
|
||||
.ztdl-h1 {
|
||||
@apply text-2xl;
|
||||
@apply text-2xl text-center;
|
||||
}
|
||||
|
||||
.ztdl-p {
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
type?: AlertType;
|
||||
};
|
||||
|
||||
export default function Alert({ children }: Props) {
|
||||
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 default function Alert({ children, type = AlertType.ALERT }: Props) {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center border 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 rounded-md py-2 scroll-px-40">
|
||||
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
<span className="text-center text-sm">{children}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-row items-center justify-center border rounded-md py-2 pr-2 scroll-px-40",
|
||||
{
|
||||
[yellow]: type === AlertType.ALERT,
|
||||
[neutral]: type === AlertType.INFO,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{type === AlertType.ALERT && (
|
||||
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
)}
|
||||
{type === AlertType.INFO && (
|
||||
<InformationCircleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
)}
|
||||
<span className="text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
|
||||
export const methods = [
|
||||
{
|
||||
name: "Passkeys",
|
||||
description: "Authenticate with your device.",
|
||||
},
|
||||
{
|
||||
name: "Password",
|
||||
description: "Authenticate with a password",
|
||||
},
|
||||
];
|
||||
|
||||
export default 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="grid grid-cols-2 space-x-2">
|
||||
{methods.map((method) => (
|
||||
<RadioGroup.Option
|
||||
key={method.name}
|
||||
value={method}
|
||||
className={({ active, checked }) =>
|
||||
`${
|
||||
active
|
||||
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
|
||||
: "h-full "
|
||||
}
|
||||
${
|
||||
checked
|
||||
? "bg-background-light-400 dark:bg-background-dark-400"
|
||||
: "bg-background-light-400 dark:bg-background-dark-400"
|
||||
}
|
||||
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10`
|
||||
}
|
||||
>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm">
|
||||
<RadioGroup.Label
|
||||
as="p"
|
||||
className={`font-medium ${checked ? "" : ""}`}
|
||||
>
|
||||
{method.name}
|
||||
</RadioGroup.Label>
|
||||
<RadioGroup.Description
|
||||
as="span"
|
||||
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
|
||||
checked ? "" : ""
|
||||
}`}
|
||||
>
|
||||
{method.description}
|
||||
<span aria-hidden="true">·</span>{" "}
|
||||
</RadioGroup.Description>
|
||||
</div>
|
||||
</div>
|
||||
{checked && (
|
||||
<div className="shrink-0 text-white">
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon(props: any) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<circle
|
||||
className="fill-current text-black/50 dark:text-white/50"
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={12}
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M7 13l3 3 7-7"
|
||||
className="stroke-black dark:stroke-white"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,17 @@ export default function PasswordForm({ loginName }: Props) {
|
||||
|
||||
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
return submitPassword(value).then((resp: any) => {
|
||||
return router.push(`/accounts`);
|
||||
if (resp.factors && !resp.factors.passwordless) {
|
||||
return router.push(
|
||||
`/passkey/add?` +
|
||||
new URLSearchParams({
|
||||
loginName: resp.factors.user.loginName,
|
||||
prompt: "true",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return router.push(`/accounts`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
171
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal file
171
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { LegalAndSupportSettings } from "@zitadel/server";
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { TextInput } from "./Input";
|
||||
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
import AuthenticationMethodRadio, {
|
||||
methods,
|
||||
} from "./AuthenticationMethodRadio";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
legal: LegalAndSupportSettings;
|
||||
};
|
||||
|
||||
export default function RegisterFormWithoutPassword({ legal }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [selected, setSelected] = useState(methods[0]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitAndRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/registeruser", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
firstName: values.firstname,
|
||||
lastName: values.lastname,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to register user");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createSessionWithLoginName(loginName: string) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
loginName: loginName,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to set user");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function submitAndContinue(
|
||||
value: Inputs,
|
||||
withPassword: boolean = false
|
||||
) {
|
||||
return withPassword
|
||||
? router.push(`/register?` + new URLSearchParams(value))
|
||||
: submitAndRegister(value).then((resp: any) => {
|
||||
createSessionWithLoginName(value.email).then(({ factors }) => {
|
||||
return router.push(
|
||||
`/passkey/add?` +
|
||||
new URLSearchParams({ loginName: factors.user.loginName })
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="firstname"
|
||||
autoComplete="firstname"
|
||||
required
|
||||
{...register("firstname", { required: "This field is required" })}
|
||||
label="First name"
|
||||
error={errors.firstname?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="lastname"
|
||||
autoComplete="lastname"
|
||||
required
|
||||
{...register("lastname", { required: "This field is required" })}
|
||||
label="Last name"
|
||||
error={errors.lastname?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextInput
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
{...register("email", { required: "This field is required" })}
|
||||
label="E-mail"
|
||||
error={errors.email?.message as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legal && (
|
||||
<PrivacyPolicyCheckboxes
|
||||
legal={legal}
|
||||
onChange={setTosAndPolicyAccepted}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="mt-4 ztdl-p mb-6 block text-text-light-secondary-500 dark:text-text-dark-secondary-500">
|
||||
Select the method you would like to authenticate
|
||||
</p>
|
||||
|
||||
<div className="pb-4">
|
||||
<AuthenticationMethodRadio
|
||||
selected={selected}
|
||||
selectionChanged={setSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
|
||||
onClick={handleSubmit((values) =>
|
||||
submitAndContinue(values, selected === methods[0] ? false : true)
|
||||
)}
|
||||
>
|
||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||
continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
206
apps/login/ui/RegisterPasskey.tsx
Normal file
206
apps/login/ui/RegisterPasskey.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
import Alert from "./Alert";
|
||||
import { RegisterPasskeyResponse } from "@zitadel/server";
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
|
||||
type Inputs = {};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
isPrompt: boolean;
|
||||
};
|
||||
|
||||
export default function RegisterPasskey({ sessionId, isPrompt }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitRegister() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const res = await fetch("/passkeys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await res.json();
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
setError(response.details);
|
||||
return Promise.reject(response.details);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function submitVerify(
|
||||
passkeyId: string,
|
||||
passkeyName: string,
|
||||
publicKeyCredential: any,
|
||||
sessionId: string
|
||||
) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/passkeys/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
publicKeyCredential,
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await res.json();
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
setError(response.details);
|
||||
return Promise.reject(response.details);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function submitRegisterAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
return submitRegister().then((resp: RegisterPasskeyResponse) => {
|
||||
const passkeyId = resp.passkeyId;
|
||||
|
||||
if (
|
||||
resp.publicKeyCredentialCreationOptions &&
|
||||
resp.publicKeyCredentialCreationOptions.publicKey
|
||||
) {
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.challenge =
|
||||
coerceToArrayBuffer(
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.challenge,
|
||||
"challenge"
|
||||
);
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.user.id =
|
||||
coerceToArrayBuffer(
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.user.id,
|
||||
"userid"
|
||||
);
|
||||
if (
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials
|
||||
) {
|
||||
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials.map(
|
||||
(cred: any) => {
|
||||
cred.id = coerceToArrayBuffer(
|
||||
cred.id as string,
|
||||
"excludeCredentials.id"
|
||||
);
|
||||
return cred;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
navigator.credentials
|
||||
.create(resp.publicKeyCredentialCreationOptions)
|
||||
.then((resp) => {
|
||||
if (
|
||||
resp &&
|
||||
(resp as any).response.attestationObject &&
|
||||
(resp as any).response.clientDataJSON &&
|
||||
(resp as any).rawId
|
||||
) {
|
||||
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"
|
||||
),
|
||||
},
|
||||
};
|
||||
return submitVerify(passkeyId, "", data, sessionId).then(() => {
|
||||
router.push("/accounts");
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError("An error on registering passkey");
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
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={() => router.push("/accounts")}
|
||||
>
|
||||
skip
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span className="flex-grow"></span>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid}
|
||||
onClick={handleSubmit(submitRegisterAndContinue)}
|
||||
>
|
||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||
continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function SessionsList({ sessions }: Props) {
|
||||
const [list, setList] = useState<Session[]>(sessions);
|
||||
|
||||
return sessions ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{list
|
||||
.filter((session) => session?.factors?.user?.loginName)
|
||||
.map((session, index) => {
|
||||
|
||||
190
apps/login/ui/SetPasswordForm.tsx
Normal file
190
apps/login/ui/SetPasswordForm.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordComplexitySettings } from "@zitadel/server";
|
||||
import PasswordComplexity from "./PasswordComplexity";
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { TextInput } from "./Input";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "#/utils/validators";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
import Alert from "./Alert";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
};
|
||||
|
||||
export default function SetPasswordForm({
|
||||
passwordComplexitySettings,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/registeruser", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
firstName: firstname,
|
||||
lastName: lastname,
|
||||
password: values.password,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.details);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createSessionWithLoginNameAndPassword(
|
||||
loginName: string,
|
||||
password: string
|
||||
) {
|
||||
const res = await fetch("/session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
loginName: loginName,
|
||||
password: password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to set user");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function submitAndLink(value: Inputs): Promise<boolean | void> {
|
||||
return submitRegister(value)
|
||||
.then((humanResponse: any) => {
|
||||
setError("");
|
||||
return createSessionWithLoginNameAndPassword(
|
||||
email,
|
||||
value.password
|
||||
).then(() => {
|
||||
setLoading(false);
|
||||
return router.push(`/verify?userID=${humanResponse.userId}`);
|
||||
});
|
||||
})
|
||||
.catch((errorDetails: Error) => {
|
||||
setLoading(false);
|
||||
setError(errorDetails.message);
|
||||
});
|
||||
}
|
||||
|
||||
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="pt-4 grid grid-cols-1 gap-4 mb-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
{...register("password", {
|
||||
required: "You have to provide a password!",
|
||||
})}
|
||||
label="Password"
|
||||
error={errors.password?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
{...register("confirmPassword", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
/>
|
||||
</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">
|
||||
<Button type="button" variant={ButtonVariants.Secondary}>
|
||||
back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={
|
||||
loading ||
|
||||
!policyIsValid ||
|
||||
!formState.isValid ||
|
||||
watchPassword !== watchConfirmPassword
|
||||
}
|
||||
onClick={handleSubmit(submitAndLink)}
|
||||
>
|
||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||
continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
loginName: string;
|
||||
loginName?: string;
|
||||
displayName?: string;
|
||||
showDropdown: boolean;
|
||||
};
|
||||
@@ -14,12 +14,12 @@ export default function UserAvatar({
|
||||
showDropdown,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
|
||||
<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}
|
||||
name={displayName ?? loginName ?? ""}
|
||||
loginName={loginName ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-4 text-14px">{loginName}</span>
|
||||
@@ -27,7 +27,7 @@ export default function UserAvatar({
|
||||
{showDropdown && (
|
||||
<Link
|
||||
href="/accounts"
|
||||
className="flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all"
|
||||
className="ml-4 flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
63
apps/login/utils/base64.ts
Normal file
63
apps/login/utils/base64.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export function coerceToBase64Url(thing: any, name: string) {
|
||||
// Array or ArrayBuffer to Uint8Array
|
||||
if (Array.isArray(thing)) {
|
||||
thing = Uint8Array.from(thing);
|
||||
}
|
||||
|
||||
if (thing instanceof ArrayBuffer) {
|
||||
thing = new Uint8Array(thing);
|
||||
}
|
||||
|
||||
// Uint8Array to base64
|
||||
if (thing instanceof Uint8Array) {
|
||||
var str = "";
|
||||
var len = thing.byteLength;
|
||||
|
||||
for (var i = 0; i < len; i++) {
|
||||
str += String.fromCharCode(thing[i]);
|
||||
}
|
||||
thing = window.btoa(str);
|
||||
}
|
||||
|
||||
if (typeof thing !== "string") {
|
||||
throw new Error("could not coerce '" + name + "' to string");
|
||||
}
|
||||
|
||||
// base64 to base64url
|
||||
// NOTE: "=" at the end of challenge is optional, strip it off here
|
||||
thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
|
||||
|
||||
return thing;
|
||||
}
|
||||
|
||||
export function coerceToArrayBuffer(thing: any, name: string) {
|
||||
if (typeof thing === "string") {
|
||||
// base64url to base64
|
||||
thing = thing.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
// base64 to Uint8Array
|
||||
var str = window.atob(thing);
|
||||
var bytes = new Uint8Array(str.length);
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
thing = bytes;
|
||||
}
|
||||
|
||||
// Array to Uint8Array
|
||||
if (Array.isArray(thing)) {
|
||||
thing = new Uint8Array(thing);
|
||||
}
|
||||
|
||||
// Uint8Array to ArrayBuffer
|
||||
if (thing instanceof Uint8Array) {
|
||||
thing = thing.buffer;
|
||||
}
|
||||
|
||||
// error if none of the above worked
|
||||
if (!(thing instanceof ArrayBuffer)) {
|
||||
throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
|
||||
}
|
||||
|
||||
return thing;
|
||||
}
|
||||
@@ -135,7 +135,6 @@ export async function getMostRecentCookieWithLoginname(
|
||||
|
||||
if (stringifiedCookie?.value) {
|
||||
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
|
||||
|
||||
const filtered = sessions.filter((cookie) => {
|
||||
return !!loginName ? cookie.loginName === loginName : true;
|
||||
});
|
||||
@@ -153,10 +152,10 @@ export async function getMostRecentCookieWithLoginname(
|
||||
if (latest) {
|
||||
return latest;
|
||||
} else {
|
||||
return Promise.reject();
|
||||
return Promise.reject("Could not get the context or retrieve a session");
|
||||
}
|
||||
} else {
|
||||
return Promise.reject();
|
||||
return Promise.reject("Could not read session cookie");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-zitadel": "workspace:*",
|
||||
"prettier": "^2.5.1",
|
||||
"turbo": "^1.9.8"
|
||||
"turbo": "^1.10.3"
|
||||
},
|
||||
"packageManager": "pnpm@7.15.0"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as settings from "./v2/settings";
|
||||
import * as session from "./v2/session";
|
||||
import * as user from "./v2/user";
|
||||
import * as management from "./management";
|
||||
|
||||
import * as login from "./proto/server/zitadel/settings/v2alpha/login_settings";
|
||||
import * as password from "./proto/server/zitadel/settings/v2alpha/password_settings";
|
||||
@@ -28,8 +29,17 @@ export {
|
||||
export {
|
||||
AddHumanUserResponse,
|
||||
VerifyEmailResponse,
|
||||
VerifyPasskeyRegistrationRequest,
|
||||
VerifyPasskeyRegistrationResponse,
|
||||
RegisterPasskeyRequest,
|
||||
RegisterPasskeyResponse,
|
||||
CreatePasskeyRegistrationLinkResponse,
|
||||
CreatePasskeyRegistrationLinkRequest,
|
||||
} from "./proto/server/zitadel/user/v2alpha/user_service";
|
||||
|
||||
export {
|
||||
SetHumanPasswordResponse,
|
||||
SetHumanPasswordRequest,
|
||||
} from "./proto/server/zitadel/management";
|
||||
export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2alpha/legal_settings";
|
||||
export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2alpha/password_settings";
|
||||
export { type ResourceOwnerType } from "./proto/server/zitadel/settings/v2alpha/settings";
|
||||
@@ -48,6 +58,7 @@ export {
|
||||
type ZitadelServerOptions,
|
||||
initializeServer,
|
||||
user,
|
||||
management,
|
||||
session,
|
||||
settings,
|
||||
login,
|
||||
|
||||
@@ -25,7 +25,6 @@ const createClient = <Client>(
|
||||
};
|
||||
|
||||
export const getManagement = (app?: string | ZitadelServer) => {
|
||||
console.log("init management");
|
||||
let config;
|
||||
if (app && typeof app === "string") {
|
||||
const apps = getServers();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { createChannel, createClientFactory } from "nice-grpc";
|
||||
import {
|
||||
SettingsServiceClient,
|
||||
SettingsServiceDefinition,
|
||||
} from "./proto/server/zitadel/settings/v2alpha/settings_service";
|
||||
import { authMiddleware } from "./middleware";
|
||||
import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";
|
||||
|
||||
|
||||
3511
pnpm-lock.yaml
generated
3511
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user