mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 09:54:00 +00:00
Merge branch 'main' into issue-template
This commit is contained in:
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal 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
|
||||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
@@ -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
74
.github/workflows/test.yml
vendored
Normal 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
10
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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! 🎉
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
0
acceptance/machinekey/.gitkeep
Normal file
0
acceptance/machinekey/.gitkeep
Normal 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}
|
||||
|
||||
1
apps/login/.env.integration
Normal file
1
apps/login/.env.integration
Normal file
@@ -0,0 +1 @@
|
||||
ZITADEL_API_URL=http://localhost:22222
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
extends: "next/core-web-vitals",
|
||||
extends: ["next/core-web-vitals"],
|
||||
ignorePatterns: ["external/**/*.ts"],
|
||||
};
|
||||
|
||||
57
apps/login/__test__/PasswordComplexity.test.tsx
Normal file
57
apps/login/__test__/PasswordComplexity.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
22
apps/login/__test__/jest.config.ts
Normal file
22
apps/login/__test__/jest.config.ts
Normal 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"],
|
||||
};
|
||||
};
|
||||
7
apps/login/__test__/tsconfig.json
Normal file
7
apps/login/__test__/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsxdev",
|
||||
"types": ["node", "jest", "@testing-library/jest-dom"]
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
122
apps/login/app/(login)/login/route.ts
Normal file
122
apps/login/app/(login)/login/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
28
apps/login/app/(login)/loginname/page.tsx
Normal file
28
apps/login/app/(login)/loginname/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/login/app/(login)/passkey/add/page.tsx
Normal file
72
apps/login/app/(login)/passkey/add/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getSession, server } from "#/lib/zitadel";
|
||||
import Alert, { AlertType } from "#/ui/Alert";
|
||||
import RegisterPasskey from "#/ui/RegisterPasskey";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { loginName, prompt } = searchParams;
|
||||
|
||||
const sessionFactors = await loadSession(loginName);
|
||||
|
||||
async function loadSession(loginName?: string) {
|
||||
const recent = await getMostRecentCookieWithLoginname(loginName);
|
||||
return getSession(server, recent.id, recent.token).then((response) => {
|
||||
if (response?.session) {
|
||||
return response.session;
|
||||
}
|
||||
});
|
||||
}
|
||||
const title = !!prompt
|
||||
? "Authenticate with a passkey"
|
||||
: "Use your passkey to confirm it's really you";
|
||||
const description = !!prompt
|
||||
? "When set up, you will be able to authenticate without a password."
|
||||
: "Your device will ask for your fingerprint, face, or screen lock";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>{title}</h1>
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
></UserAvatar>
|
||||
)}
|
||||
<p className="ztdl-p mb-6 block">{description}</p>
|
||||
|
||||
<Alert type={AlertType.INFO}>
|
||||
<span>
|
||||
A passkey is an authentication method on a device like your
|
||||
fingerprint, Apple FaceID or similar.
|
||||
<a
|
||||
className="text-primary-light-500 dark:text-primary-dark-500 hover:text-primary-light-300 hover:dark:text-primary-dark-300"
|
||||
target="_blank"
|
||||
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
|
||||
>
|
||||
Passwordless Authentication
|
||||
</a>
|
||||
</span>
|
||||
</Alert>
|
||||
|
||||
{!sessionFactors && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
Could not get the context of the user. Make sure to enter the
|
||||
username first or provide a loginName as searchParam.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors?.id && (
|
||||
<RegisterPasskey sessionId={sessionFactors.id} isPrompt={!!prompt} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/login/app/(login)/passkey/login/page.tsx
Normal file
57
apps/login/app/(login)/passkey/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ClientError } from "nice-grpc";
|
||||
|
||||
type Props = {
|
||||
userId?: string;
|
||||
isMe?: boolean;
|
||||
userState?: any; // UserState;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [passwordLoading, setPasswordLoading] = useState<boolean>(false);
|
||||
const [policyValid, setPolicyValid] = useState<boolean>(false);
|
||||
|
||||
type Inputs = {
|
||||
password?: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const { register, handleSubmit, watch, reset, formState } = useForm<Inputs>({
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
shouldUseNativeValidation: true,
|
||||
});
|
||||
|
||||
const { errors, isValid } = formState;
|
||||
|
||||
const watchNewPassword = watch("newPassword", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
async function updatePassword(value: Inputs) {
|
||||
setPasswordLoading(true);
|
||||
|
||||
// const authData: UpdateMyPasswordRequest = {
|
||||
// oldPassword: value.password ?? '',
|
||||
// newPassword: value.newPassword,
|
||||
// };
|
||||
|
||||
const response = await fetch(
|
||||
`/api/user/password/me` +
|
||||
`?${new URLSearchParams({
|
||||
resend: `false`,
|
||||
})}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setPasswordLoading(false);
|
||||
// toast('password.set');
|
||||
// TODO: success info
|
||||
reset();
|
||||
} else {
|
||||
const error = (await response.json()) as ClientError;
|
||||
// toast.error(error.details);
|
||||
// TODO: show error
|
||||
}
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
|
||||
async function sendHumanResetPasswordNotification(userId: string) {
|
||||
// const mgmtData: SendHumanResetPasswordNotificationRequest = {
|
||||
// type: SendHumanResetPasswordNotificationRequest_Type.TYPE_EMAIL,
|
||||
// userId: userId,
|
||||
// };
|
||||
|
||||
const response = await fetch(`/api/user/password/resetlink/${userId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// TODO: success info
|
||||
// toast(t('sendPasswordResetLinkSent'));
|
||||
} else {
|
||||
const error = await response.json();
|
||||
// TODO: show error
|
||||
// toast.error((error as ClientError).details);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-center">Set Password</h1>
|
||||
<p className="text-center my-4 mb-6 text-14px text-input-light-label dark:text-input-dark-label">
|
||||
Enter your new Password according to the requirements listed.
|
||||
</p>
|
||||
<form>
|
||||
<div>
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("password", { required: true })}
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("newPassword", { required: true })}
|
||||
label="New Password"
|
||||
error={errors.newPassword?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 mb-4">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
{...register("confirmPassword", {
|
||||
required: true,
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Password</h1>
|
||||
<p className="ztdl-p mb-6 block">Enter your password.</p>
|
||||
|
||||
<UserAvatar
|
||||
showDropdown
|
||||
displayName="Max Peintner"
|
||||
loginName="max@zitadel.com"
|
||||
></UserAvatar>
|
||||
|
||||
<div className="w-full">
|
||||
<TextInput type="password" label="Password" />
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button variant={ButtonVariants.Primary}>continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import { TextInput } from "#/ui/Input";
|
||||
import UserAvatar from "#/ui/UserAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Password</h1>
|
||||
<p className="ztdl-p mb-6 block">Enter your password.</p>
|
||||
|
||||
<UserAvatar
|
||||
showDropdown
|
||||
displayName="Max Peintner"
|
||||
loginName="max@zitadel.com"
|
||||
></UserAvatar>
|
||||
|
||||
<div className="w-full">
|
||||
<TextInput type="password" label="Password" />
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
variant={ButtonVariants.Secondary}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button variant={ButtonVariants.Primary}>continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/login/app/(login)/register/idp/[provider]/success/page.tsx
Normal file
136
apps/login/app/(login)/register/idp/[provider]/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
50
apps/login/app/(login)/register/idp/page.tsx
Normal file
50
apps/login/app/(login)/register/idp/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,24 +3,46 @@ import {
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
import RegisterFormWithoutPassword from "#/ui/RegisterFormWithoutPassword";
|
||||
import SetPasswordForm from "#/ui/SetPasswordForm";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { firstname, lastname, email } = searchParams;
|
||||
|
||||
const setPassword = !!(firstname && lastname && email);
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
return setPassword ? (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Set Password</h1>
|
||||
<p className="ztdl-p">Set the password for your account</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<SetPasswordForm
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
email={email}
|
||||
firstname={firstname}
|
||||
lastname={lastname}
|
||||
></SetPasswordForm>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
<RegisterFormWithoutPassword
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
></RegisterFormWithoutPassword>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
export default async function Page({ searchParams }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Registration successful</h1>
|
||||
<p className="ztdl-p">You are registered.</p>
|
||||
|
||||
{`userId: ${searchParams["userid"]}`}
|
||||
<Link href="/register">
|
||||
<Button variant={ButtonVariants.Primary}>back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
27
apps/login/app/(login)/signup/page.tsx
Normal file
27
apps/login/app/(login)/signup/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
getLegalAndSupportSettings,
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
25
apps/login/app/api/idp/start/route.ts
Normal file
25
apps/login/app/api/idp/start/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
apps/login/app/api/loginname/route.ts
Normal file
39
apps/login/app/api/loginname/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
48
apps/login/app/api/passkeys/route.ts
Normal file
48
apps/login/app/api/passkeys/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
createPasskeyRegistrationLink,
|
||||
getSession,
|
||||
registerPasskey,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import { getSessionCookieById } from "#/utils/cookies";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (body) {
|
||||
const { sessionId } = body;
|
||||
|
||||
const sessionCookie = await getSessionCookieById(sessionId);
|
||||
|
||||
const session = await getSession(
|
||||
server,
|
||||
sessionCookie.id,
|
||||
sessionCookie.token
|
||||
);
|
||||
|
||||
const domain: string = request.nextUrl.hostname;
|
||||
|
||||
const userId = session?.session?.factors?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
return createPasskeyRegistrationLink(userId)
|
||||
.then((resp) => {
|
||||
const code = resp.code;
|
||||
return registerPasskey(userId, code, domain).then((resp) => {
|
||||
return NextResponse.json(resp);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("error on creating passkey registration link");
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ details: "could not get session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({}, { status: 400 });
|
||||
}
|
||||
}
|
||||
49
apps/login/app/api/passkeys/verify/route.ts
Normal file
49
apps/login/app/api/passkeys/verify/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getSession, server, verifyPasskeyRegistration } from "#/lib/zitadel";
|
||||
import { getSessionCookieById } from "#/utils/cookies";
|
||||
import { NextRequest, NextResponse, userAgent } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (body) {
|
||||
let { passkeyId, passkeyName, publicKeyCredential, sessionId } = body;
|
||||
|
||||
if (!!!passkeyName) {
|
||||
const { browser, device, os } = userAgent(request);
|
||||
passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${
|
||||
device.vendor || device.model ? ", " : ""
|
||||
}${os.name}${os.name ? ", " : ""}${browser.name}`;
|
||||
}
|
||||
const sessionCookie = await getSessionCookieById(sessionId);
|
||||
|
||||
const session = await getSession(
|
||||
server,
|
||||
sessionCookie.id,
|
||||
sessionCookie.token
|
||||
);
|
||||
|
||||
const userId = session?.session?.factors?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
return verifyPasskeyRegistration(
|
||||
server,
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
publicKeyCredential,
|
||||
userId
|
||||
)
|
||||
.then((resp) => {
|
||||
return NextResponse.json(resp);
|
||||
})
|
||||
.catch((error) => {
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ details: "could not get session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({}, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,18 @@ export async function POST(request: NextRequest) {
|
||||
if (body) {
|
||||
const { email, password, firstName, lastName } = body;
|
||||
|
||||
const userId = await addHumanUser(server, {
|
||||
return addHumanUser(server, {
|
||||
email: email,
|
||||
firstName,
|
||||
lastName,
|
||||
password: password,
|
||||
});
|
||||
return NextResponse.json({ userId });
|
||||
password: password ? password : undefined,
|
||||
})
|
||||
.then((userId) => {
|
||||
return NextResponse.json({ userId });
|
||||
})
|
||||
.catch((error) => {
|
||||
return NextResponse.json(error, { status: 500 });
|
||||
});
|
||||
} else {
|
||||
return NextResponse.error();
|
||||
}
|
||||
125
apps/login/app/api/session/route.ts
Normal file
125
apps/login/app/api/session/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
2
apps/login/cypress/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
screenshots
|
||||
videos
|
||||
12
apps/login/cypress/cypress.config.ts
Normal file
12
apps/login/cypress/cypress.config.ts
Normal 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
|
||||
},
|
||||
},
|
||||
});
|
||||
5
apps/login/cypress/fixtures/example.json
Normal file
5
apps/login/cypress/fixtures/example.json
Normal 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"
|
||||
}
|
||||
103
apps/login/cypress/integration/login.cy.ts
Normal file
103
apps/login/cypress/integration/login.cy.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
apps/login/cypress/integration/register-idp.cy.ts
Normal file
21
apps/login/cypress/integration/register-idp.cy.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
apps/login/cypress/integration/register.cy.ts
Normal file
22
apps/login/cypress/integration/register.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
19
apps/login/cypress/integration/verify.cy.ts
Normal file
19
apps/login/cypress/integration/verify.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
37
apps/login/cypress/support/commands.ts
Normal file
37
apps/login/cypress/support/commands.ts
Normal 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>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
20
apps/login/cypress/support/e2e.ts
Normal file
20
apps/login/cypress/support/e2e.ts
Normal 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')
|
||||
27
apps/login/cypress/support/mock.ts
Normal file
27
apps/login/cypress/support/mock.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
9
apps/login/cypress/tsconfig.json
Normal file
9
apps/login/cypress/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
28
apps/login/middleware.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
20
apps/login/mock/Dockerfile
Normal file
20
apps/login/mock/Dockerfile
Normal 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" ]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
6
apps/login/mock/mocked-services.cfg
Normal file
6
apps/login/mock/mocked-services.cfg
Normal 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
|
||||
@@ -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: "/**",
|
||||
},
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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) -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
21
apps/login/turbo.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
type?: AlertType;
|
||||
};
|
||||
|
||||
export default function Alert({ children }: Props) {
|
||||
export enum AlertType {
|
||||
ALERT,
|
||||
INFO,
|
||||
}
|
||||
|
||||
const yellow =
|
||||
"border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200";
|
||||
const red =
|
||||
"border-red-600/40 dark:border-red-500/20 bg-red-200/30 text-red-600 dark:bg-red-700/20 dark:text-red-200";
|
||||
const neutral =
|
||||
"border-divider-light dark:border-divider-dark bg-black/5 text-gray-600 dark:bg-white/10 dark:text-gray-200";
|
||||
|
||||
export default function Alert({ children, type = AlertType.ALERT }: Props) {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center border border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200 rounded-md py-2 scroll-px-40">
|
||||
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
<span className="text-center text-sm">{children}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-row items-center justify-center border rounded-md py-2 pr-2 scroll-px-40",
|
||||
{
|
||||
[yellow]: type === AlertType.ALERT,
|
||||
[neutral]: type === AlertType.INFO,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{type === AlertType.ALERT && (
|
||||
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
)}
|
||||
{type === AlertType.INFO && (
|
||||
<InformationCircleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
||||
)}
|
||||
<span className="text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
|
||||
export const methods = [
|
||||
{
|
||||
name: "Passkeys",
|
||||
description: "Authenticate with your device.",
|
||||
},
|
||||
{
|
||||
name: "Password",
|
||||
description: "Authenticate with a password",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AuthenticationMethodRadio({
|
||||
selected,
|
||||
selectionChanged,
|
||||
}: {
|
||||
selected: any;
|
||||
selectionChanged: (value: any) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<RadioGroup value={selected} onChange={selectionChanged}>
|
||||
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
|
||||
<div className="grid grid-cols-2 space-x-2">
|
||||
{methods.map((method) => (
|
||||
<RadioGroup.Option
|
||||
key={method.name}
|
||||
value={method}
|
||||
className={({ active, checked }) =>
|
||||
`${
|
||||
active
|
||||
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
|
||||
: "h-full "
|
||||
}
|
||||
${
|
||||
checked
|
||||
? "bg-background-light-400 dark:bg-background-dark-400"
|
||||
: "bg-background-light-400 dark:bg-background-dark-400"
|
||||
}
|
||||
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10`
|
||||
}
|
||||
>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm">
|
||||
<RadioGroup.Label
|
||||
as="p"
|
||||
className={`font-medium ${checked ? "" : ""}`}
|
||||
>
|
||||
{method.name}
|
||||
</RadioGroup.Label>
|
||||
<RadioGroup.Description
|
||||
as="span"
|
||||
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
|
||||
checked ? "" : ""
|
||||
}`}
|
||||
>
|
||||
{method.description}
|
||||
<span aria-hidden="true">·</span>{" "}
|
||||
</RadioGroup.Description>
|
||||
</div>
|
||||
</div>
|
||||
{checked && (
|
||||
<div className="shrink-0 text-white">
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon(props: any) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<circle
|
||||
className="fill-current text-black/50 dark:text-white/50"
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={12}
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M7 13l3 3 7-7"
|
||||
className="stroke-black dark:stroke-white"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
232
apps/login/ui/LoginPasskey.tsx
Normal file
232
apps/login/ui/LoginPasskey.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
187
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal file
187
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
apps/login/ui/RegisterPasskey.tsx
Normal file
206
apps/login/ui/RegisterPasskey.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
import Alert from "./Alert";
|
||||
import { RegisterPasskeyResponse } from "@zitadel/server";
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
|
||||
type Inputs = {};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
isPrompt: boolean;
|
||||
};
|
||||
|
||||
export default function RegisterPasskey({ sessionId, isPrompt }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitRegister() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const res = await fetch("/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>
|
||||
);
|
||||
}
|
||||
119
apps/login/ui/SessionItem.tsx
Normal file
119
apps/login/ui/SessionItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/login/ui/SessionsList.tsx
Normal file
35
apps/login/ui/SessionsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
apps/login/ui/SetPasswordForm.tsx
Normal file
191
apps/login/ui/SetPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/login/ui/SignInWithIDP.tsx
Normal file
132
apps/login/ui/SignInWithIDP.tsx
Normal 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";
|
||||
16
apps/login/ui/ThemeProvider.tsx
Normal file
16
apps/login/ui/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
63
apps/login/utils/base64.ts
Normal file
63
apps/login/utils/base64.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export function coerceToBase64Url(thing: any, name: string) {
|
||||
// Array or ArrayBuffer to Uint8Array
|
||||
if (Array.isArray(thing)) {
|
||||
thing = Uint8Array.from(thing);
|
||||
}
|
||||
|
||||
if (thing instanceof ArrayBuffer) {
|
||||
thing = new Uint8Array(thing);
|
||||
}
|
||||
|
||||
// Uint8Array to base64
|
||||
if (thing instanceof Uint8Array) {
|
||||
var str = "";
|
||||
var len = thing.byteLength;
|
||||
|
||||
for (var i = 0; i < len; i++) {
|
||||
str += String.fromCharCode(thing[i]);
|
||||
}
|
||||
thing = window.btoa(str);
|
||||
}
|
||||
|
||||
if (typeof thing !== "string") {
|
||||
throw new Error("could not coerce '" + name + "' to string");
|
||||
}
|
||||
|
||||
// base64 to base64url
|
||||
// NOTE: "=" at the end of challenge is optional, strip it off here
|
||||
thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
|
||||
|
||||
return thing;
|
||||
}
|
||||
|
||||
export function coerceToArrayBuffer(thing: any, name: string) {
|
||||
if (typeof thing === "string") {
|
||||
// base64url to base64
|
||||
thing = thing.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
// base64 to Uint8Array
|
||||
var str = window.atob(thing);
|
||||
var bytes = new Uint8Array(str.length);
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
thing = bytes;
|
||||
}
|
||||
|
||||
// Array to Uint8Array
|
||||
if (Array.isArray(thing)) {
|
||||
thing = new Uint8Array(thing);
|
||||
}
|
||||
|
||||
// Uint8Array to ArrayBuffer
|
||||
if (thing instanceof Uint8Array) {
|
||||
thing = thing.buffer;
|
||||
}
|
||||
|
||||
// error if none of the above worked
|
||||
if (!(thing instanceof ArrayBuffer)) {
|
||||
throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
|
||||
}
|
||||
|
||||
return thing;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
120
apps/login/utils/session.ts
Normal 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";
|
||||
}
|
||||
});
|
||||
}
|
||||
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
8
packages/zitadel-client/jest.config.ts
Normal file
8
packages/zitadel-client/jest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { JestConfigWithTsJest } from 'ts-jest'
|
||||
|
||||
const jestConfig: JestConfigWithTsJest = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node'
|
||||
}
|
||||
|
||||
export default jestConfig
|
||||
@@ -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"
|
||||
|
||||
57
packages/zitadel-client/src/middleware.test.ts
Normal file
57
packages/zitadel-client/src/middleware.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
13
packages/zitadel-client/tsup.config.ts
Normal file
13
packages/zitadel-client/tsup.config.ts
Normal 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,
|
||||
}));
|
||||
21
packages/zitadel-client/turbo.json
Normal file
21
packages/zitadel-client/turbo.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": [
|
||||
"//"
|
||||
],
|
||||
"pipeline": {
|
||||
"generate": {
|
||||
"outputs": [
|
||||
"src/proto/**"
|
||||
],
|
||||
"cache": true
|
||||
},
|
||||
"build": {
|
||||
"outputs": [
|
||||
"dist/**"
|
||||
],
|
||||
"dependsOn": [
|
||||
"generate"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/zitadel-next/jest.config.ts
Normal file
8
packages/zitadel-next/jest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { JestConfigWithTsJest } from 'ts-jest'
|
||||
|
||||
const jestConfig: JestConfigWithTsJest = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node'
|
||||
}
|
||||
|
||||
export default jestConfig
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/zitadel-next/postcss.config.js
Normal file
9
packages/zitadel-next/postcss.config.js
Normal 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
Reference in New Issue
Block a user