Merge branch 'main' into issue-template

This commit is contained in:
Fabi
2024-01-16 10:46:27 +01:00
committed by GitHub
144 changed files with 9362 additions and 2374 deletions

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
### Definition of Ready
- [ ] I am happy with the code
- [ ] Short description of the feature/issue is added in the pr description
- [ ] PR is linked to the corresponding user story
- [ ] Acceptance criteria are met
- [ ] All open todos and follow ups are defined in a new ticket and justified
- [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented.
- [ ] Jest unit tests ensure that components produce expected outputs on different inputs.
- [ ] Cypress integration tests ensure that login app pages work as expected. The ZITADEL API is mocked.
- [ ] No debug or dead code
- [ ] My code has no repetitions

View File

@@ -1,44 +0,0 @@
# name: Release
# on:
# push:
# branches:
# - main
# concurrency: ${{ github.workflow }}-${{ github.ref }}
# jobs:
# release:
# name: Release
# runs-on: ubuntu-latest
# steps:
# - name: Checkout Repo
# uses: actions/checkout@v2
# - name: Setup pnpm 7
# uses: pnpm/action-setup@v2
# with:
# version: 7
# - name: Setup Node.js 16.x
# uses: actions/setup-node@v2
# with:
# node-version: 16.x
# - name: Install Dependencies
# run: pnpm i
# - name: Create Release Pull Request or Publish to npm
# id: changesets
# uses: changesets/action@v1
# with:
# # This expects you to have a script called release which does a build for your packages and calls changeset publish
# publish: pnpm release
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# - name: Send a Slack notification if a publish happens
# if: steps.changesets.outputs.published == 'true'
# # You can do something when a publish happens.
# run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!"

74
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Quality
on: pull_request
jobs:
quality:
name: Ensure Quality
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: "read"
strategy:
fail-fast: false
matrix:
command:
- lint
- test:unit
- test:integration
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup pnpm 7
uses: pnpm/action-setup@v2
with:
version: 7
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- uses: pnpm/action-setup@v2
name: Install pnpm
id: pnpm-install
with:
version: 7
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v3
name: Setup Cypress binary cache
with:
path: ~/.cache/Cypress
key: ${{ runner.os }}-cypress-binary-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-cypress-binary-
if: ${{ matrix.command }} == "test:integration"
- name: Install Dependencies
id: deps
run: pnpm install
- name: Check
id: check
run: pnpm ${{ matrix.command }}

10
.gitignore vendored
View File

@@ -7,13 +7,15 @@ dist
dist-ssr
*.local
.env
apps/login/.env.local.?.bak
apps/login/.env.local.??.bak
apps/login/.env.local
apps/login/.env.acceptance
.cache
server/dist
public/dist
.turbo
packages/zitadel-server/src/app/proto
packages/zitadel-client/src/app/proto
apps/login/.vscode
.vscode
.idea
.vercel
.env*.local

View File

@@ -49,15 +49,18 @@ docker compose --file ./acceptance/docker-compose.yaml pull
# Run ZITADEL and configure ./apps/login/.env.local
docker compose --file ./acceptance/docker-compose.yaml run setup
# Configure your shell to use the environment variables written to ./apps/login/.env.acceptance
export $(cat ./apps/login/.env.acceptance | xargs)
```
### Developing Against Your ZITADEL Cloud Instance
Create the file ./apps/login/.env.local with the following content:
Configure your shell by exporting the following environment variables:
```sh
ZITADEL_API_URL=<your cloud instance URL here>
ZITADEL_ORG_ID=<your service accounts organization id here>
ZITADEL_SERVICE_USER_TOKEN=<your service account personal access token here>
export ZITADEL_API_URL=<your cloud instance URL here>
export ZITADEL_ORG_ID=<your service accounts organization id here>
export ZITADEL_SERVICE_USER_TOKEN=<your service account personal access token here>
```
### Setting up local environment
@@ -75,4 +78,20 @@ pnpm dev
The application is now available at `http://localhost:3000`
### Testing
You can execute the following commands `pnpm test` for a single test run or `pnpm test:watch` in the following directories:
- apps/login
- packages/zitadel-client
- packages/zitadel-server
- packages/zitadel-react
- packages/zitadel-next
- The projects root directory: all tests in the project are executed
In apps/login, these commands also spin up the application and a ZITADEL gRPC API mock server to run integration tests using [Cypress](https://www.cypress.io/) against them.
If you want to run the integration tests standalone against an environment of your choice, navigate to ./apps/login, [configure your shell as you like](# Developing Against Your ZITADEL Cloud Instance) and run `pnpm test:integration:run` or `pnpm test:integration:open`.
Then you need to lifecycle the mock process using the command `pnpm mock` or the more fine grained commands `pnpm mock:build`, `pnpm mock:build:nocache`, `pnpm mock:run` and `pnpm mock:destroy`.
That's it! 🎉

View File

@@ -44,6 +44,8 @@ Each package and app is 100% [TypeScript](https://www.typescriptlang.org/).
- `pnpm generate` - Build proto stubs for server and client package
- `pnpm build` - Build all packages and the login app
- `pnpm test` - Test all packages and the login app
- `pnpm test:watch` - Rerun tests on file change
- `pnpm dev` - Develop all packages and the login app
- `pnpm lint` - Lint all packages
- `pnpm changeset` - Generate a changeset

View File

@@ -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"

View File

View File

@@ -11,7 +11,7 @@ echo "Using audience ${AUDIENCE} for which the key is used."
SERVICE=${SERVICE:-$AUDIENCE}
echo "Using the service ${SERVICE} to connect to ZITADEL. For example in docker compose this can differ from the audience."
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.acceptance}
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
AUDIENCE_HOST="$(echo $AUDIENCE | cut -d/ -f3)"
@@ -44,27 +44,6 @@ echo "${ORG_RESPONSE}" | jq
ORG_ID=$(echo -n ${ORG_RESPONSE} | jq --raw-output '.org.id')
echo "Extracted default org id ${ORG_ID}"
ENVIRONMENT_BACKUP_FILE=${WRITE_ENVIRONMENT_FILE}
# If the original file already exists, rename it
if [[ -e ${WRITE_ENVIRONMENT_FILE} ]]; then
if grep -q 'localhost' ${WRITE_ENVIRONMENT_FILE}; then
echo "Current environment file ${WRITE_ENVIRONMENT_FILE} contains localhost. Overwriting:"
cat ${WRITE_ENVIRONMENT_FILE}
else
i=0
# If a backup file already exists, increment counter until a free filename is found
while [[ -e ${ENVIRONMENT_BACKUP_FILE}.${i}.bak ]]; do
let "i++"
if [[ ${i} -eq 50 ]]; then
echo "Warning: Too many backup files (limit is 50), overwriting ${ENVIRONMENT_BACKUP_FILE}.${i}.bak"
break
fi
done
mv ${WRITE_ENVIRONMENT_FILE} ${ENVIRONMENT_BACKUP_FILE}.${i}.bak
echo "Renamed existing environment file to ${ENVIRONMENT_BACKUP_FILE}.${i}.bak"
fi
fi
echo "ZITADEL_API_URL=${AUDIENCE}
ZITADEL_ORG_ID=${ORG_ID}
ZITADEL_SERVICE_USER_TOKEN=${TOKEN}" > ${WRITE_ENVIRONMENT_FILE}

View File

@@ -0,0 +1 @@
ZITADEL_API_URL=http://localhost:22222

View File

@@ -1,4 +1,4 @@
module.exports = {
extends: "next/core-web-vitals",
extends: ["next/core-web-vitals"],
ignorePatterns: ["external/**/*.ts"],
};

View File

@@ -0,0 +1,57 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import PasswordComplexity from "../ui/PasswordComplexity";
// TODO: Why does this not compile?
// import { ResourceOwnerType } from '@zitadel/server';
const matchesTitle = `Matches`;
const doesntMatchTitle = `Doesn't match`;
describe("<PasswordComplexity/>", () => {
describe.each`
settingsMinLength | password | expectSVGTitle
${5} | ${"Password1!"} | ${matchesTitle}
${30} | ${"Password1!"} | ${doesntMatchTitle}
${0} | ${"Password1!"} | ${matchesTitle}
${undefined} | ${"Password1!"} | ${false}
`(
`With settingsMinLength=$settingsMinLength, password=$password, expectSVGTitle=$expectSVGTitle`,
({ settingsMinLength, password, expectSVGTitle }) => {
const feedbackElementLabel = /password length/i;
beforeEach(() => {
render(
<PasswordComplexity
password={password}
equals
passwordComplexitySettings={{
minLength: settingsMinLength,
requiresLowercase: false,
requiresUppercase: false,
requiresNumber: false,
requiresSymbol: false,
resourceOwnerType: 0, // ResourceOwnerType.RESOURCE_OWNER_TYPE_UNSPECIFIED,
}}
/>
);
});
if (expectSVGTitle === false) {
it(`should not render the feedback element`, async () => {
await waitFor(() => {
expect(
screen.queryByText(feedbackElementLabel)
).not.toBeInTheDocument();
});
});
} else {
it(`Should show one SVG with title ${expectSVGTitle}`, async () => {
await waitFor(async () => {
const svg = within(
screen.getByText(feedbackElementLabel)
.parentElement as HTMLElement
).findByRole("img");
expect(await svg).toHaveTextContent(expectSVGTitle);
});
});
}
}
);
});

View File

@@ -0,0 +1,22 @@
import type { Config } from "@jest/types";
import { pathsToModuleNameMapper } from "ts-jest";
import { compilerOptions } from "../tsconfig.json";
// We make these type imports explicit, so IDEs with their own typescript engine understand them, too.
import type {} from "@testing-library/jest-dom";
export default async (): Promise<Config.InitialOptions> => {
return {
preset: "ts-jest",
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.json" }],
},
setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: "<rootDir>/../",
}),
testEnvironment: "jsdom",
testRegex: "/__test__/.*\\.test\\.tsx?$",
modulePathIgnorePatterns: ["cypress"],
};
};

