mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 08:23:16 +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
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
apps/login/.env.local.?.bak
|
apps/login/.env.local
|
||||||
apps/login/.env.local.??.bak
|
apps/login/.env.acceptance
|
||||||
.cache
|
.cache
|
||||||
server/dist
|
server/dist
|
||||||
public/dist
|
public/dist
|
||||||
.turbo
|
.turbo
|
||||||
packages/zitadel-server/src/app/proto
|
packages/zitadel-server/src/app/proto
|
||||||
packages/zitadel-client/src/app/proto
|
packages/zitadel-client/src/app/proto
|
||||||
|
.vscode
|
||||||
apps/login/.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
|
# Run ZITADEL and configure ./apps/login/.env.local
|
||||||
docker compose --file ./acceptance/docker-compose.yaml run setup
|
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
|
### 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
|
```sh
|
||||||
ZITADEL_API_URL=<your cloud instance URL here>
|
export ZITADEL_API_URL=<your cloud instance URL here>
|
||||||
ZITADEL_ORG_ID=<your service accounts organization id here>
|
export ZITADEL_ORG_ID=<your service accounts organization id here>
|
||||||
ZITADEL_SERVICE_USER_TOKEN=<your service account personal access token here>
|
export ZITADEL_SERVICE_USER_TOKEN=<your service account personal access token here>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setting up local environment
|
### Setting up local environment
|
||||||
@@ -75,4 +78,20 @@ pnpm dev
|
|||||||
|
|
||||||
The application is now available at `http://localhost:3000`
|
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! 🎉
|
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 generate` - Build proto stubs for server and client package
|
||||||
- `pnpm build` - Build all packages and the login app
|
- `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 dev` - Develop all packages and the login app
|
||||||
- `pnpm lint` - Lint all packages
|
- `pnpm lint` - Lint all packages
|
||||||
- `pnpm changeset` - Generate a changeset
|
- `pnpm changeset` - Generate a changeset
|
||||||
|
|||||||
@@ -1,49 +1,54 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
zitadel:
|
zitadel:
|
||||||
user: '${ZITADEL_DEV_UID}'
|
user: "${ZITADEL_DEV_UID}"
|
||||||
image: '${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}'
|
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}"
|
||||||
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
|
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./machinekey:/machinekey
|
- ./machinekey:/machinekey
|
||||||
- ./zitadel.yaml:/zitadel.yaml
|
- ./zitadel.yaml:/zitadel.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: 'service_healthy'
|
condition: "service_healthy"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: 'cockroachdb/cockroach:v22.2.2'
|
image: "cockroachdb/cockroach:v22.2.2"
|
||||||
command: 'start-single-node --insecure --http-addr :9090'
|
command: "start-single-node --insecure --http-addr :9090"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:9090/health?ready=1']
|
test: ["CMD", "curl", "-f", "http://localhost:9090/health?ready=1"]
|
||||||
interval: '10s'
|
interval: "10s"
|
||||||
timeout: '30s'
|
timeout: "30s"
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: '20s'
|
start_period: "20s"
|
||||||
ports:
|
ports:
|
||||||
- "26257:26257"
|
- "26257:26257"
|
||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
|
|
||||||
wait_for_zitadel:
|
wait_for_zitadel:
|
||||||
image: curlimages/curl:8.00.1
|
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:
|
depends_on:
|
||||||
- zitadel
|
- zitadel
|
||||||
|
|
||||||
setup:
|
setup:
|
||||||
user: '${ZITADEL_DEV_UID}'
|
user: "${ZITADEL_DEV_UID}"
|
||||||
container_name: setup
|
container_name: setup
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
KEY: /key/zitadel-admin-sa.json
|
KEY: /key/zitadel-admin-sa.json
|
||||||
SERVICE: http://zitadel:8080
|
SERVICE: http://zitadel:8080
|
||||||
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
|
WRITE_ENVIRONMENT_FILE: /apps/login/.env.acceptance
|
||||||
volumes:
|
volumes:
|
||||||
- "./machinekey:/key"
|
- "./machinekey:/key"
|
||||||
- "../apps/login:/apps/login"
|
- "../apps/login:/apps/login"
|
||||||
depends_on:
|
depends_on:
|
||||||
wait_for_zitadel:
|
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}
|
SERVICE=${SERVICE:-$AUDIENCE}
|
||||||
echo "Using the service ${SERVICE} to connect to ZITADEL. For example in docker compose this can differ from the 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."
|
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
|
||||||
|
|
||||||
AUDIENCE_HOST="$(echo $AUDIENCE | cut -d/ -f3)"
|
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')
|
ORG_ID=$(echo -n ${ORG_RESPONSE} | jq --raw-output '.org.id')
|
||||||
echo "Extracted default org id ${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}
|
echo "ZITADEL_API_URL=${AUDIENCE}
|
||||||
ZITADEL_ORG_ID=${ORG_ID}
|
ZITADEL_ORG_ID=${ORG_ID}
|
||||||
ZITADEL_SERVICE_USER_TOKEN=${TOKEN}" > ${WRITE_ENVIRONMENT_FILE}
|
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 = {
|
module.exports = {
|
||||||
extends: "next/core-web-vitals",
|
extends: ["next/core-web-vitals"],
|
||||||
ignorePatterns: ["external/**/*.ts"],
|
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 { listSessions, server } from "#/lib/zitadel";
|
||||||
import Alert from "#/ui/Alert";
|
import { getAllSessionCookieIds } from "#/utils/cookies";
|
||||||
import { Avatar } from "#/ui/Avatar";
|
import { UserPlusIcon } from "@heroicons/react/24/outline";
|
||||||
import { getAllSessionIds } from "#/utils/cookies";
|
|
||||||
import { UserPlusIcon, XCircleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import moment from "moment";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import SessionsList from "#/ui/SessionsList";
|
||||||
|
|
||||||
async function loadSessions(): Promise<Session[]> {
|
async function loadSessions(): Promise<Session[]> {
|
||||||
const ids = await getAllSessionIds();
|
const ids = await getAllSessionCookieIds();
|
||||||
|
|
||||||
if (ids && ids.length) {
|
if (ids && ids.length) {
|
||||||
const response = await listSessions(
|
const response = await listSessions(
|
||||||
@@ -22,8 +20,14 @@ async function loadSessions(): Promise<Session[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page({
|
||||||
const sessions = await loadSessions();
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Record<string | number | symbol, string | undefined>;
|
||||||
|
}) {
|
||||||
|
const authRequestId = searchParams?.authRequestId;
|
||||||
|
|
||||||
|
let sessions = await loadSessions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<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>
|
<p className="ztdl-p mb-6 block">Use your ZITADEL Account</p>
|
||||||
|
|
||||||
<div className="flex flex-col w-full space-y-2">
|
<div className="flex flex-col w-full space-y-2">
|
||||||
{sessions ? (
|
<SessionsList sessions={sessions} authRequestId={authRequestId} />
|
||||||
sessions
|
<Link href="/loginname">
|
||||||
.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">
|
|
||||||
<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="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">
|
<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" />
|
<UserPlusIcon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function Error({ error, reset }: any) {
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary labels={["Home page Error UI"]} color="red">
|
<Boundary labels={["Login Error"]} color="red">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm text-red-500 dark:text-red-500">
|
<div className="text-sm text-red-500 dark:text-red-500">
|
||||||
<strong className="font-bold">Error:</strong> {error?.message}
|
<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>;
|
searchParams: Record<string | number | symbol, string | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const { loginName } = searchParams;
|
const { loginName, promptPasswordless, authRequestId, alt } = searchParams;
|
||||||
const sessionFactors = await loadSession(loginName);
|
const sessionFactors = await loadSession(loginName);
|
||||||
|
|
||||||
async function loadSession(loginName?: string) {
|
async function loadSession(loginName?: string) {
|
||||||
@@ -38,13 +38,18 @@ export default async function Page({
|
|||||||
|
|
||||||
{sessionFactors && (
|
{sessionFactors && (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName ?? ""}
|
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||||
displayName={sessionFactors.factors?.user?.displayName}
|
displayName={sessionFactors.factors?.user?.displayName}
|
||||||
showDropdown
|
showDropdown
|
||||||
></UserAvatar>
|
></UserAvatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PasswordForm loginName={loginName} />
|
<PasswordForm
|
||||||
|
loginName={loginName}
|
||||||
|
authRequestId={authRequestId}
|
||||||
|
promptPasswordless={promptPasswordless === "true"}
|
||||||
|
isAlternative={alt === "true"}
|
||||||
|
/>
|
||||||
</div>
|
</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,
|
getPasswordComplexitySettings,
|
||||||
server,
|
server,
|
||||||
} from "#/lib/zitadel";
|
} 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 legal = await getLegalAndSupportSettings(server);
|
||||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||||
server
|
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">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||||
|
|
||||||
{legal && passwordComplexitySettings && (
|
{legal && passwordComplexitySettings && (
|
||||||
<RegisterForm
|
<RegisterFormWithoutPassword
|
||||||
legal={legal}
|
legal={legal}
|
||||||
passwordComplexitySettings={passwordComplexitySettings}
|
></RegisterFormWithoutPassword>
|
||||||
></RegisterForm>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 UserAvatar from "#/ui/UserAvatar";
|
||||||
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
|
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}`);
|
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) => {
|
return getSession(server, recent.id, recent.token).then((response) => {
|
||||||
if (response?.session) {
|
if (response?.session) {
|
||||||
return response.session;
|
return response.session;
|
||||||
@@ -13,8 +22,8 @@ async function loadSession(loginName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ searchParams }: { searchParams: any }) {
|
export default async function Page({ searchParams }: { searchParams: any }) {
|
||||||
const { loginName } = searchParams;
|
const { loginName, authRequestId } = searchParams;
|
||||||
const sessionFactors = await loadSession(loginName);
|
const sessionFactors = await loadSession(loginName, authRequestId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<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) {
|
if (body) {
|
||||||
const { email, password, firstName, lastName } = body;
|
const { email, password, firstName, lastName } = body;
|
||||||
|
|
||||||
const userId = await addHumanUser(server, {
|
return addHumanUser(server, {
|
||||||
email: email,
|
email: email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
password: password,
|
password: password ? password : undefined,
|
||||||
});
|
})
|
||||||
return NextResponse.json({ userId });
|
.then((userId) => {
|
||||||
|
return NextResponse.json({ userId });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return NextResponse.json(error, { status: 500 });
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.error();
|
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 { getBrandingSettings } from "#/lib/zitadel";
|
||||||
import { server } from "../lib/zitadel";
|
import { server } from "../lib/zitadel";
|
||||||
import { BrandingSettings } from "@zitadel/server";
|
import { BrandingSettings } from "@zitadel/server";
|
||||||
|
import ThemeProvider from "#/ui/ThemeProvider";
|
||||||
|
|
||||||
const lato = Lato({
|
const lato = Lato({
|
||||||
weight: ["400", "700", "900"],
|
weight: ["400", "700", "900"],
|
||||||
@@ -41,29 +42,31 @@ export default async function RootLayout({
|
|||||||
<head />
|
<head />
|
||||||
<body>
|
<body>
|
||||||
<ThemeWrapper branding={partial}>
|
<ThemeWrapper branding={partial}>
|
||||||
<LayoutProviders>
|
<ThemeProvider>
|
||||||
<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')]">
|
<LayoutProviders>
|
||||||
{showNav && <GlobalNav />}
|
<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={`${showNav ? "lg:pl-72" : ""} pb-4`}>
|
||||||
<div className="mx-auto max-w-[440px] space-y-8 pt-20 lg:py-8">
|
<div className="mx-auto max-w-[440px] space-y-8 pt-20 lg:py-8">
|
||||||
{showNav && (
|
{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-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">
|
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500">
|
||||||
<AddressBar domain={domain} />
|
<AddressBar domain={domain} />
|
||||||
|
</div>
|
||||||
</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-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">
|
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LayoutProviders>
|
||||||
</LayoutProviders>
|
</ThemeProvider>
|
||||||
</ThemeWrapper>
|
</ThemeWrapper>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</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;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum ProviderSlug {
|
||||||
|
GOOGLE = "google",
|
||||||
|
GITHUB = "github",
|
||||||
|
}
|
||||||
|
|
||||||
export const demos: { name: string; items: Item[] }[] = [
|
export const demos: { name: string; items: Item[] }[] = [
|
||||||
{
|
{
|
||||||
name: "Login",
|
name: "Login",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Username",
|
name: "Loginname",
|
||||||
slug: "username",
|
slug: "loginname",
|
||||||
description: "The entrypoint of the application",
|
description: "The entrypoint of the application",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -23,36 +28,11 @@ export const demos: { name: string; items: Item[] }[] = [
|
|||||||
slug: "accounts",
|
slug: "accounts",
|
||||||
description: "List active and inactive sessions",
|
description: "List active and inactive sessions",
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// name: "Set Password",
|
name: "Passkey Registration",
|
||||||
// slug: "password/set",
|
slug: "passkey/add",
|
||||||
// description: "The page to set a users password",
|
description: "The page to add a users passkey device",
|
||||||
// },
|
},
|
||||||
// {
|
|
||||||
// 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",
|
|
||||||
// },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,6 +43,11 @@ export const demos: { name: string; items: Item[] }[] = [
|
|||||||
slug: "register",
|
slug: "register",
|
||||||
description: "Create your ZITADEL account",
|
description: "Create your ZITADEL account",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "IDP Register",
|
||||||
|
slug: "register/idp",
|
||||||
|
description: "Register with an Identity Provider",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Verify email",
|
name: "Verify email",
|
||||||
slug: "verify",
|
slug: "verify",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
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() {
|
export function useUserData() {
|
||||||
const [clientData, setClientData] = useState(null);
|
const [clientData, setClientData] = useState(null);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ZitadelServer,
|
ZitadelServer,
|
||||||
ZitadelServerOptions,
|
ZitadelServerOptions,
|
||||||
user,
|
user,
|
||||||
|
oidc,
|
||||||
settings,
|
settings,
|
||||||
getServers,
|
getServers,
|
||||||
initializeServer,
|
initializeServer,
|
||||||
@@ -19,6 +20,22 @@ import {
|
|||||||
GetSessionResponse,
|
GetSessionResponse,
|
||||||
VerifyEmailResponse,
|
VerifyEmailResponse,
|
||||||
SetSessionResponse,
|
SetSessionResponse,
|
||||||
|
SetSessionRequest,
|
||||||
|
DeleteSessionResponse,
|
||||||
|
VerifyPasskeyRegistrationResponse,
|
||||||
|
LoginSettings,
|
||||||
|
GetLoginSettingsResponse,
|
||||||
|
ListAuthenticationMethodTypesResponse,
|
||||||
|
StartIdentityProviderIntentRequest,
|
||||||
|
StartIdentityProviderIntentResponse,
|
||||||
|
RetrieveIdentityProviderIntentRequest,
|
||||||
|
RetrieveIdentityProviderIntentResponse,
|
||||||
|
GetAuthRequestResponse,
|
||||||
|
GetAuthRequestRequest,
|
||||||
|
CreateCallbackRequest,
|
||||||
|
CreateCallbackResponse,
|
||||||
|
RequestChallenges,
|
||||||
|
AddHumanUserRequest,
|
||||||
} from "@zitadel/server";
|
} from "@zitadel/server";
|
||||||
|
|
||||||
export const zitadelConfig: ZitadelServerOptions = {
|
export const zitadelConfig: ZitadelServerOptions = {
|
||||||
@@ -34,7 +51,7 @@ if (!getServers().length) {
|
|||||||
server = initializeServer(zitadelConfig);
|
server = initializeServer(zitadelConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrandingSettings(
|
export async function getBrandingSettings(
|
||||||
server: ZitadelServer
|
server: ZitadelServer
|
||||||
): Promise<BrandingSettings | undefined> {
|
): Promise<BrandingSettings | undefined> {
|
||||||
const settingsService = settings.getSettings(server);
|
const settingsService = settings.getSettings(server);
|
||||||
@@ -43,7 +60,16 @@ export function getBrandingSettings(
|
|||||||
.then((resp: GetBrandingSettingsResponse) => resp.settings);
|
.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
|
server: ZitadelServer
|
||||||
): Promise<string[] | undefined> {
|
): Promise<string[] | undefined> {
|
||||||
const settingsService = settings.getSettings(server);
|
const settingsService = settings.getSettings(server);
|
||||||
@@ -52,7 +78,7 @@ export function getGeneralSettings(
|
|||||||
.then((resp: GetGeneralSettingsResponse) => resp.supportedLanguages);
|
.then((resp: GetGeneralSettingsResponse) => resp.supportedLanguages);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegalAndSupportSettings(
|
export async function getLegalAndSupportSettings(
|
||||||
server: ZitadelServer
|
server: ZitadelServer
|
||||||
): Promise<LegalAndSupportSettings | undefined> {
|
): Promise<LegalAndSupportSettings | undefined> {
|
||||||
const settingsService = settings.getSettings(server);
|
const settingsService = settings.getSettings(server);
|
||||||
@@ -63,7 +89,7 @@ export function getLegalAndSupportSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPasswordComplexitySettings(
|
export async function getPasswordComplexitySettings(
|
||||||
server: ZitadelServer
|
server: ZitadelServer
|
||||||
): Promise<PasswordComplexitySettings | undefined> {
|
): Promise<PasswordComplexitySettings | undefined> {
|
||||||
const settingsService = settings.getSettings(server);
|
const settingsService = settings.getSettings(server);
|
||||||
@@ -73,28 +99,58 @@ export function getPasswordComplexitySettings(
|
|||||||
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
|
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(
|
export async function createSession(
|
||||||
server: ZitadelServer,
|
server: ZitadelServer,
|
||||||
loginName: string
|
loginName: string,
|
||||||
|
password: string | undefined,
|
||||||
|
challenges: RequestChallenges | undefined
|
||||||
): Promise<CreateSessionResponse | undefined> {
|
): Promise<CreateSessionResponse | undefined> {
|
||||||
const sessionService = session.getSession(server);
|
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,
|
server: ZitadelServer,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
sessionToken: string,
|
sessionToken: string,
|
||||||
password: string
|
password: string | undefined,
|
||||||
|
webAuthN: { credentialAssertionData: any } | undefined,
|
||||||
|
challenges: RequestChallenges | undefined
|
||||||
): Promise<SetSessionResponse | undefined> {
|
): Promise<SetSessionResponse | undefined> {
|
||||||
const sessionService = session.getSession(server);
|
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,
|
server: ZitadelServer,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
@@ -103,7 +159,16 @@ export function getSession(
|
|||||||
return sessionService.getSession({ sessionId, sessionToken }, {});
|
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,
|
server: ZitadelServer,
|
||||||
ids: string[]
|
ids: string[]
|
||||||
): Promise<ListSessionsResponse | undefined> {
|
): Promise<ListSessionsResponse | undefined> {
|
||||||
@@ -117,22 +182,28 @@ export type AddHumanUserData = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function addHumanUser(
|
export async function addHumanUser(
|
||||||
server: ZitadelServer,
|
server: ZitadelServer,
|
||||||
{ email, firstName, lastName, password }: AddHumanUserData
|
{ email, firstName, lastName, password }: AddHumanUserData
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const mgmt = user.getUser(server);
|
const userService = user.getUser(server);
|
||||||
return mgmt
|
|
||||||
|
const payload: Partial<AddHumanUserRequest> = {
|
||||||
|
email: { email },
|
||||||
|
username: email,
|
||||||
|
profile: { givenName: firstName, familyName: lastName },
|
||||||
|
};
|
||||||
|
return userService
|
||||||
.addHumanUser(
|
.addHumanUser(
|
||||||
{
|
password
|
||||||
email: { email },
|
? {
|
||||||
username: email,
|
...payload,
|
||||||
profile: { firstName, lastName },
|
password: { password },
|
||||||
password: { password },
|
}
|
||||||
},
|
: payload,
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
.then((resp: AddHumanUserResponse) => {
|
.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,
|
server: ZitadelServer,
|
||||||
userId: string,
|
userId: string,
|
||||||
verificationCode: string
|
verificationCode: string
|
||||||
@@ -161,7 +276,10 @@ export function verifyEmail(
|
|||||||
* @param userId the id of the user where the email should be set
|
* @param userId the id of the user where the email should be set
|
||||||
* @returns the newly set email
|
* @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);
|
const userservice = user.getUser(server);
|
||||||
return userservice.setEmail(
|
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 };
|
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: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: process.env.ZITADEL_API_URL.replace("https://", ""),
|
hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "",
|
||||||
port: "",
|
port: "",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,28 @@
|
|||||||
"name": "@zitadel/login",
|
"name": "@zitadel/login",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
|
||||||
"dev": "next dev",
|
"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": "next lint && prettier --check .",
|
||||||
"lint:fix": "prettier --write .",
|
"lint:fix": "prettier --write .",
|
||||||
"lint-staged": "lint-staged",
|
"lint-staged": "lint-staged",
|
||||||
|
"build": "next build",
|
||||||
|
"prestart": "pnpm build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "yarn prettier:check &nexarn lint",
|
"clean": "pnpm mock:destroy && rm -rf .turbo && rm -rf node_modules && rm -rf .next"
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next"
|
|
||||||
},
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"pre-commit": "lint-staged"
|
"pre-commit": "lint-staged"
|
||||||
@@ -22,41 +36,57 @@
|
|||||||
"@heroicons/react": "2.0.13",
|
"@heroicons/react": "2.0.13",
|
||||||
"@tailwindcss/forms": "0.5.3",
|
"@tailwindcss/forms": "0.5.3",
|
||||||
"@vercel/analytics": "^1.0.0",
|
"@vercel/analytics": "^1.0.0",
|
||||||
"@zitadel/next": "workspace:*",
|
"@zitadel/client": "workspace:*",
|
||||||
"@zitadel/react": "workspace:*",
|
"@zitadel/react": "workspace:*",
|
||||||
"@zitadel/server": "workspace:*",
|
"@zitadel/server": "workspace:*",
|
||||||
"clsx": "1.2.1",
|
"clsx": "1.2.1",
|
||||||
"date-fns": "2.29.3",
|
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "13.4.2",
|
"next": "13.4.12",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nice-grpc": "2.0.1",
|
"nice-grpc": "2.0.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "7.39.5",
|
"react-hook-form": "7.39.5",
|
||||||
"sass": "^1.62.0",
|
"sass": "^1.62.0",
|
||||||
|
"swr": "^2.2.0",
|
||||||
"tinycolor2": "1.4.2"
|
"tinycolor2": "1.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.14.0",
|
"@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/ms": "0.7.31",
|
||||||
"@types/node": "18.11.9",
|
"@types/node": "18.11.9",
|
||||||
"@types/react": "18.0.25",
|
"@types/react": "18.2.8",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.0.9",
|
||||||
|
"@types/testing-library__jest-dom": "^5.14.6",
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
|
"@types/uuid": "^9.0.1",
|
||||||
"@vercel/git-hooks": "1.0.0",
|
"@vercel/git-hooks": "1.0.0",
|
||||||
"@zitadel/tsconfig": "workspace:*",
|
"@zitadel/tsconfig": "workspace:*",
|
||||||
"autoprefixer": "10.4.13",
|
"autoprefixer": "10.4.13",
|
||||||
|
"concurrently": "^8.1.0",
|
||||||
|
"cypress": "^12.14.0",
|
||||||
"del-cli": "5.0.0",
|
"del-cli": "5.0.0",
|
||||||
|
"env-cmd": "^10.1.0",
|
||||||
"eslint-config-zitadel": "workspace:*",
|
"eslint-config-zitadel": "workspace:*",
|
||||||
"grpc-tools": "1.11.3",
|
"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",
|
"lint-staged": "13.0.3",
|
||||||
"make-dir-cli": "3.0.0",
|
"make-dir-cli": "3.0.0",
|
||||||
|
"nodemon": "^2.0.22",
|
||||||
"postcss": "8.4.21",
|
"postcss": "8.4.21",
|
||||||
"prettier-plugin-tailwindcss": "0.1.13",
|
"prettier-plugin-tailwindcss": "0.1.13",
|
||||||
|
"start-server-and-test": "^2.0.0",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "3.2.4",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
"ts-proto": "^1.139.0",
|
"ts-proto": "^1.139.0",
|
||||||
"typescript": "4.8.4",
|
"typescript": "5.0.4",
|
||||||
"zitadel-tailwind-config": "workspace:*"
|
"zitadel-tailwind-config": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,11 @@ The Login UI should provide the following functionality:
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
https://beta.nextjs.org/docs
|
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 {
|
@layer base {
|
||||||
h1,
|
h1,
|
||||||
.ztdl-h1 {
|
.ztdl-h1 {
|
||||||
@apply text-2xl;
|
@apply text-2xl text-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ztdl-p {
|
.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",
|
"extends": "@zitadel/tsconfig/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"jsx": "preserve",
|
||||||
|
"rootDir": ".",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"#/*": ["./*"]
|
"#/*": ["./*"]
|
||||||
},
|
},
|
||||||
"plugins": [{ "name": "next" }]
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"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();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
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">
|
<div className="text-gray-600">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -27,8 +27,8 @@ export function AddressBar({ domain }: Props) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-1 text-sm font-medium">
|
<div className="flex space-x-1 text-sm font-medium">
|
||||||
<div>
|
<div className="max-w-[150px] px-2 overflow-hidden text-gray-500 text-ellipsis">
|
||||||
<span className="px-2 text-gray-500">{domain}</span>
|
<span className="whitespace-nowrap">{domain}</span>
|
||||||
</div>
|
</div>
|
||||||
{pathname ? (
|
{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 = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
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 (
|
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">
|
<div
|
||||||
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
|
className={clsx(
|
||||||
<span className="text-center text-sm">{children}</span>
|
"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>
|
</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, {
|
import React, {
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
DetailedHTMLProps,
|
DetailedHTMLProps,
|
||||||
|
ReactNode,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
@@ -65,16 +66,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => (
|
||||||
return (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
ref={ref}
|
||||||
ref={ref}
|
className={`${getButtonClasses(size, variant, color)} ${className}`}
|
||||||
className={`${getButtonClasses(size, variant, color)} ${className}`}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</button>
|
||||||
</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";
|
"use client";
|
||||||
|
import { ZitadelReactProvider } from "@zitadel/react";
|
||||||
import { ThemeProvider, useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LayoutProviders({ children }: Props) {
|
export function LayoutProviders({ children }: Props) {
|
||||||
// const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isDark = false; //resolvedTheme && resolvedTheme === "dark";
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// console.log("layoutproviders useeffect");
|
|
||||||
// setTheme(document);
|
|
||||||
// });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<div className={`${isDark ? "ui-dark" : "ui-light"} `}>
|
||||||
attribute="class"
|
<ZitadelReactProvider dark={isDark}>{children}</ZitadelReactProvider>
|
||||||
defaultTheme="system"
|
</div>
|
||||||
storageKey="cp-theme"
|
|
||||||
value={{ dark: "dark" }}
|
|
||||||
>
|
|
||||||
<div className={`${isDark ? "ui-dark" : "ui-light"} `}>{children}</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-6 h-6 las la-check text-green-500 dark:text-green-500 mr-2 text-lg"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -36,7 +38,9 @@ const cross = (
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
role="img"
|
||||||
>
|
>
|
||||||
|
<title>Doesn't match</title>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -60,12 +64,16 @@ export default function PasswordComplexity({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 grid grid-cols-2 gap-x-8 gap-y-2">
|
<div className="mb-4 grid grid-cols-2 gap-x-8 gap-y-2">
|
||||||
<div className="flex flex-row items-center">
|
{passwordComplexitySettings.minLength != undefined ? (
|
||||||
{hasMinLength ? check : cross}
|
<div className="flex flex-row items-center">
|
||||||
<span className={desc}>
|
{hasMinLength ? check : cross}
|
||||||
Password length {passwordComplexitySettings.minLength}
|
<span className={desc}>
|
||||||
</span>
|
Password length {passwordComplexitySettings.minLength}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
{hasSymbol ? check : cross}
|
{hasSymbol ? check : cross}
|
||||||
<span className={desc}>has Symbol</span>
|
<span className={desc}>has Symbol</span>
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ type Inputs = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
loginName?: string;
|
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>({
|
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
});
|
});
|
||||||
@@ -30,13 +38,15 @@ export default function PasswordForm({ loginName }: Props) {
|
|||||||
async function submitPassword(values: Inputs) {
|
async function submitPassword(values: Inputs) {
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch("/session", {
|
const res = await fetch("/api/session", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
loginName,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
|
authRequestId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +62,34 @@ export default function PasswordForm({ loginName }: Props) {
|
|||||||
|
|
||||||
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
|
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
|
||||||
return submitPassword(value).then((resp: any) => {
|
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
|
<Checkbox
|
||||||
className="mr-4"
|
className="mr-4"
|
||||||
checked={false}
|
checked={false}
|
||||||
|
value={"privacypolicy"}
|
||||||
onChangeVal={(checked: boolean) => {
|
onChangeVal={(checked: boolean) => {
|
||||||
setAcceptanceState({
|
setAcceptanceState({
|
||||||
...acceptanceState,
|
...acceptanceState,
|
||||||
@@ -74,6 +75,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
className="mr-4"
|
className="mr-4"
|
||||||
checked={false}
|
checked={false}
|
||||||
|
value={"tos"}
|
||||||
onChangeVal={(checked: boolean) => {
|
onChangeVal={(checked: boolean) => {
|
||||||
setAcceptanceState({
|
setAcceptanceState({
|
||||||
...acceptanceState,
|
...acceptanceState,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
async function submitRegister(values: Inputs) {
|
async function submitRegister(values: Inputs) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch("/registeruser", {
|
const res = await fetch("/api/registeruser", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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";
|
import Link from "next/link";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
loginName: string;
|
loginName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
showDropdown: boolean;
|
showDropdown: boolean;
|
||||||
};
|
};
|
||||||
@@ -14,12 +14,12 @@ export default function UserAvatar({
|
|||||||
showDropdown,
|
showDropdown,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
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>
|
<div>
|
||||||
<Avatar
|
<Avatar
|
||||||
size="small"
|
size="small"
|
||||||
name={displayName ?? loginName}
|
name={displayName ?? loginName ?? ""}
|
||||||
loginName={loginName}
|
loginName={loginName ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-4 text-14px">{loginName}</span>
|
<span className="ml-4 text-14px">{loginName}</span>
|
||||||
@@ -27,7 +27,7 @@ export default function UserAvatar({
|
|||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
<Link
|
<Link
|
||||||
href="/accounts"
|
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" />
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,55 +1,146 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, ButtonVariants } from "./Button";
|
import { Button, ButtonVariants } from "./Button";
|
||||||
import { TextInput } from "./Input";
|
import { TextInput } from "./Input";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Spinner } from "./Spinner";
|
import { Spinner } from "./Spinner";
|
||||||
|
import { LoginSettings } from "@zitadel/server";
|
||||||
|
import Alert from "./Alert";
|
||||||
|
|
||||||
type Inputs = {
|
type Inputs = {
|
||||||
loginName: string;
|
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>({
|
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
|
defaultValues: {
|
||||||
|
loginName: loginName ? loginName : "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
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);
|
setLoading(true);
|
||||||
const res = await fetch("/session", {
|
|
||||||
|
const body = {
|
||||||
|
loginName: values.loginName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/loginname", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(authRequestId ? { ...body, authRequestId } : body),
|
||||||
loginName: values.loginName,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!res.ok) {
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitUsernameAndContinue(value: Inputs): Promise<boolean | void> {
|
async function setLoginNameAndGetAuthMethods(values: Inputs) {
|
||||||
return submitUsername(value).then(({ factors }) => {
|
return submitLoginName(values).then((response) => {
|
||||||
return router.push(
|
if (response.authMethodTypes.length == 1) {
|
||||||
`/password?` +
|
const method = response.authMethodTypes[0];
|
||||||
new URLSearchParams({ loginName: `${factors.user.loginName}` })
|
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;
|
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 (
|
return (
|
||||||
<form className="w-full">
|
<form className="w-full">
|
||||||
<div className="">
|
<div className="">
|
||||||
@@ -58,21 +149,23 @@ export default function UsernameForm() {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
{...register("loginName", { required: "This field is required" })}
|
{...register("loginName", { required: "This field is required" })}
|
||||||
label="Loginname"
|
label="Loginname"
|
||||||
// error={errors.username?.message as string}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="py-4">
|
||||||
|
<Alert>{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-row items-center">
|
<div className="mt-8 flex w-full flex-row items-center">
|
||||||
{/* <Button type="button" variant={ButtonVariants.Secondary}>
|
|
||||||
back
|
|
||||||
</Button> */}
|
|
||||||
<span className="flex-grow"></span>
|
<span className="flex-grow"></span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="self-end"
|
className="self-end"
|
||||||
variant={ButtonVariants.Primary}
|
variant={ButtonVariants.Primary}
|
||||||
disabled={loading || !formState.isValid}
|
disabled={loading || !formState.isValid}
|
||||||
onClick={handleSubmit(submitUsernameAndContinue)}
|
onClick={handleSubmit(setLoginNameAndGetAuthMethods)}
|
||||||
>
|
>
|
||||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submit && code && userId) {
|
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) {
|
async function submitCode(values: Inputs) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch("/verifyemail", {
|
const res = await fetch("/api/verifyemail", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -53,19 +55,18 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
|
|||||||
|
|
||||||
const response = await res.json();
|
const response = await res.json();
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setLoading(false);
|
|
||||||
setError(response.details);
|
setError(response.details);
|
||||||
return Promise.reject(response);
|
return Promise.reject(response);
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resendCode() {
|
async function resendCode() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch("/resendverifyemail", {
|
const res = await fetch("/api/resendverifyemail", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -86,7 +87,7 @@ export default function VerifyEmailForm({ userId, code, submit }: Props) {
|
|||||||
|
|
||||||
function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
||||||
return submitCode(value).then((resp: any) => {
|
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>) {
|
export function setTheme(document: any, policy?: Partial<BrandingSettings>) {
|
||||||
const lP: BrandingColors = {
|
const lP: BrandingColors = {
|
||||||
lightTheme: {
|
lightTheme: {
|
||||||
backgroundColor: policy?.lightTheme?.backgroundColor ?? BACKGROUND,
|
backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND,
|
||||||
fontColor: policy?.lightTheme?.fontColor ?? TEXT,
|
fontColor: policy?.lightTheme?.fontColor || TEXT,
|
||||||
primaryColor: policy?.lightTheme?.primaryColor ?? PRIMARY,
|
primaryColor: policy?.lightTheme?.primaryColor || PRIMARY,
|
||||||
warnColor: policy?.lightTheme?.warnColor ?? WARN,
|
warnColor: policy?.lightTheme?.warnColor || WARN,
|
||||||
},
|
},
|
||||||
darkTheme: {
|
darkTheme: {
|
||||||
backgroundColor: policy?.darkTheme?.backgroundColor ?? DARK_BACKGROUND,
|
backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND,
|
||||||
fontColor: policy?.darkTheme?.fontColor ?? DARK_TEXT,
|
fontColor: policy?.darkTheme?.fontColor || DARK_TEXT,
|
||||||
primaryColor: policy?.darkTheme?.primaryColor ?? DARK_PRIMARY,
|
primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY,
|
||||||
warnColor: policy?.darkTheme?.warnColor ?? DARK_WARN,
|
warnColor: policy?.darkTheme?.warnColor || DARK_WARN,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SessionCookie = {
|
|||||||
token: string;
|
token: string;
|
||||||
loginName: string;
|
loginName: string;
|
||||||
changeDate: string;
|
changeDate: string;
|
||||||
|
authRequestId?: string; // if its linked to an OIDC flow
|
||||||
};
|
};
|
||||||
|
|
||||||
function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
|
function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
|
||||||
@@ -19,6 +20,7 @@ function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
|
|||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addSessionToCookie(session: SessionCookie): Promise<any> {
|
export async function addSessionToCookie(session: SessionCookie): Promise<any> {
|
||||||
const cookiesList = cookies();
|
const cookiesList = cookies();
|
||||||
const stringifiedCookie = cookiesList.get("sessions");
|
const stringifiedCookie = cookiesList.get("sessions");
|
||||||
@@ -37,7 +39,7 @@ export async function addSessionToCookie(session: SessionCookie): Promise<any> {
|
|||||||
currentSessions = [...currentSessions, session];
|
currentSessions = [...currentSessions, session];
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionHttpOnlyCookie(currentSessions);
|
return setSessionHttpOnlyCookie(currentSessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSessionCookie(
|
export async function updateSessionCookie(
|
||||||
@@ -52,9 +54,12 @@ export async function updateSessionCookie(
|
|||||||
: [session];
|
: [session];
|
||||||
|
|
||||||
const foundIndex = sessions.findIndex((session) => session.id === id);
|
const foundIndex = sessions.findIndex((session) => session.id === id);
|
||||||
sessions[foundIndex] = session;
|
if (foundIndex > -1) {
|
||||||
|
sessions[foundIndex] = session;
|
||||||
return setSessionHttpOnlyCookie(sessions);
|
return setSessionHttpOnlyCookie(sessions);
|
||||||
|
} else {
|
||||||
|
throw "updateSessionCookie: session id now found";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSessionFromCookie(
|
export async function removeSessionFromCookie(
|
||||||
@@ -87,12 +92,50 @@ export async function getMostRecentSessionCookie(): Promise<any> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return latest;
|
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 {
|
} else {
|
||||||
return Promise.reject();
|
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 cookiesList = cookies();
|
||||||
const stringifiedCookie = cookiesList.get("sessions");
|
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
|
* Returns most recent session filtered by optinal loginName
|
||||||
* @param loginName
|
* @param loginName
|
||||||
@@ -117,7 +172,6 @@ export async function getMostRecentCookieWithLoginname(
|
|||||||
|
|
||||||
if (stringifiedCookie?.value) {
|
if (stringifiedCookie?.value) {
|
||||||
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
|
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
|
||||||
|
|
||||||
const filtered = sessions.filter((cookie) => {
|
const filtered = sessions.filter((cookie) => {
|
||||||
return !!loginName ? cookie.loginName === loginName : true;
|
return !!loginName ? cookie.loginName === loginName : true;
|
||||||
});
|
});
|
||||||
@@ -135,10 +189,10 @@ export async function getMostRecentCookieWithLoginname(
|
|||||||
if (latest) {
|
if (latest) {
|
||||||
return latest;
|
return latest;
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject();
|
return Promise.reject("Could not get the context or retrieve a session");
|
||||||
}
|
}
|
||||||
} else {
|
} 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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"generate": "turbo run generate",
|
||||||
"build": "turbo run build",
|
"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",
|
"dev": "turbo run dev --no-cache --continue",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
|
"lint:fix": "turbo run lint:fix",
|
||||||
"clean": "turbo run clean && rm -rf node_modules",
|
"clean": "turbo run clean && rm -rf node_modules",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"release": "turbo run build --filter=login^... && changeset publish",
|
"release": "turbo run build --filter=login^... && changeset publish"
|
||||||
"prebuild": "turbo run generate",
|
|
||||||
"generate": "turbo run generate"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.22.0",
|
"@changesets/cli": "^2.22.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-zitadel": "workspace:*",
|
"eslint-config-zitadel": "workspace:*",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"turbo": "^1.9.8"
|
"turbo": "^1.10.8"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@7.15.0"
|
"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/**"
|
"dist/**"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm,cjs --dts",
|
"generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel",
|
||||||
"dev": "tsup src/index.ts --format esm,cjs --watch --dts",
|
"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*\"",
|
"lint": "eslint \"src/**/*.ts*\"",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf src/proto"
|
||||||
"generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/buf": "^1.14.0",
|
"@bufbuild/buf": "^1.14.0",
|
||||||
|
"@types/jest": "^29.5.1",
|
||||||
"@zitadel/tsconfig": "workspace:*",
|
"@zitadel/tsconfig": "workspace:*",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-zitadel": "workspace:*",
|
"eslint-config-zitadel": "workspace:*",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
"ts-proto": "^1.139.0",
|
"ts-proto": "^1.139.0",
|
||||||
"tsup": "^5.10.1",
|
"tsup": "^5.10.1",
|
||||||
"typescript": "^4.5.3"
|
"typescript": "^4.9.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"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/**"
|
"dist/**"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
|
"build": "tsup",
|
||||||
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
|
"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*\"",
|
"lint": "eslint \"src/**/*.ts*\"",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.1",
|
||||||
|
"@types/react": "^17.0.13",
|
||||||
"@zitadel/tsconfig": "workspace:*",
|
"@zitadel/tsconfig": "workspace:*",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-zitadel": "workspace:*",
|
"eslint-config-zitadel": "workspace:*",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
"tsup": "^5.10.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": {
|
"peerDependencies": {
|
||||||
"next": "^13"
|
"@zitadel/react": "workspace:*",
|
||||||
|
"@zitadel/server": "workspace:*",
|
||||||
|
"next": "^13",
|
||||||
|
"react": "18.2.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"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