View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsxdev",
"types": ["node", "jest", "@testing-library/jest-dom"]
}
}

View File

@@ -1,14 +1,12 @@
import { Session } from "#/../../packages/zitadel-server/dist";
import { Session } from "@zitadel/server";
import { listSessions, server } from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import { Avatar } from "#/ui/Avatar";
import { getAllSessionIds } from "#/utils/cookies";
import { UserPlusIcon, XCircleIcon } from "@heroicons/react/24/outline";
import moment from "moment";
import { getAllSessionCookieIds } from "#/utils/cookies";
import { UserPlusIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import SessionsList from "#/ui/SessionsList";
async function loadSessions(): Promise<Session[]> {
const ids = await getAllSessionIds();
const ids = await getAllSessionCookieIds();
if (ids && ids.length) {
const response = await listSessions(
@@ -22,8 +20,14 @@ async function loadSessions(): Promise<Session[]> {
}
}
export default async function Page() {
const sessions = await loadSessions();
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const authRequestId = searchParams?.authRequestId;
let sessions = await loadSessions();
return (
<div className="flex flex-col items-center space-y-4">
@@ -31,66 +35,8 @@ export default async function Page() {
<p className="ztdl-p mb-6 block">Use your ZITADEL Account</p>
<div className="flex flex-col w-full space-y-2">
{sessions ? (
sessions
.filter((session) => session?.factors?.user?.loginName)
.map((session, index) => {
const validPassword = session?.factors?.password?.verifiedAt;
return (
<Link
key={"session-" + index}
href={
validPassword
? `/signedin?` +
new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
})
: `/password?` +
new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
})
}
className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all"
>
<div className="pr-4">
<Avatar
size="small"
loginName={session.factors?.user?.loginName as string}
name={session.factors?.user?.displayName ?? ""}
/>
</div>
<div className="flex flex-col">
<span className="">
{session.factors?.user?.displayName}
</span>
<span className="text-xs opacity-80">
{session.factors?.user?.loginName}
</span>
{validPassword && (
<span className="text-xs opacity-80">
{moment(new Date(validPassword)).fromNow()}
</span>
)}
</div>
<span className="flex-grow"></span>
<div className="relative flex flex-row items-center">
{validPassword ? (
<div className="absolute h-2 w-2 bg-green-500 rounded-full mx-2 transform right-0 group-hover:right-6 transition-all"></div>
) : (
<div className="absolute h-2 w-2 bg-red-500 rounded-full mx-2 transform right-0 group-hover:right-6 transition-all"></div>
)}
<XCircleIcon className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100" />
</div>
</Link>
);
})
) : (
<Alert>No Sessions available!</Alert>
)}
<Link href="/username">
<SessionsList sessions={sessions} authRequestId={authRequestId} />
<Link href="/loginname">
<div className="flex flex-row items-center py-3 px-4 hover:bg-black/10 dark:hover:bg-white/10 rounded-md transition-all">
<div className="w-8 h-8 mr-4 flex flex-row justify-center items-center rounded-full bg-black/5 dark:bg-white/5">
<UserPlusIcon className="h-5 w-5" />

View File

@@ -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}

View File

@@ -0,0 +1,122 @@
import {
createCallback,
getAuthRequest,
listSessions,
server,
} from "#/lib/zitadel";
import { SessionCookie, getAllSessions } from "#/utils/cookies";
import { Session, AuthRequest, Prompt } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server";
async function loadSessions(ids: string[]): Promise<Session[]> {
const response = await listSessions(
server,
ids.filter((id: string | undefined) => !!id)
);
return response?.sessions ?? [];
}
function findSession(
sessions: Session[],
authRequest: AuthRequest
): Session | undefined {
if (authRequest.hintUserId) {
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
}
if (authRequest.loginHint) {
console.log(`find session for loginHint: ${authRequest.loginHint}`);
return sessions.find(
(s) => s.factors?.user?.loginName === authRequest.loginHint
);
}
return undefined;
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const authRequestId = searchParams.get("authRequest");
if (authRequestId) {
const { authRequest } = await getAuthRequest(server, { authRequestId });
const sessionCookies: SessionCookie[] = await getAllSessions();
const ids = sessionCookies.map((s) => s.id);
let sessions: Session[] = [];
if (ids && ids.length) {
sessions = await loadSessions(ids);
} else {
console.info("No session cookie found.");
sessions = [];
}
// use existing session and hydrate it for oidc
if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set
if (
authRequest &&
authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)
) {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
} else {
// check for loginHint, userId hint sessions
let selectedSession = findSession(sessions, authRequest);
// if (!selectedSession) {
// selectedSession = sessions[0]; // TODO: remove
// }
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
const { callbackUrl } = await createCallback(server, {
authRequestId,
session,
});
return NextResponse.redirect(callbackUrl);
} else {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
}
} else {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
// return NextResponse.error();
}
}
} else {
const loginNameUrl = new URL("/loginname", request.url);
if (authRequest?.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
loginNameUrl.searchParams.set("submit", "true"); // autosubmit
}
}
return NextResponse.redirect(loginNameUrl);
}
} else {
return NextResponse.error();
}
}

View File

@@ -0,0 +1,28 @@
import { getLoginSettings, server } from "#/lib/zitadel";
import UsernameForm from "#/ui/UsernameForm";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const loginName = searchParams?.loginName;
const authRequestId = searchParams?.authRequestId;
const submit: boolean = searchParams?.submit === "true";
const loginSettings = await getLoginSettings(server);
return (
<div className="flex flex-col items-center space-y-4">
<h1>Welcome back!</h1>
<p className="ztdl-p">Enter your login data.</p>
<UsernameForm
loginSettings={loginSettings}
loginName={loginName}
authRequestId={authRequestId}
submit={submit}
/>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,57 @@
import { getSession, server } from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import LoginPasskey from "#/ui/LoginPasskey";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
const title = "Authenticate with a passkey";
const description =
"Your device will ask for your fingerprint, face, or screen lock";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, altPassword, authRequestId } = 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>{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>
{!sessionFactors && <div className="py-4"></div>}
{!loginName && (
<Alert>Provide your active session as loginName param</Alert>
)}
{loginName && (
<LoginPasskey
loginName={loginName}
authRequestId={authRequestId}
altPassword={altPassword === "true"}
/>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -9,7 +9,7 @@ export default async function Page({
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName } = searchParams;
const { loginName, promptPasswordless, authRequestId, alt } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {
@@ -38,13 +38,18 @@ 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>
)}
<PasswordForm loginName={loginName} />
<PasswordForm
loginName={loginName}
authRequestId={authRequestId}
promptPasswordless={promptPasswordless === "true"}
isAlternative={alt === "true"}
/>
</div>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,136 @@
import { ProviderSlug } from "#/lib/demos";
import { server } from "#/lib/zitadel";
import Alert, { AlertType } from "#/ui/Alert";
import {
AddHumanUserRequest,
IDPInformation,
RetrieveIdentityProviderIntentResponse,
user,
IDPLink,
} from "@zitadel/server";
const PROVIDER_MAPPING: {
[provider: string]: (rI: IDPInformation) => Partial<AddHumanUserRequest>;
} = {
[ProviderSlug.GOOGLE]: (idp: IDPInformation) => {
const idpLink: IDPLink = {
idpId: idp.idpId,
userId: idp.userId,
userName: idp.userName,
};
const req: Partial<AddHumanUserRequest> = {
username: idp.userName,
email: {
email: idp.rawInformation?.User?.email,
isVerified: true,
},
// organisation: Organisation | undefined;
profile: {
displayName: idp.rawInformation?.User?.name ?? "",
givenName: idp.rawInformation?.User?.given_name ?? "",
familyName: idp.rawInformation?.User?.family_name ?? "",
},
idpLinks: [idpLink],
};
return req;
},
[ProviderSlug.GITHUB]: (idp: IDPInformation) => {
const idpLink: IDPLink = {
idpId: idp.idpId,
userId: idp.userId,
userName: idp.userName,
};
const req: Partial<AddHumanUserRequest> = {
username: idp.userName,
email: {
email: idp.rawInformation?.email,
isVerified: true,
},
// organisation: Organisation | undefined;
profile: {
displayName: idp.rawInformation?.name ?? "",
givenName: idp.rawInformation?.name ?? "",
familyName: idp.rawInformation?.name ?? "",
},
idpLinks: [idpLink],
};
return req;
},
};
function retrieveIDP(
id: string,
token: string
): Promise<IDPInformation | undefined> {
const userService = user.getUser(server);
return userService
.retrieveIdentityProviderIntent(
{ idpIntentId: id, idpIntentToken: token },
{}
)
.then((resp: RetrieveIdentityProviderIntentResponse) => {
return resp.idpInformation;
});
}
function createUser(
provider: ProviderSlug,
info: IDPInformation
): Promise<string> {
const userData = PROVIDER_MAPPING[provider](info);
const userService = user.getUser(server);
return userService.addHumanUser(userData, {}).then((resp) => resp.userId);
}
export default async function Page({
searchParams,
params,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: { provider: ProviderSlug };
}) {
const { id, token } = searchParams;
const { provider } = params;
if (provider && id && token) {
return retrieveIDP(id, token)
.then((information) => {
if (information) {
return createUser(provider, information).catch((error) => {
throw new Error(error.details);
});
} else {
throw new Error("Could not get user information.");
}
})
.then((userId) => {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register successful</h1>
<div>You have successfully been registered!</div>
</div>
);
})
.catch((error: Error) => {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register failed</h1>
<div className="w-full">
{
<Alert type={AlertType.ALERT}>
{JSON.stringify(error.message)}
</Alert>
}
</div>
</div>
);
});
} else {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">No id and token received!</p>
</div>
);
}
}

View File

@@ -0,0 +1,50 @@
import { getLegalAndSupportSettings, server } from "#/lib/zitadel";
import { SignInWithIDP } from "#/ui/SignInWithIDP";
import {
GetActiveIdentityProvidersResponse,
IdentityProvider,
ZitadelServer,
settings,
} from "@zitadel/server";
function getIdentityProviders(
server: ZitadelServer,
orgId?: string
): Promise<IdentityProvider[] | undefined> {
const settingsService = settings.getSettings(server);
return settingsService
.getActiveIdentityProviders(
orgId ? { ctx: { orgId } } : { ctx: { instance: true } },
{}
)
.then((resp: GetActiveIdentityProvidersResponse) => {
return resp.identityProviders;
});
}
export default async function Page() {
const legal = await getLegalAndSupportSettings(server);
// TODO if org idps should be shown replace emptystring with the orgId.
const identityProviders = await getIdentityProviders(server, "");
const host = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">
Select one of the following providers to register
</p>
{legal && identityProviders && process.env.ZITADEL_API_URL && (
<SignInWithIDP
host={host}
identityProviders={identityProviders}
></SignInWithIDP>
)}
</div>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -1,10 +1,19 @@
import { getSession, server } from "#/lib/zitadel";
import { createCallback, getSession, server } from "#/lib/zitadel";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
import { redirect } from "next/navigation";
async function loadSession(loginName: string) {
async function loadSession(loginName: string, authRequestId?: string) {
const recent = await getMostRecentCookieWithLoginname(`${loginName}`);
if (authRequestId) {
return createCallback(server, {
authRequestId,
session: { sessionId: recent.id, sessionToken: recent.token },
}).then(({ callbackUrl }) => {
return redirect(callbackUrl);
});
}
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
@@ -13,8 +22,8 @@ async function loadSession(loginName: string) {
}
export default async function Page({ searchParams }: { searchParams: any }) {
const { loginName } = searchParams;
const sessionFactors = await loadSession(loginName);
const { loginName, authRequestId } = searchParams;
const sessionFactors = await loadSession(loginName, authRequestId);
return (
<div className="flex flex-col items-center space-y-4">

View 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>
);
}

View File

@@ -1,12 +0,0 @@
import UsernameForm from "#/ui/UsernameForm";
export default function Page() {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Welcome back!</h1>
<p className="ztdl-p">Enter your login data.</p>
<UsernameForm />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { server, startIdentityProviderFlow } from "#/lib/zitadel";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
let { idpId, successUrl, failureUrl } = body;
return startIdentityProviderFlow(server, {
idpId,
urls: {
successUrl,
failureUrl,
},
})
.then((resp) => {
return NextResponse.json(resp);
})
.catch((error) => {
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.json({}, { status: 400 });
}
}

View File

@@ -0,0 +1,39 @@
import { listAuthenticationMethodTypes } from "#/lib/zitadel";
import { createSessionAndUpdateCookie } from "#/utils/session";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName, authRequestId } = body;
return createSessionAndUpdateCookie(
loginName,
undefined,
undefined,
authRequestId
)
.then((session) => {
if (session.factors?.user?.id) {
return listAuthenticationMethodTypes(session.factors?.user?.id)
.then((methods) => {
return NextResponse.json({
authMethodTypes: methods.authMethodTypes,
sessionId: session.id,
factors: session.factors,
});
})
.catch((error) => {
return NextResponse.json(error, { status: 500 });
});
} else {
throw { details: "No user id found in session" };
}
})
.catch((error) => {
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.error();
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,125 @@
import { server, deleteSession } from "#/lib/zitadel";
import {
SessionCookie,
getMostRecentSessionCookie,
getSessionCookieById,
getSessionCookieByLoginName,
removeSessionFromCookie,
} from "#/utils/cookies";
import {
createSessionAndUpdateCookie,
setSessionAndUpdateCookie,
} from "#/utils/session";
import { RequestChallenges } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName, password } = body;
return createSessionAndUpdateCookie(
loginName,
password,
undefined,
undefined
).then((session) => {
return NextResponse.json(session);
});
} else {
return NextResponse.json(
{ details: "Session could not be created" },
{ status: 500 }
);
}
}
/**
*
* @param request password for the most recent session
* @returns the updated most recent Session with the added password
*/
export async function PUT(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName, password, webAuthN, authRequestId } = body;
const challenges: RequestChallenges = body.challenges;
const recentPromise: Promise<SessionCookie> = loginName
? getSessionCookieByLoginName(loginName).catch((error) => {
return Promise.reject(error);
})
: getMostRecentSessionCookie().catch((error) => {
return Promise.reject(error);
});
const domain: string = request.nextUrl.hostname;
if (challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
challenges.webAuthN.domain = domain;
}
return recentPromise
.then((recent) => {
console.log("setsession", webAuthN);
return setSessionAndUpdateCookie(
recent.id,
recent.token,
recent.loginName,
password,
webAuthN,
challenges,
authRequestId
).then((session) => {
return NextResponse.json({
sessionId: session.id,
factors: session.factors,
challenges: session.challenges,
});
});
})
.catch((error) => {
return NextResponse.json({ details: error }, { status: 500 });
});
} else {
return NextResponse.json(
{ details: "Request body is missing" },
{ status: 400 }
);
}
}
/**
*
* @param request id of the session to be deleted
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (id) {
const session = await getSessionCookieById(id);
return deleteSession(server, session.id, session.token)
.then(() => {
return removeSessionFromCookie(session)
.then(() => {
return NextResponse.json({});
})
.catch((error) => {
return NextResponse.json(
{ details: "could not set cookie" },
{ status: 500 }
);
});
})
.catch((error) => {
return NextResponse.json(
{ details: "could not delete session" },
{ status: 500 }
);
});
} else {
return NextResponse.error();
}
}

View File

@@ -8,6 +8,7 @@ import ThemeWrapper from "#/ui/ThemeWrapper";
import { getBrandingSettings } from "#/lib/zitadel";
import { server } from "../lib/zitadel";
import { BrandingSettings } from "@zitadel/server";
import ThemeProvider from "#/ui/ThemeProvider";
const lato = Lato({
weight: ["400", "700", "900"],
@@ -41,29 +42,31 @@ export default async function RootLayout({
<head />
<body>
<ThemeWrapper branding={partial}>
<LayoutProviders>
<div className="h-screen overflow-y-scroll bg-background-light-600 dark:bg-background-dark-600 bg-[url('/grid-light.svg')] dark:bg-[url('/grid-dark.svg')]">
{showNav && <GlobalNav />}
<ThemeProvider>
<LayoutProviders>
<div className="h-screen overflow-y-scroll bg-background-light-600 dark:bg-background-dark-600 bg-[url('/grid-light.svg')] dark:bg-[url('/grid-dark.svg')]">
{showNav && <GlobalNav />}
<div className={`${showNav ? "lg:pl-72" : ""} pb-4`}>
<div className="mx-auto max-w-[440px] space-y-8 pt-20 lg:py-8">
{showNav && (
<div className="rounded-lg bg-vc-border-gradient dark:bg-dark-vc-border-gradient p-px shadow-lg shadow-black/5 dark:shadow-black/20">
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500">
<AddressBar domain={domain} />
<div className={`${showNav ? "lg:pl-72" : ""} pb-4`}>
<div className="mx-auto max-w-[440px] space-y-8 pt-20 lg:py-8">
{showNav && (
<div className="rounded-lg bg-vc-border-gradient dark:bg-dark-vc-border-gradient p-px shadow-lg shadow-black/5 dark:shadow-black/20">
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500">
<AddressBar domain={domain} />
</div>
</div>
</div>
)}
)}
<div className="rounded-lg bg-vc-border-gradient dark:bg-dark-vc-border-gradient p-px shadow-lg shadow-black/5 dark:shadow-black/20 mb-10">
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
{children}
<div className="rounded-lg bg-vc-border-gradient dark:bg-dark-vc-border-gradient p-px shadow-lg shadow-black/5 dark:shadow-black/20 mb-10">
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
{children}
</div>
</div>
</div>
</div>
</div>
</div>
</LayoutProviders>
</LayoutProviders>
</ThemeProvider>
</ThemeWrapper>
<Analytics />
</body>

View File

@@ -1,124 +0,0 @@
import { createSession, getSession, server, setSession } from "#/lib/zitadel";
import {
SessionCookie,
addSessionToCookie,
getMostRecentSessionCookie,
updateSessionCookie,
} from "#/utils/cookies";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName } = body;
const createdSession = await createSession(server, loginName);
if (createdSession) {
return getSession(
server,
createdSession.sessionId,
createdSession.sessionToken
).then((response) => {
if (response?.session && response.session?.factors?.user?.loginName) {
const sessionCookie: SessionCookie = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
changeDate: response.session.changeDate?.toString() ?? "",
loginName: response.session?.factors?.user?.loginName ?? "",
};
return addSessionToCookie(sessionCookie).then(() => {
return NextResponse.json({ factors: response?.session?.factors });
});
} else {
return NextResponse.json(
{
details:
"could not get session or session does not have loginName",
},
{ status: 500 }
);
}
});
} else {
return NextResponse.error();
}
} else {
return NextResponse.json(
{ details: "Session could not be created" },
{ status: 500 }
);
}
}
/**
*
* @param request password for the most recent session
* @returns the updated most recent Session with the added password
*/
export async function PUT(request: NextRequest) {
const body = await request.json();
if (body) {
const { password } = body;
const recent = await getMostRecentSessionCookie();
return setSession(server, recent.id, recent.token, password)
.then((session) => {
if (session) {
const sessionCookie: SessionCookie = {
id: recent.id,
token: session.sessionToken,
changeDate: session.details?.changeDate?.toString() ?? "",
loginName: recent.loginName,
};
return getSession(server, sessionCookie.id, sessionCookie.token).then(
(response) => {
if (
response?.session &&
response.session.factors?.user?.loginName
) {
const { session } = response;
const newCookie: SessionCookie = {
id: sessionCookie.id,
token: sessionCookie.token,
changeDate: session.changeDate?.toString() ?? "",
loginName: session.factors?.user?.loginName ?? "",
};
return updateSessionCookie(sessionCookie.id, newCookie)
.then(() => {
return NextResponse.json({ factors: session.factors });
})
.catch((error) => {
return NextResponse.json(
{ details: "could not set cookie" },
{ status: 500 }
);
});
} else {
return NextResponse.json(
{
details:
"could not get session or session does not have loginName",
},
{ status: 500 }
);
}
}
);
} else {
return NextResponse.json(
{ details: "Session not be set" },
{ status: 500 }
);
}
})
.catch((error) => {
console.error("erasd", error);
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.error();
}
}

2
apps/login/cypress/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
screenshots
videos

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "cypress";
export default defineConfig({
reporter: "list",
e2e: {
baseUrl: "http://localhost:3000",
specPattern: "cypress/integration/**/*.cy.{js,jsx,ts,tsx}",
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,103 @@
import { stub } from "../support/mock";
describe("login", () => {
beforeEach(() => {
stub("zitadel.session.v2beta.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
changeDate: "2023-07-04T07:58:20.126Z",
resourceOwner: "220516472055706145",
},
sessionId: "221394658884845598",
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
stub("zitadel.session.v2beta.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
creationDate: "2023-07-04T07:58:20.026Z",
changeDate: "2023-07-04T07:58:20.126Z",
sequence: 859,
factors: {
user: {
id: "123",
loginName: "john@zitadel.com",
},
password: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
},
},
});
stub("zitadel.settings.v2beta.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
},
},
});
});
describe("password login", () => {
beforeEach(() => {
stub("zitadel.user.v2beta.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [1], // 1 for password authentication
},
});
});
it("should redirect a user with password authentication to /password", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/password");
});
describe("with passkey prompt", () => {
beforeEach(() => {
stub("zitadel.session.v2beta.SessionService", "SetSession", {
data: {
details: {
sequence: 859,
changeDate: "2023-07-04T07:58:20.126Z",
resourceOwner: "220516472055706145",
},
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
});
it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/password");
cy.get('input[type="password"]').focus().type("MyStrongPassword!1");
cy.get('button[type="submit"]').click();
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/passkey/add"
);
});
});
});
describe("passkey login", () => {
beforeEach(() => {
stub("zitadel.user.v2beta.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [2], // 2 for passwordless authentication
},
});
});
it("should redirect a user with passwordless authentication to /passkey/login", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/passkey/login"
);
});
});
});

View File

@@ -0,0 +1,21 @@
import { stub } from "../support/mock";
const IDP_URL = "https://example.com/idp/url";
describe("register idps", () => {
beforeEach(() => {
stub("zitadel.user.v2beta.UserService", "StartIdentityProviderIntent", {
data: {
authUrl: IDP_URL,
},
});
});
it("should redirect the user to the correct url", () => {
cy.visit("/register/idp");
cy.get('button[e2e="google"]').click();
cy.origin(IDP_URL, { args: IDP_URL }, (url) => {
cy.location("href", { timeout: 10_000 }).should("eq", url);
});
});
});

View File

@@ -0,0 +1,22 @@
import { stub } from "../support/mock";
describe("register", () => {
beforeEach(() => {
stub("zitadel.user.v2beta.UserService", "AddHumanUser", {
data: {
userId: "123",
},
});
});
it("should redirect a user who selects passwordless on register to /passkeys/add", () => {
cy.visit("/register");
cy.get('input[autocomplete="firstname"]').focus().type("John");
cy.get('input[autocomplete="lastname"]').focus().type("Doe");
cy.get('input[autocomplete="email"]').focus().type("john@zitadel.com");
cy.get('input[type="checkbox"][value="privacypolicy"]').check();
cy.get('input[type="checkbox"][value="tos"]').check();
cy.get('button[type="submit"]').click();
cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey/add");
});
});

View File

@@ -0,0 +1,19 @@
import { stub } from "../support/mock";
describe("/verify", () => {
it("redirects after successful email verification", () => {
stub("zitadel.user.v2beta.UserService", "VerifyEmail");
cy.visit("/verify?userID=123&code=abc&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/loginname");
});
it("shows an error if validation failed", () => {
stub("zitadel.user.v2beta.UserService", "VerifyEmail", {
code: 3,
error: "error validating code",
});
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userID=123&code=abc&submit=true");
cy.contains("error validating code");
});
});

View File

@@ -0,0 +1,37 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -0,0 +1,27 @@
function removeStub(service: string, method: string) {
return cy.request({
url: "http://localhost:22220/v1/stubs",
method: "DELETE",
qs: {
service,
method,
},
});
}
export function stub(service: string, method: string, out?: any) {
removeStub(service, method);
return cy.request({
url: "http://localhost:22220/v1/stubs",
method: "POST",
body: {
stubs: [
{
service,
method,
out,
},
],
},
});
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress", "node"]
},
"include": ["**/*.ts"]
}

View File

@@ -4,13 +4,18 @@ export type Item = {
description?: string;
};
export enum ProviderSlug {
GOOGLE = "google",
GITHUB = "github",
}
export const demos: { name: string; items: Item[] }[] = [
{
name: "Login",
items: [
{
name: "Username",
slug: "username",
name: "Loginname",
slug: "loginname",
description: "The entrypoint of the application",
},
{
@@ -23,36 +28,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",
},
],
},
{
@@ -63,6 +43,11 @@ export const demos: { name: string; items: Item[] }[] = [
slug: "register",
description: "Create your ZITADEL account",
},
{
name: "IDP Register",
slug: "register/idp",
description: "Register with an Identity Provider",
},
{
name: "Verify email",
slug: "verify",

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
// Custom hook to read auth record and user profile doc
// Custom hook to read auth record and user profile doc
export function useUserData() {
const [clientData, setClientData] = useState(null);

View File

@@ -2,6 +2,7 @@ import {
ZitadelServer,
ZitadelServerOptions,
user,
oidc,
settings,
getServers,
initializeServer,
@@ -19,6 +20,22 @@ import {
GetSessionResponse,
VerifyEmailResponse,
SetSessionResponse,
SetSessionRequest,
DeleteSessionResponse,
VerifyPasskeyRegistrationResponse,
LoginSettings,
GetLoginSettingsResponse,
ListAuthenticationMethodTypesResponse,
StartIdentityProviderIntentRequest,
StartIdentityProviderIntentResponse,
RetrieveIdentityProviderIntentRequest,
RetrieveIdentityProviderIntentResponse,
GetAuthRequestResponse,
GetAuthRequestRequest,
CreateCallbackRequest,
CreateCallbackResponse,
RequestChallenges,
AddHumanUserRequest,
} from "@zitadel/server";
export const zitadelConfig: ZitadelServerOptions = {
@@ -34,7 +51,7 @@ if (!getServers().length) {
server = initializeServer(zitadelConfig);
}
export function getBrandingSettings(
export async function getBrandingSettings(
server: ZitadelServer
): Promise<BrandingSettings | undefined> {
const settingsService = settings.getSettings(server);
@@ -43,7 +60,16 @@ export function getBrandingSettings(
.then((resp: GetBrandingSettingsResponse) => resp.settings);
}
export function getGeneralSettings(
export async function getLoginSettings(
server: ZitadelServer
): Promise<LoginSettings | undefined> {
const settingsService = settings.getSettings(server);
return settingsService
.getLoginSettings({}, {})
.then((resp: GetLoginSettingsResponse) => resp.settings);
}
export async function getGeneralSettings(
server: ZitadelServer
): Promise<string[] | undefined> {
const settingsService = settings.getSettings(server);
@@ -52,7 +78,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);
@@ -63,7 +89,7 @@ export function getLegalAndSupportSettings(
});
}
export function getPasswordComplexitySettings(
export async function getPasswordComplexitySettings(
server: ZitadelServer
): Promise<PasswordComplexitySettings | undefined> {
const settingsService = settings.getSettings(server);
@@ -73,28 +99,58 @@ export function getPasswordComplexitySettings(
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
}
export function createSession(
export async function createSession(
server: ZitadelServer,
loginName: string
loginName: string,
password: string | undefined,
challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server);
return sessionService.createSession({ checks: { user: { loginName } } }, {});
return password
? sessionService.createSession(
{
checks: { user: { loginName }, password: { password } },
challenges,
},
{}
)
: sessionService.createSession(
{ checks: { user: { loginName } }, challenges },
{}
);
}
export function setSession(
export async function setSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string,
password: string
password: string | undefined,
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined
): Promise<SetSessionResponse | undefined> {
const sessionService = session.getSession(server);
return sessionService.setSession(
{ sessionId, sessionToken, checks: { password: { password } } },
{}
);
const payload: SetSessionRequest = {
sessionId,
sessionToken,
challenges,
checks: {},
metadata: {},
};
if (password && payload.checks) {
payload.checks.password = { password };
}
if (webAuthN && payload.checks) {
payload.checks.webAuthN = webAuthN;
}
return sessionService.setSession(payload, {});
}
export function getSession(
export async function getSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string
@@ -103,7 +159,16 @@ export function getSession(
return sessionService.getSession({ sessionId, sessionToken }, {});
}
export function listSessions(
export async function deleteSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string
): Promise<DeleteSessionResponse | undefined> {
const sessionService = session.getSession(server);
return sessionService.deleteSession({ sessionId, sessionToken }, {});
}
export async function listSessions(
server: ZitadelServer,
ids: string[]
): Promise<ListSessionsResponse | undefined> {
@@ -117,22 +182,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);
return mgmt
const userService = user.getUser(server);
const payload: Partial<AddHumanUserRequest> = {
email: { email },
username: email,
profile: { givenName: firstName, familyName: lastName },
};
return userService
.addHumanUser(
{
email: { email },
username: email,
profile: { firstName, lastName },
password: { password },
},
password
? {
...payload,
password: { password },
}
: payload,
{}
)
.then((resp: AddHumanUserResponse) => {
@@ -140,7 +211,51 @@ export function addHumanUser(
});
}
export function verifyEmail(
export async function startIdentityProviderFlow(
server: ZitadelServer,
{ idpId, urls }: StartIdentityProviderIntentRequest
): Promise<StartIdentityProviderIntentResponse> {
const userService = user.getUser(server);
return userService.startIdentityProviderIntent({
idpId,
urls,
});
}
export async function retrieveIdentityProviderInformation(
server: ZitadelServer,
{ idpIntentId, idpIntentToken }: RetrieveIdentityProviderIntentRequest
): Promise<RetrieveIdentityProviderIntentResponse> {
const userService = user.getUser(server);
return userService.retrieveIdentityProviderIntent({
idpIntentId,
idpIntentToken,
});
}
export async function getAuthRequest(
server: ZitadelServer,
{ authRequestId }: GetAuthRequestRequest
): Promise<GetAuthRequestResponse> {
const oidcService = oidc.getOidc(server);
return oidcService.getAuthRequest({
authRequestId,
});
}
export async function createCallback(
server: ZitadelServer,
req: CreateCallbackRequest
): Promise<CreateCallbackResponse> {
const oidcService = oidc.getOidc(server);
return oidcService.createCallback(req);
}
export async function verifyEmail(
server: ZitadelServer,
userId: string,
verificationCode: string
@@ -161,7 +276,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(
{
@@ -171,4 +289,86 @@ 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:
});
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function listAuthenticationMethodTypes(
userId: string
): Promise<ListAuthenticationMethodTypesResponse> {
const userservice = user.getUser(server);
return userservice.listAuthenticationMethodTypes({
userId,
});
}
export { server };

28
apps/login/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const config = {
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*"],
};
const INSTANCE = process.env.ZITADEL_API_URL;
const SERVICE_USER_ID = process.env.ZITADEL_SERVICE_USER_ID as string;
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-zitadel-login-client", SERVICE_USER_ID);
requestHeaders.set("Forwarded", `host="${request.nextUrl.host}"`);
const responseHeaders = new Headers();
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*");
request.nextUrl.href = `${INSTANCE}${request.nextUrl.pathname}${request.nextUrl.search}`;
return NextResponse.rewrite(request.nextUrl, {
request: {
headers: requestHeaders,
},
headers: responseHeaders,
});
}

View File

@@ -0,0 +1,20 @@
FROM bufbuild/buf:1.21.0 as protos
RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto
RUN buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto
RUN buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto
RUN buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto
FROM scratch AS config
COPY mocked-services.cfg .
COPY initial-stubs initial-stubs
COPY --from=protos /proto .
FROM golang:1.20.5-alpine3.18 as grpc-mock
RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c
COPY --from=config / .
ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs" ]

View File

@@ -0,0 +1,50 @@
[
{
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetBrandingSettings",
"out": {}
},
{
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetLegalAndSupportSettings",
"out": {
"data": {
"settings": {
"tosLink": "http://whatever.com/help",
"privacyPolicyLink": "http://whatever.com/help",
"helpLink": "http://whatever.com/help"
}
}
}
},
{
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetActiveIdentityProviders",
"out": {
"data": {
"identityProviders": [
{
"id": "123",
"name": "Hubba bubba",
"type": 10
}
]
}
}
},
{
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetPasswordComplexitySettings",
"out": {
"data": {
"settings": {
"minLength": 8,
"requiresUppercase": true,
"requiresLowercase": true,
"requiresNumber": true,
"requiresSymbol": true
}
}
}
}
]

View File

@@ -0,0 +1,6 @@
zitadel/user/v2beta/user_service.proto
zitadel/session/v2beta/session_service.proto
zitadel/settings/v2beta/settings_service.proto
zitadel/management.proto
zitadel/auth.proto
zitadel/admin.proto

View File

@@ -9,7 +9,7 @@ const nextConfig = {
remotePatterns: [
{
protocol: "https",
hostname: process.env.ZITADEL_API_URL.replace("https://", ""),
hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "",
port: "",
pathname: "/**",
},

View File

@@ -2,14 +2,28 @@
"name": "@zitadel/login",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"test": "concurrently --timings --kill-others-on-fail 'npm:test:unit' 'npm:test:integration'",
"test:watch": "concurrently --kill-others 'npm:test:unit:watch' 'npm:test:integration:watch'",
"test:unit": "jest --config ./__test__/jest.config.ts",
"test:unit:watch": "pnpm test:unit --watch",
"test:integration": "pnpm mock:build && concurrently --names 'mock,test' --success command-test --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test start http://localhost:3000 \"test:integration:run\"'",
"test:integration:watch": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:run\\\"\"'",
"test:integration:run": "cypress run --config-file ./cypress/cypress.config.ts --quiet",
"test:integration:open": "cypress open --config-file ./cypress/cypress.config.ts",
"mock": "pnpm mock:build && pnpm mock:run",
"mock:run": "pnpm mock:stop && docker run --rm --name zitadel-mock-grpc-server --publish 22220:22220 --publish 22222:22222 zitadel-mock-grpc-server",
"mock:build": "DOCKER_BUILDKIT=1 docker build --tag zitadel-mock-grpc-server ./mock",
"mock:build:nocache": "pnpm mock:build --no-cache",
"mock:stop": "docker rm --force zitadel-mock-grpc-server 2>/dev/null || true",
"mock:destroy": "docker rmi --force zitadel-mock-grpc-server 2>/dev/null || true",
"lint": "next lint && prettier --check .",
"lint:fix": "prettier --write .",
"lint-staged": "lint-staged",
"build": "next build",
"prestart": "pnpm build",
"start": "next start",
"test": "yarn prettier:check &nexarn lint",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next"
"clean": "pnpm mock:destroy && rm -rf .turbo && rm -rf node_modules && rm -rf .next"
},
"git": {
"pre-commit": "lint-staged"
@@ -22,41 +36,57 @@
"@heroicons/react": "2.0.13",
"@tailwindcss/forms": "0.5.3",
"@vercel/analytics": "^1.0.0",
"@zitadel/next": "workspace:*",
"@zitadel/client": "workspace:*",
"@zitadel/react": "workspace:*",
"@zitadel/server": "workspace:*",
"clsx": "1.2.1",
"date-fns": "2.29.3",
"moment": "^2.29.4",
"next": "13.4.2",
"next": "13.4.12",
"next-themes": "^0.2.1",
"nice-grpc": "2.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.39.5",
"sass": "^1.62.0",
"swr": "^2.2.0",
"tinycolor2": "1.4.2"
},
"devDependencies": {
"@bufbuild/buf": "^1.14.0",
"@jest/types": "^29.5.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.1",
"@types/ms": "0.7.31",
"@types/node": "18.11.9",
"@types/react": "18.0.25",
"@types/react": "18.2.8",
"@types/react-dom": "18.0.9",
"@types/testing-library__jest-dom": "^5.14.6",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "^9.0.1",
"@vercel/git-hooks": "1.0.0",
"@zitadel/tsconfig": "workspace:*",
"autoprefixer": "10.4.13",
"concurrently": "^8.1.0",
"cypress": "^12.14.0",
"del-cli": "5.0.0",
"env-cmd": "^10.1.0",
"eslint-config-zitadel": "workspace:*",
"grpc-tools": "1.11.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest-silent-reporter": "^0.5.0",
"lint-staged": "13.0.3",
"make-dir-cli": "3.0.0",
"nodemon": "^2.0.22",
"postcss": "8.4.21",
"prettier-plugin-tailwindcss": "0.1.13",
"start-server-and-test": "^2.0.0",
"tailwindcss": "3.2.4",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"ts-proto": "^1.139.0",
"typescript": "4.8.4",
"typescript": "5.0.4",
"zitadel-tailwind-config": "workspace:*"
}
}

View File

@@ -15,3 +15,11 @@ The Login UI should provide the following functionality:
## Documentation
https://beta.nextjs.org/docs
<!--
This can be uncommented once @zitadel/... packages are available in the public npm registry
## Deploy your own
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript%2Ftree%2Fmain%2Fapps%2Flogin&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&demo-title=Next.js%20Login&demo-description=A%20Login%20Application%20built%20with%20Next.js) -->

View File

@@ -9,11 +9,11 @@
@layer base {
h1,
.ztdl-h1 {
@apply text-2xl;
@apply text-2xl text-center;
}
.ztdl-p {
@apply text-sm;
@apply text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500;
}
}

View File

@@ -1,11 +1,17 @@
{
"extends": "@zitadel/tsconfig/nextjs.json",
"compilerOptions": {
"jsx": "preserve",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"#/*": ["./*"]
},
"plugins": [{ "name": "next" }]
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]

21
apps/login/turbo.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": ["//"],
"pipeline": {
"build": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["@zitadel/server#build", "@zitadel/react#build"]
},
"test:integration": {
"dependsOn": ["@zitadel/server#build", "@zitadel/react#build"]
},
"test:unit": {
"dependsOn": ["@zitadel/server#build"]
},
"test:watch": {
"dependsOn": ["@zitadel/server#build", "@zitadel/react#build"]
}
}
}

View File

@@ -11,7 +11,7 @@ export function AddressBar({ domain }: Props) {
const pathname = usePathname();
return (
<div className="flex items-center space-x-2 p-3.5 lg:px-5 lg:py-3">
<div className="flex items-center space-x-2 p-3.5 lg:px-5 lg:py-3 overflow-hidden">
<div className="text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -27,8 +27,8 @@ export function AddressBar({ domain }: Props) {
</svg>
</div>
<div className="flex space-x-1 text-sm font-medium">
<div>
<span className="px-2 text-gray-500">{domain}</span>
<div className="max-w-[150px] px-2 overflow-hidden text-gray-500 text-ellipsis">
<span className="whitespace-nowrap">{domain}</span>
</div>
{pathname ? (
<>

View File

@@ -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>
);
}

View 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">&middot;</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>
);
}

View File

@@ -2,6 +2,7 @@ import clsx from "clsx";
import React, {
ButtonHTMLAttributes,
DetailedHTMLProps,
ReactNode,
forwardRef,
} from "react";
@@ -65,16 +66,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
...props
},
ref
) => {
return (
<button
type="button"
ref={ref}
className={`${getButtonClasses(size, variant, color)} ${className}`}
{...props}
>
{children}
</button>
);
}
) => (
<button
type="button"
ref={ref}
className={`${getButtonClasses(size, variant, color)} ${className}`}
{...props}
>
{children}
</button>
)
);

View File

@@ -1,10 +0,0 @@
import { SignInWithGoogle, SignInWithGitlab } from "@zitadel/react";
export default function IdentityProviders() {
return (
<div className="space-y-4 py-4">
<SignInWithGoogle />
<SignInWithGitlab />
</div>
);
}

View File

@@ -1,28 +1,18 @@
"use client";
import { ThemeProvider, useTheme } from "next-themes";
import { ZitadelReactProvider } from "@zitadel/react";
import { useTheme } from "next-themes";
type Props = {
children: React.ReactNode;
};
export function LayoutProviders({ children }: Props) {
// const { resolvedTheme } = useTheme();
const isDark = false; //resolvedTheme && resolvedTheme === "dark";
// useEffect(() => {
// console.log("layoutproviders useeffect");
// setTheme(document);
// });
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
storageKey="cp-theme"
value={{ dark: "dark" }}
>
<div className={`${isDark ? "ui-dark" : "ui-light"} `}>{children}</div>
</ThemeProvider>
<div className={`${isDark ? "ui-dark" : "ui-light"} `}>
<ZitadelReactProvider dark={isDark}>{children}</ZitadelReactProvider>
</div>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
import { Button, ButtonVariants } from "./Button";
import Alert from "./Alert";
import { Spinner } from "./Spinner";
type Props = {
loginName: string;
authRequestId?: string;
altPassword: boolean;
};
export default function LoginPasskey({
loginName,
authRequestId,
altPassword,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
setLoading(true);
updateSessionForChallenge()
.then((response) => {
const pK =
response.challenges.webAuthN.publicKeyCredentialRequestOptions
.publicKey;
if (pK) {
submitLoginAndContinue(pK)
.then(() => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
} else {
setError("Could not request passkey challenge");
setLoading(false);
}
})
.catch((error) => {
setError(error);
setLoading(false);
});
}
}, []);
async function updateSessionForChallenge() {
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
challenges: {
webAuthN: {
domain: "",
userVerificationRequirement: 1,
},
},
authRequestId,
}),
});
setLoading(false);
if (!res.ok) {
const error = await res.json();
throw error.details.details;
}
return res.json();
}
async function submitLogin(data: any) {
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
webAuthN: { credentialAssertionData: data },
authRequestId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
async function submitLoginAndContinue(
publicKey: any
): Promise<boolean | void> {
publicKey.challenge = coerceToArrayBuffer(
publicKey.challenge,
"publicKey.challenge"
);
publicKey.allowCredentials.map((listItem: any) => {
listItem.id = coerceToArrayBuffer(
listItem.id,
"publicKey.allowCredentials.id"
);
});
navigator.credentials
.get({
publicKey,
})
.then((assertedCredential: any) => {
if (assertedCredential) {
const authData = new Uint8Array(
assertedCredential.response.authenticatorData
);
const clientDataJSON = new Uint8Array(
assertedCredential.response.clientDataJSON
);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(
assertedCredential.response.userHandle
);
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: assertedCredential.type,
response: {
authenticatorData: coerceToBase64Url(authData, "authData"),
clientDataJSON: coerceToBase64Url(
clientDataJSON,
"clientDataJSON"
),
signature: coerceToBase64Url(sig, "sig"),
userHandle: coerceToBase64Url(userHandle, "userHandle"),
},
};
return submitLogin(data).then((resp) => {
return router.push(
`/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
)
);
});
} else {
setLoading(false);
setError("An error on retrieving passkey");
return null;
}
})
.catch((error) => {
console.error(error);
setLoading(false);
// setError(error);
return null;
});
}
return (
<div className="w-full">
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
{altPassword ? (
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => {
const params = { loginName, alt: "true" };
return router.push(
"/password?" +
new URLSearchParams(
authRequestId ? { ...params, authRequestId } : params
) // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
);
}}
>
use password
</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}
onClick={() => updateSessionForChallenge()}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</div>
);
}

View File

@@ -20,7 +20,9 @@ const check = (
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6 las la-check text-green-500 dark:text-green-500 mr-2 text-lg"
role="img"
>
<title>Matches</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -36,7 +38,9 @@ const cross = (
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
role="img"
>
<title>Doesn't match</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -60,12 +64,16 @@ export default function PasswordComplexity({
return (
<div className="mb-4 grid grid-cols-2 gap-x-8 gap-y-2">
<div className="flex flex-row items-center">
{hasMinLength ? check : cross}
<span className={desc}>
Password length {passwordComplexitySettings.minLength}
</span>
</div>
{passwordComplexitySettings.minLength != undefined ? (
<div className="flex flex-row items-center">
{hasMinLength ? check : cross}
<span className={desc}>
Password length {passwordComplexitySettings.minLength}
</span>
</div>
) : (
<span />
)}
<div className="flex flex-row items-center">
{hasSymbol ? check : cross}
<span className={desc}>has Symbol</span>

View File

@@ -14,9 +14,17 @@ type Inputs = {
type Props = {
loginName?: string;
authRequestId?: string;
isAlternative?: boolean; // whether password was requested as alternative auth method
promptPasswordless?: boolean;
};
export default function PasswordForm({ loginName }: Props) {
export default function PasswordForm({
loginName,
authRequestId,
promptPasswordless,
isAlternative,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
@@ -30,13 +38,15 @@ export default function PasswordForm({ loginName }: Props) {
async function submitPassword(values: Inputs) {
setError("");
setLoading(true);
const res = await fetch("/session", {
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
password: values.password,
authRequestId,
}),
});
@@ -52,7 +62,34 @@ 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 && // if session was not verified with a passkey
promptPasswordless && // if explicitly prompted due policy
!isAlternative // escaped if password was used as an alternative method
) {
return router.push(
`/passkey/add?` +
new URLSearchParams({
loginName: resp.factors.user.loginName,
promptPasswordless: "true",
})
);
} else {
return router.push(
`/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
)
);
}
});
}

View File

@@ -50,6 +50,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<Checkbox
className="mr-4"
checked={false}
value={"privacypolicy"}
onChangeVal={(checked: boolean) => {
setAcceptanceState({
...acceptanceState,
@@ -74,6 +75,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<Checkbox
className="mr-4"
checked={false}
value={"tos"}
onChangeVal={(checked: boolean) => {
setAcceptanceState({
...acceptanceState,

View File

@@ -48,7 +48,7 @@ export default function RegisterForm({
async function submitRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/registeruser", {
const res = await fetch("/api/registeruser", {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -0,0 +1,187 @@
"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";
import Alert from "./Alert";
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 [error, setError] = useState<string>("");
const router = useRouter();
async function submitAndRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/api/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) {
const error = await res.json();
throw new Error(error.details);
}
return res.json();
}
async function createSessionWithLoginName(loginName: string) {
setLoading(true);
const res = await fetch("/api/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: loginName,
// authRequestId, register does not need an oidc callback at the end
}),
});
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 }) => {
setError("");
return router.push(
`/passkey/add?` +
new URLSearchParams({ loginName: factors.user.loginName })
);
});
})
.catch((errorDetails: Error) => {
setLoading(false);
setError(errorDetails.message);
});
}
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>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</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>
);
}

View 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("/api/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("/api/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>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { Session } from "@zitadel/server";
import Link from "next/link";
import { useState } from "react";
import { Avatar } from "./Avatar";
import moment from "moment";
import { XCircleIcon } from "@heroicons/react/24/outline";
export default function SessionItem({
session,
reload,
authRequestId,
}: {
session: Session;
reload: () => void;
authRequestId?: string;
}) {
const [loading, setLoading] = useState<boolean>(false);
async function clearSession(id: string) {
setLoading(true);
const res = await fetch("/api/session?" + new URLSearchParams({ id }), {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: id,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
// setError(response.details);
return Promise.reject(response);
} else {
return response;
}
}
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validUser = validPassword || validPasskey;
return (
<Link
href={
validUser
? `/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: session.factors?.user?.loginName as string,
authRequestId,
}
: {
loginName: session.factors?.user?.loginName as string,
}
)
: `/loginname?` +
new URLSearchParams(
authRequestId
? {
loginName: session.factors?.user?.loginName as string,
submit: "true",
authRequestId,
}
: {
loginName: session.factors?.user?.loginName as string,
submit: "true",
}
)
}
className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all"
>
<div className="pr-4">
<Avatar
size="small"
loginName={session.factors?.user?.loginName as string}
name={session.factors?.user?.displayName ?? ""}
/>
</div>
<div className="flex flex-col">
<span className="">{session.factors?.user?.displayName}</span>
<span className="text-xs opacity-80">
{session.factors?.user?.loginName}
</span>
{validUser && (
<span className="text-xs opacity-80">
{moment(new Date(validUser)).fromNow()}
</span>
)}
</div>
<span className="flex-grow"></span>
<div className="relative flex flex-row items-center">
{validUser ? (
<div className="absolute h-2 w-2 bg-green-500 rounded-full mx-2 transform right-0 group-hover:right-6 transition-all"></div>
) : (
<div className="absolute h-2 w-2 bg-red-500 rounded-full mx-2 transform right-0 group-hover:right-6 transition-all"></div>
)}
<XCircleIcon
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
onClick={(event) => {
event.preventDefault();
clearSession(session.id).then(() => {
reload();
});
}}
/>
</div>
</Link>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Session } from "@zitadel/server";
import SessionItem from "./SessionItem";
import Alert from "./Alert";
import { useEffect, useState } from "react";
type Props = {
sessions: Session[];
authRequestId?: string;
};
export default function SessionsList({ sessions, authRequestId }: Props) {
const [list, setList] = useState<Session[]>(sessions);
return sessions ? (
<div className="flex flex-col space-y-2">
{list
.filter((session) => session?.factors?.user?.loginName)
.map((session, index) => {
return (
<SessionItem
session={session}
authRequestId={authRequestId}
reload={() => {
setList(list.filter((s) => s.id !== session.id));
}}
key={"session-" + index}
/>
);
})}
</div>
) : (
<Alert>No Sessions available!</Alert>
);
}

View File

@@ -0,0 +1,191 @@
"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("/api/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("/api/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: loginName,
password: password,
// authRequestId, register does not need an oidc callback
}),
});
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>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { ReactNode, useState } from "react";
import {
SignInWithGitlab,
SignInWithAzureAD,
SignInWithGoogle,
SignInWithGithub,
} from "@zitadel/react";
import { useRouter } from "next/navigation";
import { ProviderSlug } from "#/lib/demos";
import Alert from "./Alert";
export interface SignInWithIDPProps {
children?: ReactNode;
host: string;
identityProviders: any[];
startIDPFlowPath?: (idpId: string) => string;
}
const START_IDP_FLOW_PATH = (idpId: string) =>
`/v2beta/users/idps/${idpId}/start`;
export function SignInWithIDP({
host,
identityProviders,
startIDPFlowPath = START_IDP_FLOW_PATH,
}: SignInWithIDPProps) {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function startFlow(idpId: string, provider: ProviderSlug) {
setLoading(true);
const res = await fetch("/api/idp/start", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
idpId,
successUrl: `${host}/register/idp/${provider}/success`,
failureUrl: `${host}/register/idp/${provider}/failure`,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
return (
<div className="flex flex-col w-full space-y-2 text-sm">
{identityProviders &&
identityProviders.map((idp, i) => {
switch (idp.type) {
case 6: // IdentityProviderType.IDENTITY_PROVIDER_TYPE_GITHUB:
return (
<SignInWithGithub
key={`idp-${i}`}
onClick={() =>
startFlow(idp.id, ProviderSlug.GITHUB).then(
({ authUrl }) => {
router.push(authUrl);
}
)
}
></SignInWithGithub>
);
case 7: // IdentityProviderType.IDENTITY_PROVIDER_TYPE_GITHUB_ES:
return (
<SignInWithGithub
key={`idp-${i}`}
onClick={() => alert("TODO: unimplemented")}
></SignInWithGithub>
);
case 5: // IdentityProviderType.IDENTITY_PROVIDER_TYPE_AZURE_AD:
return (
<SignInWithAzureAD
key={`idp-${i}`}
onClick={() => alert("TODO: unimplemented")}
></SignInWithAzureAD>
);
case 10: // IdentityProviderType.IDENTITY_PROVIDER_TYPE_GOOGLE:
return (
<SignInWithGoogle
key={`idp-${i}`}
e2e="google"
name={idp.name}
onClick={() =>
startFlow(idp.id, ProviderSlug.GOOGLE).then(
({ authUrl }) => {
router.push(authUrl);
}
)
}
></SignInWithGoogle>
);
case 8: // IdentityProviderType.IDENTITY_PROVIDER_TYPE_GITLAB:
return (
<SignInWithGitlab
key={`idp-${i}`}
onClick={() => alert("TODO: unimplemented")}
></SignInWithGitlab>
);
case 9: //IdentityProviderType.IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED:
return (
<SignInWithGitlab
key={`idp-${i}`}
onClick={() => alert("TODO: unimplemented")}
></SignInWithGitlab>
);
default:
return null;
}
})}
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
</div>
);
}
SignInWithIDP.displayName = "SignInWithIDP";

View File

@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as ThemeP } from "next-themes";
import { ReactNode } from "react";
export default function ThemeProvider({ children }: { children: ReactNode }) {
return (
<ThemeP
attribute="class"
defaultTheme="system"
storageKey="cp-theme"
value={{ dark: "dark" }}
>
{children}
</ThemeP>
);
}

View File

@@ -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>

View File

@@ -1,55 +1,146 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { TextInput } from "./Input";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import { LoginSettings } from "@zitadel/server";
import Alert from "./Alert";
type Inputs = {
loginName: string;
};
export default function UsernameForm() {
type Props = {
loginSettings: LoginSettings | undefined;
loginName: string | undefined;
authRequestId: string | undefined;
submit: boolean;
};
export default function UsernameForm({
loginSettings,
loginName,
authRequestId,
submit,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
loginName: loginName ? loginName : "",
},
});
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitUsername(values: Inputs) {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
async function submitLoginName(values: Inputs) {
setLoading(true);
const res = await fetch("/session", {
const body = {
loginName: values.loginName,
};
const res = await fetch("/api/loginname", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: values.loginName,
}),
body: JSON.stringify(authRequestId ? { ...body, authRequestId } : body),
});
setLoading(false);
if (!res.ok) {
throw new Error("Failed to set user");
const response = await res.json();
setError(response.details);
return Promise.reject(response.details);
}
return res.json();
}
function submitUsernameAndContinue(value: Inputs): Promise<boolean | void> {
return submitUsername(value).then(({ factors }) => {
return router.push(
`/password?` +
new URLSearchParams({ loginName: `${factors.user.loginName}` })
);
async function setLoginNameAndGetAuthMethods(values: Inputs) {
return submitLoginName(values).then((response) => {
if (response.authMethodTypes.length == 1) {
const method = response.authMethodTypes[0];
switch (method) {
case 1: //AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSWORD:
const paramsPassword: any = { loginName: values.loginName };
if (loginSettings?.passkeysType === 1) {
paramsPassword.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED,
}
if (authRequestId) {
paramsPassword.authRequestId = authRequestId;
}
return router.push(
"/password?" + new URLSearchParams(paramsPassword)
);
case 2: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
const paramsPasskey: any = { loginName: values.loginName };
if (authRequestId) {
paramsPasskey.authRequestId = authRequestId;
}
return router.push(
"/passkey/login?" + new URLSearchParams(paramsPasskey)
);
default:
const paramsPasskeyDefault: any = { loginName: values.loginName };
if (loginSettings?.passkeysType === 1) {
paramsPasskeyDefault.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED,
}
if (authRequestId) {
paramsPasskeyDefault.authRequestId = authRequestId;
}
return router.push(
"/password?" + new URLSearchParams(paramsPasskeyDefault)
);
}
} else if (
response.authMethodTypes &&
response.authMethodTypes.length === 0
) {
setError(
"User has no available authentication methods. Contact your administrator to setup authentication for the requested user."
);
} else {
// prefer passkey in favor of other methods
if (response.authMethodTypes.includes(2)) {
const passkeyParams: any = {
loginName: values.loginName,
altPassword: `${response.authMethodTypes.includes(1)}`, // show alternative password option
};
if (authRequestId) {
passkeyParams.authRequestId = authRequestId;
}
return router.push(
"/passkey/login?" + new URLSearchParams(passkeyParams)
);
}
}
});
}
const { errors } = formState;
useEffect(() => {
if (submit && loginName) {
// When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid.
setLoginNameAndGetAuthMethods({ loginName });
}
}, []);
return (
<form className="w-full">
<div className="">
@@ -58,21 +149,23 @@ export default function UsernameForm() {
autoComplete="username"
{...register("loginName", { required: "This field is required" })}
label="Loginname"
// error={errors.username?.message as string}
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
{/* <Button type="button" variant={ButtonVariants.Secondary}>
back
</Button> */}
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitUsernameAndContinue)}
onClick={handleSubmit(setLoginNameAndGetAuthMethods)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue

View File

@@ -28,7 +28,9 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
useEffect(() => {
if (submit && code && userId) {
submitCode({ code });
// When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid.
// For programmatic verification, the /verifyemail API should be used.
submitCodeAndContinue({ code });
}
}, []);
@@ -40,7 +42,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
async function submitCode(values: Inputs) {
setLoading(true);
const res = await fetch("/verifyemail", {
const res = await fetch("/api/verifyemail", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -53,19 +55,18 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
const response = await res.json();
setLoading(false);
if (!res.ok) {
setLoading(false);
setError(response.details);
return Promise.reject(response);
} else {
setLoading(false);
return response;
}
}
async function resendCode() {
setLoading(true);
const res = await fetch("/resendverifyemail", {
const res = await fetch("/api/resendverifyemail", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -86,7 +87,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
return submitCode(value).then((resp: any) => {
return router.push(`/username`);
return router.push(`/loginname`);
});
}

View 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;
}

View File

@@ -72,16 +72,16 @@ type BrandingColors = {
export function setTheme(document: any, policy?: Partial<BrandingSettings>) {
const lP: BrandingColors = {
lightTheme: {
backgroundColor: policy?.lightTheme?.backgroundColor ?? BACKGROUND,
fontColor: policy?.lightTheme?.fontColor ?? TEXT,
primaryColor: policy?.lightTheme?.primaryColor ?? PRIMARY,
warnColor: policy?.lightTheme?.warnColor ?? WARN,
backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND,
fontColor: policy?.lightTheme?.fontColor || TEXT,
primaryColor: policy?.lightTheme?.primaryColor || PRIMARY,
warnColor: policy?.lightTheme?.warnColor || WARN,
},
darkTheme: {
backgroundColor: policy?.darkTheme?.backgroundColor ?? DARK_BACKGROUND,
fontColor: policy?.darkTheme?.fontColor ?? DARK_TEXT,
primaryColor: policy?.darkTheme?.primaryColor ?? DARK_PRIMARY,
warnColor: policy?.darkTheme?.warnColor ?? DARK_WARN,
backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND,
fontColor: policy?.darkTheme?.fontColor || DARK_TEXT,
primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY,
warnColor: policy?.darkTheme?.warnColor || DARK_WARN,
},
};

View File

@@ -7,6 +7,7 @@ export type SessionCookie = {
token: string;
loginName: string;
changeDate: string;
authRequestId?: string; // if its linked to an OIDC flow
};
function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
@@ -19,6 +20,7 @@ function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
path: "/",
});
}
export async function addSessionToCookie(session: SessionCookie): Promise<any> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -37,7 +39,7 @@ export async function addSessionToCookie(session: SessionCookie): Promise<any> {
currentSessions = [...currentSessions, session];
}
setSessionHttpOnlyCookie(currentSessions);
return setSessionHttpOnlyCookie(currentSessions);
}
export async function updateSessionCookie(
@@ -52,9 +54,12 @@ export async function updateSessionCookie(
: [session];
const foundIndex = sessions.findIndex((session) => session.id === id);
sessions[foundIndex] = session;
return setSessionHttpOnlyCookie(sessions);
if (foundIndex > -1) {
sessions[foundIndex] = session;
return setSessionHttpOnlyCookie(sessions);
} else {
throw "updateSessionCookie: session id now found";
}
}
export async function removeSessionFromCookie(
@@ -87,12 +92,50 @@ export async function getMostRecentSessionCookie(): Promise<any> {
});
return latest;
} else {
return Promise.reject("no session cookie found");
}
}
export async function getSessionCookieById(id: string): Promise<SessionCookie> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
const found = sessions.find((s) => s.id === id);
if (found) {
return found;
} else {
return Promise.reject();
}
} else {
return Promise.reject();
}
}
export async function getAllSessionIds(): Promise<any> {
export async function getSessionCookieByLoginName(
loginName: string
): Promise<SessionCookie> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
const found = sessions.find((s) => s.loginName === loginName);
if (found) {
return found;
} else {
return Promise.reject("no cookie found with loginName: " + loginName);
}
} else {
return Promise.reject("no session cookie found");
}
}
export async function getAllSessionCookieIds(): Promise<any> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -104,6 +147,18 @@ export async function getAllSessionIds(): Promise<any> {
}
}
export async function getAllSessions(): Promise<SessionCookie[]> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
return sessions;
} else {
return [];
}
}
/**
* Returns most recent session filtered by optinal loginName
* @param loginName
@@ -117,7 +172,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;
});
@@ -135,10 +189,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");
}
}

120
apps/login/utils/session.ts Normal file
View File

@@ -0,0 +1,120 @@
import { createSession, getSession, server, setSession } from "#/lib/zitadel";
import {
SessionCookie,
addSessionToCookie,
updateSessionCookie,
} from "./cookies";
import { Session, Challenges, RequestChallenges } from "@zitadel/server";
export async function createSessionAndUpdateCookie(
loginName: string,
password: string | undefined,
challenges: RequestChallenges | undefined,
authRequestId: string | undefined
): Promise<Session> {
const createdSession = await createSession(
server,
loginName,
password,
challenges
);
if (createdSession) {
return getSession(
server,
createdSession.sessionId,
createdSession.sessionToken
).then((response) => {
if (response?.session && response.session?.factors?.user?.loginName) {
const sessionCookie: SessionCookie = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
changeDate: response.session.changeDate?.toString() ?? "",
loginName: response.session?.factors?.user?.loginName ?? "",
};
if (authRequestId) {
sessionCookie.authRequestId = authRequestId;
}
return addSessionToCookie(sessionCookie).then(() => {
return response.session as Session;
});
} else {
throw "could not get session or session does not have loginName";
}
});
} else {
throw "Could not create session";
}
}
export type SessionWithChallenges = Session & {
challenges: Challenges | undefined;
};
export async function setSessionAndUpdateCookie(
sessionId: string,
sessionToken: string,
loginName: string,
password: string | undefined,
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined,
authRequestId: string | undefined
): Promise<SessionWithChallenges> {
return setSession(
server,
sessionId,
sessionToken,
password,
webAuthN,
challenges
).then((updatedSession) => {
if (updatedSession) {
const sessionCookie: SessionCookie = {
id: sessionId,
token: updatedSession.sessionToken,
changeDate: updatedSession.details?.changeDate?.toString() ?? "",
loginName: loginName,
};
if (authRequestId) {
sessionCookie.authRequestId = authRequestId;
}
return new Promise((resolve) => setTimeout(resolve, 1000)).then(() =>
// TODO: remove
getSession(server, sessionCookie.id, sessionCookie.token).then(
(response) => {
if (
response?.session &&
response.session.factors?.user?.loginName
) {
const { session } = response;
const newCookie: SessionCookie = {
id: sessionCookie.id,
token: updatedSession.sessionToken,
changeDate: session.changeDate?.toString() ?? "",
loginName: session.factors?.user?.loginName ?? "",
};
if (sessionCookie.authRequestId) {
newCookie.authRequestId = sessionCookie.authRequestId;
}
return updateSessionCookie(sessionCookie.id, newCookie).then(
() => {
return { challenges: updatedSession.challenges, ...session };
}
);
} else {
throw "could not get session or session does not have loginName";
}
}
)
);
} else {
throw "Session not be set";
}
});
}

View File

@@ -1,23 +1,27 @@
{
"private": true,
"scripts": {
"generate": "turbo run generate",
"build": "turbo run build",
"test": "turbo run test",
"test:unit": "turbo run test:unit",
"test:integration": "turbo run test:integration",
"test:watch": "turbo run test:watch",
"dev": "turbo run dev --no-cache --continue",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build --filter=login^... && changeset publish",
"prebuild": "turbo run generate",
"generate": "turbo run generate"
"release": "turbo run build --filter=login^... && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.22.0",
"eslint": "^7.32.0",
"eslint-config-zitadel": "workspace:*",
"prettier": "^2.5.1",
"turbo": "^1.9.8"
"turbo": "^1.10.8"
},
"packageManager": "pnpm@7.15.0"
}

View File

@@ -0,0 +1,8 @@
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node'
}
export default jestConfig

View File

@@ -10,20 +10,28 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts",
"generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel",
"build": "tsup --dts",
"test": "pnpm test:unit",
"test:watch": "pnpm test:unit:watch",
"test:unit": "jest",
"test:unit:watch": "jest --watch",
"dev": "tsup --watch --dts",
"lint": "eslint \"src/**/*.ts*\"",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
"generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel"
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf src/proto"
},
"devDependencies": {
"@bufbuild/buf": "^1.14.0",
"@types/jest": "^29.5.1",
"@zitadel/tsconfig": "workspace:*",
"eslint": "^7.32.0",
"eslint-config-zitadel": "workspace:*",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"ts-proto": "^1.139.0",
"tsup": "^5.10.1",
"typescript": "^4.5.3"
"typescript": "^4.9.3"
},
"publishConfig": {
"access": "public"

View File

@@ -0,0 +1,57 @@
import { CallOptions, ClientMiddlewareCall, Metadata, MethodDescriptor } from "nice-grpc-web";
import { authMiddleware } from "./middleware";
describe('authMiddleware', () => {
const scenarios = [
{
name: 'should add authorization if metadata is undefined',
initialMetadata: undefined,
expectedMetadata: new Metadata().set("authorization", "Bearer mock-token"),
token: "mock-token"
},
{
name: 'should add authorization if metadata exists but no authorization',
initialMetadata: new Metadata().set("other-key", "other-value"),
expectedMetadata: new Metadata().set("other-key", "other-value").set("authorization", "Bearer mock-token"),
token: "mock-token"
},
{
name: 'should not modify authorization if it already exists',
initialMetadata: new Metadata().set("authorization", "Bearer initial-token"),
expectedMetadata: new Metadata().set("authorization", "Bearer initial-token"),
token: "mock-token"
},
];
scenarios.forEach(({ name, initialMetadata, expectedMetadata, token }) => {
it(name, async () => {
const mockNext = jest.fn().mockImplementation(async function*() { });
const mockRequest = {};
const mockMethodDescriptor: MethodDescriptor = {
options: {idempotencyLevel: undefined},
path: '',
requestStream: false,
responseStream: false,
};
const mockCall: ClientMiddlewareCall<unknown, unknown> = {
method: mockMethodDescriptor,
requestStream: false,
responseStream: false,
request: mockRequest,
next: mockNext,
};
const options: CallOptions = {
metadata: initialMetadata
};
await authMiddleware(token)(mockCall, options).next();
expect(mockNext).toHaveBeenCalledTimes(1);
const actualMetadata = mockNext.mock.calls[0][1].metadata;
expect(actualMetadata?.get('authorization')).toEqual(expectedMetadata.get('authorization'));
});
});
});

View File

@@ -0,0 +1,13 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
treeshake: true,
splitting: true,
publicDir: true,
entry: ["src/index.ts", "src/**/index.ts"],
format: ["esm", "cjs"],
dts: true,
minify: true,
clean: true,
...options,
}));

View File

@@ -0,0 +1,21 @@
{
"extends": [
"//"
],
"pipeline": {
"generate": {
"outputs": [
"src/proto/**"
],
"cache": true
},
"build": {
"outputs": [
"dist/**"
],
"dependsOn": [
"generate"
]
}
}
}

View File

@@ -0,0 +1,8 @@
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node'
}
export default jestConfig

View File

@@ -10,22 +10,41 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
"build": "tsup",
"test": "pnpm test:unit",
"test:watch": "pnpm test:unit:watch",
"test:unit": "jest --passWithNoTests",
"test:unit:watch": "jest --watch",
"dev": "tsup --watch",
"lint": "eslint \"src/**/*.ts*\"",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/react": "^17.0.13",
"@zitadel/tsconfig": "workspace:*",
"eslint": "^7.32.0",
"eslint-config-zitadel": "workspace:*",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"tsup": "^5.10.1",
"typescript": "^4.5.3"
"typescript": "^4.9.3",
"tailwindcss": "3.2.4",
"postcss": "8.4.21",
"zitadel-tailwind-config": "workspace:*",
"@zitadel/server": "workspace:*"
},
"peerDependencies": {
"next": "^13"
"@zitadel/react": "workspace:*",
"@zitadel/server": "workspace:*",
"next": "^13",
"react": "18.2.0"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"next": "^13.4.10"
}
}

View File

@@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Some files were not shown because too many files have changed in this diff Show More