Merge branch 'main' into dependabot/github_actions/actions/add-to-project-1.0.2

This commit is contained in:
Max Peintner
2025-01-03 11:41:04 +01:00
committed by GitHub
316 changed files with 16944 additions and 8646 deletions

View File

@@ -7,6 +7,7 @@
- [ ] 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.
- [ ] Vitest 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.
- [ ] Cypress integration tests ensure that login app pages work as expected on good and bad user inputs, ZITADEL responses or IDP redirects. The ZITADEL API is mocked, IDP redirects are simulated.
- [ ] Playwright acceptances tests ensure that the happy paths of common user journeys work as expected. The ZITADEL API is not mocked but IDP redirects are simulated.
- [ ] No debug or dead code
- [ ] My code has no repetitions

View File

@@ -1,8 +1,39 @@
name: Quality
on: pull_request
on:
pull_request:
schedule:
# Every morning at 6:00 AM CET
- cron: '0 4 * * *'
workflow_dispatch:
inputs:
target-env:
description: 'Zitadel target environment to run the acceptance tests against.'
required: true
type: choice
options:
- 'qa'
- 'prod'
jobs:
matrix:
# If the workflow is triggered by a schedule event, only the acceptance tests run against QA and Prod.
name: Matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Matrix
id: matrix
run: |
if [ -n "${{ github.event.schedule }}" ]; then
echo 'matrix=["test:acceptance:qa", "test:acceptance:prod"]' >> $GITHUB_OUTPUT
elif [ -n "${{ github.event.inputs.target-env }}" ]; then
echo 'matrix=["test:acceptance:${{ github.event.inputs.target-env }}"]' >> $GITHUB_OUTPUT
else
echo 'matrix=["format --check", "lint", "test:unit", "test:integration", "test:acceptance"]' >> $GITHUB_OUTPUT
fi
quality:
name: Ensure Quality
@@ -13,46 +44,29 @@ jobs:
permissions:
contents: "read"
needs:
- matrix
strategy:
fail-fast: false
matrix:
command:
- format --check
- lint
- test:unit
- test:integration
command: ${{ fromJson( needs.matrix.outputs.matrix ) }}
steps:
- name: Checkout Repo
uses: actions/checkout@v4.1.6
- name: Setup Node.js 20.x
uses: actions/setup-node@v4.0.2
with:
node-version: 20.x
- name: Setup Buf
uses: bufbuild/buf-setup-action@v1.45.0
- name: Setup pnpm
uses: pnpm/action-setup@v4.0.0
- uses: pnpm/action-setup@v4.0.0
name: Install pnpm
id: pnpm-install
- name: Setup Node.js 20.x
uses: actions/setup-node@v4.0.2
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4.0.2
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version: 20.x
cache: 'pnpm'
- uses: actions/cache@v4.0.2
name: Setup Cypress binary cache
@@ -61,11 +75,52 @@ jobs:
key: ${{ runner.os }}-cypress-binary-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-cypress-binary-
if: ${{ matrix.command }} == "test:integration"
# The Cypress binary cache needs to be updated together with the pnpm dependencies cache.
# That's why we don't conditionally cache it using if: ${{ matrix.command == 'test:integration' }}
- name: Install Dependencies
run: pnpm install
run: pnpm install --frozen-lockfile
# We can cache the Playwright binary independently from the pnpm cache, because we install it separately.
# After pnpm install --frozen-lockfile, we can get the version so we only have to download the binary once per version.
- run: echo "PLAYWRIGHT_VERSION=$(npx playwright --version | cut -d ' ' -f 2)" >> $GITHUB_ENV
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- uses: actions/cache@v4.0.2
name: Setup Playwright binary cache
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-binary-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-binary-
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
if: ${{ startsWith(matrix.command, 'test:acceptance') && steps.playwright-cache.outputs.cache-hit != 'true' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
if: ${{ matrix.command == 'test:acceptance' }}
- name: Run ZITADEL
run: ZITADEL_DEV_UID=root pnpm run-sink
if: ${{ matrix.command == 'test:acceptance' }}
- name: Create Cloud Env File
run: |
if [ "${{ matrix.command }}" == "test:acceptance:prod" ]; then
echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_PROD }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null
else
echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_QA }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null
fi
if: ${{ matrix.command == 'test:acceptance:qa' || matrix.command == 'test:acceptance:prod' }}
- name: Create Production Build
run: pnpm build
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
- name: Check
id: check
run: pnpm ${{ matrix.command }}
run: pnpm ${{ contains(matrix.command, 'test:acceptance') && 'test:acceptance' || matrix.command }}

4
.gitignore vendored
View File

@@ -18,3 +18,7 @@ packages/zitadel-server/src/app/proto
.idea
.vercel
.env*.local
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -1,3 +1,8 @@
.next/
.changeset/
.github/
dist/
packages/zitadel-proto/google
packages/zitadel-proto/protoc-gen-openapiv2
packages/zitadel-proto/validate
packages/zitadel-proto/zitadel

View File

@@ -3,3 +3,4 @@
"trailingComma": "all",
"plugins": ["prettier-plugin-organize-imports"]
}

View File

@@ -84,10 +84,9 @@ The application is now available at `http://localhost:3000`
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-proto
- packages/zitadel-client
- packages/zitadel-server
- packages/zitadel-react
- packages/zitadel-next
- packages/zitadel-node
- 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.

173
README.md
View File

@@ -1,14 +1,16 @@
# ZITADEL TypeScript with Turborepo and Changesets
# ZITADEL TypeScript with Turborepo
This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL
Login UI.
The repo makes use of the [build system Turbo](https://turbo.build/repo) and
the [Changesets CLI for versioning the packages](https://github.com/changesets/changesets).
<img src="./apps/login/screenshots/collage.png" alt="collage of login screens" width="1600px" />
**⚠️ This repo and packages are in alpha state and subject to change ⚠️**
The scope of functionality of this repo and packages is limited and under active development.
Once the package structure is set and all APIs are fully implemented we'll move this repo to beta state.
The scope of functionality of this repo and packages is under active development.
The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme).
You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute.
Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as
a [GitHub issue](https://github.com/zitadel/typescript/issues).
@@ -28,11 +30,8 @@ We think the easiest path of getting up and running, is the following:
## Included Apps And Packages
- `login`: The login UI used by ZITADEL Cloud, powered by Next.js
- `@zitadel/node`: core components for establishing node client connection, grpc stub
- `@zitadel/client`: shared client utilities
- `@zitadel/client`: shared client utilities for node and browser environments
- `@zitadel/proto`: shared protobuf types
- `@zitadel/react`: shared React utilities and components built with tailwindcss
- `@zitadel/next`: shared Next.js utilities
- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo
- `eslint-config-zitadel`: ESLint preset
@@ -50,17 +49,19 @@ the features will be extended.
This list should show the current implementation state, and also what is missing.
You can already use the current state, and extend it with your needs.
#### Features list
- [x] Local User Registration (with Password)
- [x] User Registration and Login with external Provider
- [x] Google
- [ ] GitHub
- [ ] GitHub Enterprise
- [x] GitHub
- [x] GitHub Enterprise
- [x] GitLab
- [ ] GitLab Enterprise
- [ ] Azure
- [ ] Apple
- [ ] Generic OIDC
- [ ] Generic OAuth
- [x] GitLab Enterprise
- [x] Azure
- [x] Apple
- [x] Generic OIDC
- [x] Generic OAuth
- [ ] Generic JWT
- [ ] LDAP
- [ ] SAML SP
@@ -70,9 +71,10 @@ You can already use the current state, and extend it with your needs.
- [x] OTP: Email Code
- [x] OTP: SMS Code
- [ ] Password Change/Reset
- [ ] Domain Discovery
- [x] Domain Discovery
- [x] Branding
- OIDC Standard
- [x] Authorization Code Flow with PKCE
- [x] AuthRequest `hintUserId`
- [x] AuthRequest `loginHint`
@@ -84,12 +86,60 @@ You can already use the current state, and extend it with your needs.
- Scopes
- [x] `openid email profile address``
- [x] `offline access`
- [ ] `urn:zitadel:iam:org:idp:id:{idp_id}`
- [x] `urn:zitadel:iam:org:idp:id:{idp_id}`
- [x] `urn:zitadel:iam:org:project:id:zitadel:aud`
- [x] `urn:zitadel:iam:org:id:{orgid}`
- [x] `urn:zitadel:iam:org:domain:primary:{domain}`
- [ ] AuthRequest UI locales
#### Flow diagram
This diagram shows the available pages and flows.
> Note that back navigation or retries are not displayed.
```mermaid
flowchart TD
A[Start] --> register
A[Start] --> accounts
A[Start] --> loginname
loginname -- signInWithIDP --> idp-success
loginname -- signInWithIDP --> idp-failure
idp-success --> B[signedin]
loginname --> password
loginname -- hasPasskey --> passkey
loginname -- allowRegister --> register
passkey-add --passwordAllowed --> password
passkey -- hasPassword --> password
passkey --> B[signedin]
password -- hasMFA --> mfa
password -- allowPasskeys --> passkey-add
password -- reset --> password-set
email -- reset --> password-set
password-set --> B[signedin]
password-change --> B[signedin]
password -- userstate=initial --> password-change
mfa --> otp
otp --> B[signedin]
mfa--> u2f
u2f -->B[signedin]
register -- password/passkey --> B[signedin]
password --> B[signedin]
password-- forceMFA -->mfaset
mfaset --> u2fset
mfaset --> otpset
u2fset --> B[signedin]
otpset --> B[signedin]
accounts--> loginname
password -- not verified yet -->verify
register-- withpassword -->verify
passkey-- notVerified --> verify
verify --> B[signedin]
```
You can find a more detailed documentation of the different pages [here](./apps/login/readme.md).
## Tooling
- [TypeScript](https://www.typescriptlang.org/) for static type checking
@@ -119,22 +169,6 @@ settings. The [Changesets bot](https://github.com/apps/changeset-bot) should als
Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md)
for more information about this automation
### NPM
If you want to publish a package to the public npm registry and make them publicly available, this is already setup.
To publish packages to a private npm organization scope, **remove** the following from each of the `package.json`'s
```diff
- "publishConfig": {
- "access": "public"
- },
```
### GitHub Package Registry
See [working with the npm registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#publishing-a-package-using-publishconfig-in-the-packagejson-file)
### Run Login UI
To run the application make sure to install the dependencies with
@@ -143,32 +177,75 @@ To run the application make sure to install the dependencies with
pnpm install
```
then setup the environment for the login application which needs a `.env.local` in `/apps/login`.
Go to your instance and create a service user for the application having the `IAM_OWNER` manager role.
This user is required to have access to create users on your primary organization and reading policy data so it can be
restricted to your personal use case but we'll stick with `IAM_OWNER` for convenience. Create a PAT and copy the value to
paste it under the `ZITADEL_SERVICE_USER_TOKEN` key.
The file should look as follows:
```
ZITADEL_API_URL=[yourinstanceurl]
ZITADEL_ORG_ID=[yourprimaryorg]
ZITADEL_SERVICE_USER_TOKEN=[yourserviceuserpersonalaccesstoken]
```
then generate the GRPC stubs with
```sh
pnpm generate
```
and then run it with
To run the application against a local ZITADEL instance, run the following command:
```sh
pnpm run-zitadel
```
This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`.
<details>
<summary>Alternatively, use another environment</summary>
You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps.
Just create or overwrite the file `apps/login/.env.local` yourself.
Add your instances base URL to the file at the key `ZITADEL_API_URL`.
Go to your instance and create a service user for the login application.
The login application creates users on your primary organization and reads policy data.
For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`.
Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`.
Also add the users ID to the file using the key `ZITADEL_SERVICE_USER_ID`.
The file should look similar to this:
```
ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud
ZITADEL_SERVICE_USER_ID=289106423158521850
ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk
```
</details>
Start the login application in dev mode:
```sh
pnpm dev
```
Open the login application with your favorite browser at `localhost:3000`.
Change the source code and see the changes live in your browser.
Make sure the application still behaves as expected by running all tests
```sh
pnpm test
```
To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts.
### Run Login UI Acceptance tests
To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's.
This component should also be able to return the content of these notifications, as the codes and links are used in the login flows.
There is a basic implementation in Golang available under [the sink package](./acceptance/sink).
To setup ZITADEL with the additional Sink container for handling the notifications:
```sh
pnpm run-sink
```
Then you can start the acceptance tests with:
```sh
pnpm test:acceptance
```
### Deploy to Vercel

View File

@@ -1,6 +1,5 @@
FROM golang:1.19-alpine
RUN apk add curl jq
RUN go install github.com/zitadel/zitadel-tools@v0.4.0
COPY setup.sh /setup.sh
RUN chmod +x /setup.sh
ENTRYPOINT [ "/setup.sh" ]

View File

@@ -1,40 +1,38 @@
version: "3.8"
services:
zitadel:
user: "${ZITADEL_DEV_UID}"
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}"
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:v2.65.0}"
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
ports:
- "8080:8080"
volumes:
- ./machinekey:/machinekey
- ./pat:/pat
- ./zitadel.yaml:/zitadel.yaml
depends_on:
db:
condition: "service_healthy"
db:
image: "cockroachdb/cockroach:v22.2.2"
command: "start-single-node --insecure --http-addr :9090"
restart: "always"
image: postgres:17.0-alpine3.19
environment:
- POSTGRES_USER=zitadel
- PGUSER=zitadel
- POSTGRES_DB=zitadel
- POSTGRES_HOST_AUTH_METHOD=trust
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9090/health?ready=1"]
test: [ "CMD-SHELL", "pg_isready" ]
interval: "10s"
timeout: "30s"
retries: 5
start_period: "20s"
ports:
- "26257:26257"
- "9090:9090"
- 5432:5432
wait_for_zitadel:
image: curlimages/curl:8.00.1
command:
[
"/bin/sh",
"-c",
"i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 30 ] && exit 1 || exit 0",
]
command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false
depends_on:
- zitadel
@@ -43,12 +41,29 @@ services:
container_name: setup
build: .
environment:
KEY: /key/zitadel-admin-sa.json
SERVICE: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.acceptance
PAT_FILE: /pat/zitadel-admin-sa.pat
ZITADEL_API_INTERNAL_URL: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local
SINK_EMAIL_INTERNAL_URL: http://sink:3333/email
SINK_SMS_INTERNAL_URL: http://sink:3333/sms
SINK_NOTIFICATION_URL: http://localhost:3333/notification
volumes:
- "./machinekey:/key"
- "./pat:/pat"
- "../apps/login:/apps/login"
- "../acceptance/tests:/acceptance/tests"
depends_on:
wait_for_zitadel:
condition: "service_completed_successfully"
sink:
image: golang:1.19-alpine
container_name: sink
command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification'
ports:
- 3333:3333
volumes:
- "./sink:/sink"
depends_on:
setup:
condition: "service_completed_successfully"

View File

@@ -1 +0,0 @@
zitadel-admin-sa.json

2
acceptance/pat/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitkeep

View File

@@ -1,125 +1,112 @@
#!/bin/sh
set -e
set -ex
KEY=${KEY:-./machinekey/zitadel-admin-sa.json}
echo "Using key path ${KEY} to the instance admin service account."
PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat}
ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}"
ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}"
ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}"
ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}"
ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}"
SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}"
SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}"
SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}"
AUDIENCE=${AUDIENCE:-http://localhost:8080}
echo "Using audience ${AUDIENCE} for which the key is used."
if [ -z "${PAT}" ]; then
echo "Reading PAT from file ${PAT_FILE}"
PAT=$(cat ${PAT_FILE})
fi
SERVICE=${SERVICE:-$AUDIENCE}
echo "Using the service ${SERVICE} to connect to ZITADEL. For example in docker compose this can differ from the audience."
if [ -z "${ZITADEL_SERVICE_USER_ID}" ]; then
echo "Reading ZITADEL_SERVICE_USER_ID from userinfo endpoint"
USERINFO_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/oidc/v1/userinfo" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}")
echo "Received userinfo response: ${USERINFO_RESPONSE}"
ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub')
fi
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.acceptance}
#################################################################
# Environment files
#################################################################
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local}
echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done."
AUDIENCE_HOST="$(echo $AUDIENCE | cut -d/ -f3)"
echo "Deferred the Host header ${AUDIENCE_HOST} which will be sent in requests that ZITADEL then maps to a virtual instance"
JWT=$(zitadel-tools key2jwt --key ${KEY} --audience ${AUDIENCE})
echo "Created JWT from Admin service account key ${JWT}"
TOKEN_RESPONSE=$(curl -s --request POST \
--url ${SERVICE}/oauth/v2/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header "Host: ${AUDIENCE_HOST}" \
--data grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
--data scope='openid profile email urn:zitadel:iam:org:project:id:zitadel:aud' \
--data assertion="${JWT}")
echo "Got response from token endpoint:"
echo "${TOKEN_RESPONSE}" | jq
TOKEN=$(echo -n ${TOKEN_RESPONSE} | jq --raw-output '.access_token')
echo "Extracted access token ${TOKEN}"
ORG_RESPONSE=$(curl -s --request GET \
--url ${SERVICE}/admin/v1/orgs/default \
--header 'Accept: application/json' \
--header "Authorization: Bearer ${TOKEN}" \
--header "Host: ${AUDIENCE_HOST}")
echo "Got default org response:"
echo "${ORG_RESPONSE}" | jq
ORG_ID=$(echo -n ${ORG_RESPONSE} | jq --raw-output '.org.id')
echo "Extracted default org id ${ORG_ID}"
echo "ZITADEL_API_URL=${AUDIENCE}
ZITADEL_ORG_ID=${ORG_ID}
ZITADEL_SERVICE_USER_TOKEN=${TOKEN}" > ${WRITE_ENVIRONMENT_FILE}
echo "ZITADEL_API_URL=${ZITADEL_API_URL}
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
ZITADEL_SERVICE_USER_TOKEN=${PAT}
SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL}
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
cat ${WRITE_ENVIRONMENT_FILE}
if ! grep -q 'localhost' ${WRITE_ENVIRONMENT_FILE}; then
echo "Not developing against localhost, so creating a human user might not be necessary"
exit 0
fi
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
cat ${WRITE_TEST_ENVIRONMENT_FILE}
HUMAN_USER_USERNAME="zitadel-admin@zitadel.localhost"
HUMAN_USER_PASSWORD="Password1!"
#################################################################
# SMS provider with HTTP
#################################################################
HUMAN_USER_PAYLOAD=$(cat << EOM
{
"userName": "${HUMAN_USER_USERNAME}",
"profile": {
"firstName": "ZITADEL",
"lastName": "Admin",
"displayName": "ZITADEL Admin",
"preferredLanguage": "en"
},
"email": {
"email": "zitadel-admin@zitadel.localhost",
"isEmailVerified": true
},
"password": "${HUMAN_USER_PASSWORD}",
"passwordChangeRequired": false
}
EOM
)
echo "Creating human user"
echo "${HUMAN_USER_PAYLOAD}" | jq
SMSHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}"
HUMAN_USER_RESPONSE=$(curl -s --request POST \
--url ${SERVICE}/management/v1/users/human/_import \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header "Authorization: Bearer ${TOKEN}" \
--header "Host: ${AUDIENCE_HOST}" \
--data-raw "${HUMAN_USER_PAYLOAD}")
echo "Create human user response"
echo "${HUMAN_USER_RESPONSE}" | jq
SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id')
echo "Received SMS HTTP ID: ${SMSHTTP_ID}"
if [ "$(echo -n "${HUMAN_USER_RESPONSE}" | jq --raw-output '.code')" == "6" ]; then
echo "admin user already exists"
exit 0
fi
SMS_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}"
HUMAN_USER_ID=$(echo -n ${HUMAN_USER_RESPONSE} | jq --raw-output '.userId')
echo "Extracted human user id ${HUMAN_USER_ID}"
#################################################################
# Email provider with HTTP
#################################################################
HUMAN_ADMIN_PAYLOAD=$(cat << EOM
{
"userId": "${HUMAN_USER_ID}",
"roles": [
"IAM_OWNER"
]
}
EOM
)
echo "Granting iam owner to human user"
echo "${HUMAN_ADMIN_PAYLOAD}" | jq
EMAILHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}"
HUMAN_ADMIN_RESPONSE=$(curl -s --request POST \
--url ${SERVICE}/admin/v1/members \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header "Authorization: Bearer ${TOKEN}" \
--header "Host: ${AUDIENCE_HOST}" \
--data-raw "${HUMAN_ADMIN_PAYLOAD}")
EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id')
echo "Received Email HTTP ID: ${EMAILHTTP_ID}"
echo "Grant iam owner to human user response"
echo "${HUMAN_ADMIN_RESPONSE}" | jq
EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}"
#################################################################
# Wait for projection of default organization in ZITADEL
#################################################################
DEFAULTORG_RESPONSE_RESULTS=0
# waiting for default organization
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
do
DEFAULTORG_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"queries\": [{\"defaultQuery\":{}}]}" )
echo "Received default organization response: ${DEFAULTORG_RESPONSE}"
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
done
echo "You can now log in at ${AUDIENCE}/ui/login"
echo "username: ${HUMAN_USER_USERNAME}"
echo "password: ${HUMAN_USER_PASSWORD}"

3
acceptance/sink/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/zitadel/typescript/acceptance/sink
go 1.22.6

104
acceptance/sink/main.go Normal file
View File

@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
)
type serializableData struct {
ContextInfo map[string]interface{} `json:"contextInfo,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
}
type response struct {
Recipient string `json:"recipient,omitempty"`
}
func main() {
port := flag.String("port", "3333", "used port for the sink")
email := flag.String("email", "/email", "path for a sent email")
emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification")
sms := flag.String("sms", "/sms", "path for a sent sms")
smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification")
notification := flag.String("notification", "/notification", "path to receive the notification")
flag.Parse()
messages := make(map[string]serializableData)
http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
email, ok := serializableData.ContextInfo[*emailKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(email + ": " + string(data))
messages[email] = serializableData
io.WriteString(w, "Email!\n")
})
http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
phone, ok := serializableData.ContextInfo[*smsKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(phone + ": " + string(data))
messages[phone] = serializableData
io.WriteString(w, "SMS!\n")
})
http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := response{}
if err := json.Unmarshal(data, &response); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData, err := json.Marshal(messages[response.Recipient])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, string(serializableData))
})
fmt.Println("Starting server on", *port)
fmt.Println(*email, " for email handling")
fmt.Println(*sms, " for sms handling")
fmt.Println(*notification, " for retrieving notifications")
err := http.ListenAndServe(":"+*port, nil)
if err != nil {
panic("Server could not be started: " + err.Error())
}
}

View File

@@ -0,0 +1,7 @@
import { test } from "@playwright/test";
import { loginScreenExpect, loginWithPassword } from "./login";
test("admin login", async ({ page }) => {
await loginWithPassword(page, "zitadel-admin@zitadel.localhost", "Password1!");
await loginScreenExpect(page, "ZITADEL Admin");
});

View File

@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";
const codeTextInput = "code-text-input";
export async function codeScreen(page: Page, code: string) {
await page.getByTestId(codeTextInput).pressSequentially(code);
}
export async function codeScreenExpect(page: Page, code: string) {
await expect(page.getByTestId(codeTextInput)).toHaveValue(code);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code");
}

19
acceptance/tests/code.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Page } from "@playwright/test";
import { codeScreen } from "./code-screen";
import { getOtpFromSink } from "./sink";
export async function otpFromSink(page: Page, key: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getOtpFromSink(key);
await code(page, c);
}
export async function code(page: Page, code: string) {
await codeScreen(page, code);
await page.getByTestId("submit-button").click();
}
export async function codeResend(page: Page) {
await page.getByTestId("resend-button").click();
}

View File

@@ -0,0 +1,94 @@
// Note for all tests, in case Apple doesn't deliver all relevant information per default
// We should add an action in the needed cases
import test from "@playwright/test";
test("login with Apple IDP", async ({ page }) => {
// Given an Apple IDP is configured on the organization
// Given the user has an Apple added as auth method
// User authenticates with Apple
// User is redirected back to login
// User is redirected to the app
});
test("login with Apple IDP - error", async ({ page }) => {
// Given an Apple IDP is configured on the organization
// Given the user has an Apple added as auth method
// User is redirected to Apple
// User authenticates with Apple and gets an error
// User is redirect back to login
// An error is shown to the user "Something went wrong in Apple Login"
});
test("login with Apple IDP, no user existing - auto register", async ({ page }) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Apple
// User authenticates in Apple
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Apple
// User authenticates in Apple
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Apple
// User authenticates in Apple
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Apple IDP, no user linked - auto link", async ({ page }) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Apple
// User authenticates in Apple with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Apple
// User authenticates in Apple with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Apple IDP, no user linked, user link successful", async ({ page }) => {
// Given idp Apple is configure on the organization as only authencation method
// Given idp Apple is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Apple
// User authenticates in Apple with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,91 @@
import test from "@playwright/test";
test("login with Generic JWT IDP", async ({ page }) => {
// Given a Generic JWT IDP is configured on the organization
// Given the user has Generic JWT IDP added as auth method
// User authenticates with the Generic JWT IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with Generic JWT IDP - error", async ({ page }) => {
// Given the Generic JWT IDP is configured on the organization
// Given the user has Generic JWT IDP added as auth method
// User is redirected to the Generic JWT IDP
// User authenticates with the Generic JWT IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Generic JWT is configure on the organization as only authencation method
// Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic JWT
// User authenticates in Generic JWT with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,91 @@
import test from "@playwright/test";
test("login with Generic OAuth IDP", async ({ page }) => {
// Given a Generic OAuth IDP is configured on the organization
// Given the user has Generic OAuth IDP added as auth method
// User authenticates with the Generic OAuth IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with Generic OAuth IDP - error", async ({ page }) => {
// Given the Generic OAuth IDP is configured on the organization
// Given the user has Generic OAuth IDP added as auth method
// User is redirected to the Generic OAuth IDP
// User authenticates with the Generic OAuth IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Generic OAuth is configure on the organization as only authencation method
// Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic OAuth
// User authenticates in Generic OAuth with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,93 @@
// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented
import test from "@playwright/test";
test("login with Generic OIDC IDP", async ({ page }) => {
// Given a Generic OIDC IDP is configured on the organization
// Given the user has Generic OIDC IDP added as auth method
// User authenticates with the Generic OIDC IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with Generic OIDC IDP - error", async ({ page }) => {
// Given the Generic OIDC IDP is configured on the organization
// Given the user has Generic OIDC IDP added as auth method
// User is redirected to the Generic OIDC IDP
// User authenticates with the Generic OIDC IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Generic OIDC is configure on the organization as only authencation method
// Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Generic OIDC
// User authenticates in Generic OIDC with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,95 @@
import test from "@playwright/test";
test("login with GitHub Enterprise IDP", async ({ page }) => {
// Given a GitHub Enterprise IDP is configured on the organization
// Given the user has GitHub Enterprise IDP added as auth method
// User authenticates with the GitHub Enterprise IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with GitHub Enterprise IDP - error", async ({ page }) => {
// Given the GitHub Enterprise IDP is configured on the organization
// Given the user has GitHub Enterprise IDP added as auth method
// User is redirected to the GitHub Enterprise IDP
// User authenticates with the GitHub Enterprise IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled
// Given ZITADEL Action is added to autofill missing user information
// Given no user exists yet
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com exists
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => {
// Given idp GitHub Enterprise is configure on the organization as only authencation method
// Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to GitHub Enterprise
// User authenticates in GitHub Enterprise with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,95 @@
import test from "@playwright/test";
test("login with GitHub IDP", async ({ page }) => {
// Given a GitHub IDP is configured on the organization
// Given the user has GitHub IDP added as auth method
// User authenticates with the GitHub IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with GitHub IDP - error", async ({ page }) => {
// Given the GitHub IDP is configured on the organization
// Given the user has GitHub IDP added as auth method
// User is redirected to the GitHub IDP
// User authenticates with the GitHub IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with GitHub IDP, no user existing - auto register", async ({ page }) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with account creation alloweed, and automatic creation enabled
// Given ZITADEL Action is added to autofill missing user information
// Given no user exists yet
// User is automatically redirected to GitHub
// User authenticates in GitHub
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to GitHub
// User authenticates in GitHub
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to GitHub
// User authenticates in GitHub
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with GitHub IDP, no user linked - auto link", async ({ page }) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com exists
// User is automatically redirected to GitHub
// User authenticates in GitHub with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to GitHub
// User authenticates in GitHub with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => {
// Given idp GitHub is configure on the organization as only authencation method
// Given idp GitHub is configure with manually account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to GitHub
// User authenticates in GitHub with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,95 @@
import test from "@playwright/test";
test("login with GitLab Self-Hosted IDP", async ({ page }) => {
// Given a GitLab Self-Hosted IDP is configured on the organization
// Given the user has GitLab Self-Hosted IDP added as auth method
// User authenticates with the GitLab Self-Hosted IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with GitLab Self-Hosted IDP - error", async ({ page }) => {
// Given the GitLab Self-Hosted IDP is configured on the organization
// Given the user has GitLab Self-Hosted IDP added as auth method
// User is redirected to the GitLab Self-Hosted IDP
// User authenticates with the GitLab Self-Hosted IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled
// Given ZITADEL Action is added to autofill missing user information
// Given no user exists yet
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Gitlab Self-Hosted is configure on the organization as only authencation method
// Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Gitlab Self-Hosted
// User authenticates in Gitlab Self-Hosted with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,95 @@
import test from "@playwright/test";
test("login with GitLab IDP", async ({ page }) => {
// Given a GitLab IDP is configured on the organization
// Given the user has GitLab IDP added as auth method
// User authenticates with the GitLab IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with GitLab IDP - error", async ({ page }) => {
// Given the GitLab IDP is configured on the organization
// Given the user has GitLab IDP added as auth method
// User is redirected to the GitLab IDP
// User authenticates with the GitLab IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled
// Given ZITADEL Action is added to autofill missing user information
// Given no user exists yet
// User is automatically redirected to Gitlab
// User authenticates in Gitlab
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Gitlab
// User authenticates in Gitlab
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Gitlab
// User authenticates in Gitlab
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Gitlab
// User authenticates in Gitlab with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Gitlab
// User authenticates in Gitlab with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Gitlab is configure on the organization as only authencation method
// Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Gitlab
// User authenticates in Gitlab with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,91 @@
import test from "@playwright/test";
test("login with Google IDP", async ({ page }) => {
// Given a Google IDP is configured on the organization
// Given the user has Google IDP added as auth method
// User authenticates with the Google IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with Google IDP - error", async ({ page }) => {
// Given the Google IDP is configured on the organization
// Given the user has Google IDP added as auth method
// User is redirected to the Google IDP
// User authenticates with the Google IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Google IDP, no user existing - auto register", async ({ page }) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Google
// User authenticates in Google
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Google
// User authenticates in Google
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Google
// User authenticates in Google
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Google IDP, no user linked - auto link", async ({ page }) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Google
// User authenticates in Google with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Google IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Google
// User authenticates in Google with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Google IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Google is configure on the organization as only authencation method
// Given idp Google is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Google
// User authenticates in Google with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,91 @@
import test from "@playwright/test";
test("login with LDAP IDP", async ({ page }) => {
// Given a LDAP IDP is configured on the organization
// Given the user has LDAP IDP added as auth method
// User authenticates with the LDAP IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with LDAP IDP - error", async ({ page }) => {
// Given the LDAP IDP is configured on the organization
// Given the user has LDAP IDP added as auth method
// User is redirected to the LDAP IDP
// User authenticates with the LDAP IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with LDAP IDP, no user existing - auto register", async ({ page }) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to LDAP
// User authenticates in LDAP
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to LDAP
// User authenticates in LDAP
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to LDAP
// User authenticates in LDAP
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with LDAP IDP, no user linked - auto link", async ({ page }) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to LDAP
// User authenticates in LDAP with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to LDAP
// User authenticates in LDAP with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => {
// Given idp LDAP is configure on the organization as only authencation method
// Given idp LDAP is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to LDAP
// User authenticates in LDAP with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,94 @@
// Note for all tests, in case Microsoft doesn't deliver all relevant information per default
// We should add an action in the needed cases
import test from "@playwright/test";
test("login with Microsoft IDP", async ({ page }) => {
// Given a Microsoft IDP is configured on the organization
// Given the user has Microsoft IDP added as auth method
// User authenticates with the Microsoft IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with Microsoft IDP - error", async ({ page }) => {
// Given the Microsoft IDP is configured on the organization
// Given the user has Microsoft IDP added as auth method
// User is redirected to the Microsoft IDP
// User authenticates with the Microsoft IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Microsoft
// User authenticates in Microsoft
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Microsoft
// User authenticates in Microsoft
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to Microsoft
// User authenticates in Microsoft
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com exists
// User is automatically redirected to Microsoft
// User authenticates in Microsoft with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Microsoft
// User authenticates in Microsoft with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => {
// Given idp Microsoft is configure on the organization as only authencation method
// Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to Microsoft
// User authenticates in Microsoft with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,95 @@
import test from "@playwright/test";
test("login with SAML IDP", async ({ page }) => {
// Given a SAML IDP is configured on the organization
// Given the user has SAML IDP added as auth method
// User authenticates with the SAML IDP
// User is redirected back to login
// User is redirected to the app
});
test("login with SAML IDP - error", async ({ page }) => {
// Given the SAML IDP is configured on the organization
// Given the user has SAML IDP added as auth method
// User is redirected to the SAML IDP
// User authenticates with the SAML IDP and gets an error
// User is redirected back to login
// An error is shown to the user "Something went wrong"
});
test("login with SAML IDP, no user existing - auto register", async ({ page }) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with account creation alloweed, and automatic creation enabled
// Given ZITADEL Action is added to autofill missing user information
// Given no user exists yet
// User is automatically redirected to SAML
// User authenticates in SAML
// User is redirect to ZITADEL login
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with account creation alloweed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to SAML
// User authenticates in SAML
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// User will see the registration page with pre filled user information
// User fills missing information
// User clicks register button
// User is created in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({
page,
}) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with account creation not allowed, and automatic creation enabled
// Given no user exists yet
// User is automatically redirected to SAML
// User authenticates in SAML
// User is redirect to ZITADEL login
// Because of missing informaiton on the user auto creation is not possible
// Error message is shown, that registration of the user was not possible due to missing information
});
test("login with SAML IDP, no user linked - auto link", async ({ page }) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com exists
// User is automatically redirected to SAML
// User authenticates in SAML with user@zitadel.com
// User is redirect to ZITADEL login
// User is linked with existing user in ZITADEL
// User is redirected to the app (default redirect url)
});
test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with manually account linking not allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to SAML
// User authenticates in SAML with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User will get an error message that account linking wasn't possible
});
test("login with SAML IDP, no user linked, linking successful", async ({ page }) => {
// Given idp SAML is configure on the organization as only authencation method
// Given idp SAML is configure with manually account linking allowed, and linking set to existing email
// Given ZITADEL Action is added to autofill missing user information
// Given user with email address user@zitadel.com doesn't exists
// User is automatically redirected to SAML
// User authenticates in SAML with user@zitadel.com
// User is redirect to ZITADEL login
// User with email address user@zitadel.com can not be found
// User is prompted to link the account manually
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,51 @@
import test from "@playwright/test";
test("login with mfa setup, mfa setup prompt", async ({ page }) => {
// Given the organization has enabled at least one mfa types
// Given the user has a password but no mfa registered
// User authenticates with login name and password
// User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider
});
test("login with mfa setup, no mfa setup prompt", async ({ page }) => {
// Given the organization has set "multifactor init check time" to 0
// Given the organization has enabled mfa types
// Given the user has a password but no mfa registered
// User authenticates with loginname and password
// user is directly loged in and not prompted to setup mfa
});
test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => {
// Given the organization has enabled force mfa for local authentiacted users
// Given the organization has enabled all possible mfa types
// Given the user has a password but no mfa registered
// User authenticates with loginname and password
// User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider
});
test("login with mfa setup, force mfa - local user", async ({ page }) => {
// Given the organization has enabled force mfa for local authentiacted users
// Given the organization has enabled all possible mfa types
// Given the user has a password but no mfa registered
// User authenticates with loginname and password
// User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider
});
test("login with mfa setup, force mfa - external user", async ({ page }) => {
// Given the organization has enabled force mfa
// Given the organization has enabled all possible mfa types
// Given the user has an idp but no mfa registered
// enter login name
// redirect to configured external idp
// User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider
});
test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => {
// Given the organization has a password lockout policy set to 1 on the max password attempts
// Given the user has only a password as auth methos
// enter login name
// enter wrong password
// User will get an error "Wrong password"
// enter password
// User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator"
});

41
acceptance/tests/login.ts Normal file
View File

@@ -0,0 +1,41 @@
import { expect, Page } from "@playwright/test";
import { code, otpFromSink } from "./code";
import { loginname } from "./loginname";
import { password } from "./password";
import { totp } from "./zitadel";
export async function startLogin(page: Page) {
await page.goto("/loginname");
}
export async function loginWithPassword(page: Page, username: string, pw: string) {
await startLogin(page);
await loginname(page, username);
await password(page, pw);
}
export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) {
await startLogin(page);
await loginname(page, username);
// await passkey(page, authenticatorId);
}
export async function loginScreenExpect(page: Page, fullName: string) {
await expect(page).toHaveURL(/signedin.*/);
await expect(page.getByRole("heading")).toContainText(fullName);
}
export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) {
await loginWithPassword(page, username, password);
await otpFromSink(page, email);
}
export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) {
await loginWithPassword(page, username, password);
await otpFromSink(page, phone);
}
export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) {
await loginWithPassword(page, username, password);
await code(page, totp(secret));
}

View File

@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";
const usernameTextInput = "username-text-input";
export async function loginnameScreen(page: Page, username: string) {
await page.getByTestId(usernameTextInput).pressSequentially(username);
}
export async function loginnameScreenExpect(page: Page, username: string) {
await expect(page.getByTestId(usernameTextInput)).toHaveValue(username);
await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system");
}

View File

@@ -0,0 +1,7 @@
import { Page } from "@playwright/test";
import { loginnameScreen } from "./loginname-screen";
export async function loginname(page: Page, username: string) {
await loginnameScreen(page, username);
await page.getByTestId("submit-button").click();
}

109
acceptance/tests/passkey.ts Normal file
View File

@@ -0,0 +1,109 @@
import { expect, Page } from "@playwright/test";
import { CDPSession } from "playwright-core";
interface session {
client: CDPSession;
authenticatorId: string;
}
async function client(page: Page): Promise<session> {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("WebAuthn.enable", { enableUI: false });
const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
return { client: cdpSession, authenticatorId: result.authenticatorId };
}
export async function passkeyRegister(page: Page): Promise<string> {
const session = await client(page);
await passkeyNotExisting(session.client, session.authenticatorId);
await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () =>
page.getByTestId("submit-button").click(),
);
await passkeyRegistered(session.client, session.authenticatorId);
return session.authenticatorId;
}
export async function passkey(page: Page, authenticatorId: string) {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("WebAuthn.enable", { enableUI: false });
const signCount = await passkeyExisting(cdpSession, authenticatorId);
await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click());
await passkeyUsed(cdpSession, authenticatorId, signCount);
}
async function passkeyNotExisting(client: CDPSession, authenticatorId: string) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(0);
}
async function passkeyRegistered(client: CDPSession, authenticatorId: string) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
await passkeyUsed(client, authenticatorId, 0);
}
async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise<number> {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
return result.credentials[0].signCount;
}
async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
expect(result.credentials[0].signCount).toBeGreaterThan(signCount);
}
async function simulateSuccessfulPasskeyRegister(
client: CDPSession,
authenticatorId: string,
operationTrigger: () => Promise<void>,
) {
// initialize event listeners to wait for a successful passkey input event
const operationCompleted = new Promise<void>((resolve) => {
client.on("WebAuthn.credentialAdded", () => {
console.log("Credential Added!");
resolve();
});
});
// perform a user action that triggers passkey prompt
await operationTrigger();
// wait to receive the event that the passkey was successfully registered or verified
await operationCompleted;
}
async function simulateSuccessfulPasskeyInput(
client: CDPSession,
authenticatorId: string,
operationTrigger: () => Promise<void>,
) {
// initialize event listeners to wait for a successful passkey input event
const operationCompleted = new Promise<void>((resolve) => {
client.on("WebAuthn.credentialAsserted", () => {
console.log("Credential Asserted!");
resolve();
});
});
// perform a user action that triggers passkey prompt
await operationTrigger();
// wait to receive the event that the passkey was successfully registered or verified
await operationCompleted;
}

View File

@@ -0,0 +1,82 @@
import { expect, Page } from "@playwright/test";
import { getCodeFromSink } from "./sink";
const codeField = "code-text-input";
const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";
const lengthCheck = "length-check";
const symbolCheck = "symbol-check";
const numberCheck = "number-check";
const uppercaseCheck = "uppercase-check";
const lowercaseCheck = "lowercase-check";
const equalCheck = "equal-check";
const matchText = "Matches";
const noMatchText = "Doesn't match";
export async function changePasswordScreen(page: Page, password1: string, password2: string) {
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}
export async function passwordScreen(page: Page, password: string) {
await page.getByTestId(passwordField).pressSequentially(password);
}
export async function passwordScreenExpect(page: Page, password: string) {
await expect(page.getByTestId(passwordField)).toHaveValue(password);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify password");
}
export async function changePasswordScreenExpect(
page: Page,
password1: string,
password2: string,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await expect(page.getByTestId(passwordField)).toHaveValue(password1);
await expect(page.getByTestId(passwordConfirmField)).toHaveValue(password2);
await checkContent(page, lengthCheck, length);
await checkContent(page, symbolCheck, symbol);
await checkContent(page, numberCheck, number);
await checkContent(page, uppercaseCheck, uppercase);
await checkContent(page, lowercaseCheck, lowercase);
await checkContent(page, equalCheck, equals);
}
async function checkContent(page: Page, testid: string, match: boolean) {
if (match) {
await expect(page.getByTestId(testid)).toContainText(matchText);
} else {
await expect(page.getByTestId(testid)).toContainText(noMatchText);
}
}
export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(username);
await page.getByTestId(codeField).pressSequentially(c);
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}
export async function resetPasswordScreenExpect(
page: Page,
password1: string,
password2: string,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await changePasswordScreenExpect(page, password1, password2, length, symbol, number, uppercase, lowercase, equals);
}

View File

@@ -0,0 +1,30 @@
import { Page } from "@playwright/test";
import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen";
const passwordSubmitButton = "submit-button";
const passwordResetButton = "reset-button";
export async function startChangePassword(page: Page, loginname: string) {
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
}
export async function changePassword(page: Page, loginname: string, password: string) {
await startChangePassword(page, loginname);
await changePasswordScreen(page, password, password);
await page.getByTestId(passwordSubmitButton).click();
}
export async function password(page: Page, password: string) {
await passwordScreen(page, password);
await page.getByTestId(passwordSubmitButton).click();
}
export async function startResetPassword(page: Page) {
await page.getByTestId(passwordResetButton).click();
}
export async function resetPassword(page: Page, username: string, password: string) {
await startResetPassword(page);
await resetPasswordScreen(page, username, password, password);
await page.getByTestId(passwordSubmitButton).click();
}

View File

@@ -0,0 +1,27 @@
import { Page } from "@playwright/test";
const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";
export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("password-radio").click();
}
export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("passkey-radio").click();
}
export async function registerPasswordScreen(page: Page, password1: string, password2: string) {
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}
export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) {
await page.getByTestId("firstname-text-input").pressSequentially(firstname);
await page.getByTestId("lastname-text-input").pressSequentially(lastname);
await page.getByTestId("email-text-input").pressSequentially(email);
await page.getByTestId("privacy-policy-checkbox").check();
await page.getByTestId("tos-checkbox").check();
}

View File

@@ -0,0 +1,173 @@
import { faker } from "@faker-js/faker";
import { test } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect } from "./login";
import { registerWithPasskey, registerWithPassword } from "./register";
import { removeUserByUsername } from "./zitadel";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
test("register with password", async ({ page }) => {
const username = faker.internet.email();
const password = "Password1!";
const firstname = faker.person.firstName();
const lastname = faker.person.lastName();
await registerWithPassword(page, firstname, lastname, username, password, password);
await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
});
test("register with passkey", async ({ page }) => {
const username = faker.internet.email();
const firstname = faker.person.firstName();
const lastname = faker.person.lastName();
await registerWithPasskey(page, firstname, lastname, username);
await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
});
test("register with username and password - only password enabled", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and "password"
// User is redirected to app (default redirect url)
});
test("register with username and password - wrong password not enough characters", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given password policy is set to 8 characters and must include number, symbol, lower and upper letter
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and a password thats to short
// Error is shown "Password doesn't match the policy - it must have at least 8 characters"
});
test("register with username and password - wrong password number missing", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given password policy is set to 8 characters and must include number, symbol, lower and upper letter
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and a password without a number
// Error is shown "Password doesn't match the policy - number missing"
});
test("register with username and password - wrong password upper case missing", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given password policy is set to 8 characters and must include number, symbol, lower and upper letter
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and a password without an upper case
// Error is shown "Password doesn't match the policy - uppercase letter missing"
});
test("register with username and password - wrong password lower case missing", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given password policy is set to 8 characters and must include number, symbol, lower and upper letter
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and a password without an lower case
// Error is shown "Password doesn't match the policy - lowercase letter missing"
});
test("register with username and password - wrong password symboo missing", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is not enabled
// Given password policy is set to 8 characters and must include number, symbol, lower and upper letter
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// Only password is shown as an option - no passkey
// User enters "firstname", "lastname", "username" and a password without an symbol
// Error is shown "Password doesn't match the policy - symbol missing"
});
test("register with username and password - password and passkey enabled", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is enabled
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// User enters "firstname", "lastname", "username"
// Password and passkey are shown as authentication option
// User clicks password
// User enters password
// User is redirected to app (default redirect url)
});
test("register with username and passkey - password and passkey enabled", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given on the default organization passkey is enabled
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration page
// User enters "firstname", "lastname", "username"
// Password and passkey are shown as authentication option
// User clicks passkey
// Passkey is opened automatically
// User verifies passkey
// User is redirected to app (default redirect url)
});
test("register with username and password - registration disabled", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization no idp is configured and enabled
// Given user doesn't exist
// Button "register new user" is not available
});
test("register with username and password - multiple registration options", async ({ page }) => {
// Given on the default organization "username and password is allowed" is enabled
// Given on the default organization "username registeration allowed" is enabled
// Given on the default organization one idp is configured and enabled
// Given user doesn't exist
// Click on button "register new user"
// User is redirected to registration options
// Local User and idp button are shown
// User clicks idp button
// User enters "firstname", "lastname", "username" and "password"
// User clicks next
// User is redirected to app (default redirect url)
});

View File

@@ -0,0 +1,29 @@
import { Page } from "@playwright/test";
import { passkeyRegister } from "./passkey";
import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen";
export async function registerWithPassword(
page: Page,
firstname: string,
lastname: string,
email: string,
password1: string,
password2: string,
) {
await page.goto("/register");
await registerUserScreenPassword(page, firstname, lastname, email);
await page.getByTestId("submit-button").click();
await registerPasswordScreen(page, password1, password2);
await page.getByTestId("submit-button").click();
}
export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise<string> {
await page.goto("/register");
await registerUserScreenPasskey(page, firstname, lastname, email);
await page.getByTestId("submit-button").click();
// wait for projection of user
await page.waitForTimeout(2000);
return await passkeyRegister(page);
}

55
acceptance/tests/sink.ts Normal file
View File

@@ -0,0 +1,55 @@
import axios from "axios";
export async function getOtpFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.oTP;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function getCodeFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.code;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}

171
acceptance/tests/user.ts Normal file
View File

@@ -0,0 +1,171 @@
import { Page } from "@playwright/test";
import { registerWithPasskey } from "./register";
import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel";
export interface userProps {
email: string;
firstName: string;
lastName: string;
organization: string;
password: string;
phone: string;
}
class User {
private readonly props: userProps;
private user: string;
constructor(userProps: userProps) {
this.props = userProps;
}
async ensure(page: Page) {
const response = await addUser(this.props);
this.setUserId(response.userId);
}
async cleanup() {
await removeUser(this.getUserId());
}
public setUserId(userId: string) {
this.user = userId;
}
public getUserId() {
return this.user;
}
public getUsername() {
return this.props.email;
}
public getPassword() {
return this.props.password;
}
public getFirstname() {
return this.props.firstName;
}
public getLastname() {
return this.props.lastName;
}
public getPhone() {
return this.props.phone;
}
public getFullName() {
return `${this.props.firstName} ${this.props.lastName}`;
}
}
export class PasswordUser extends User {
async ensure(page: Page) {
await super.ensure(page);
// wait for projection of user
await page.waitForTimeout(2000);
}
}
export enum OtpType {
sms = "sms",
email = "email",
}
export interface otpUserProps {
email: string;
firstName: string;
lastName: string;
organization: string;
password: string;
phone: string;
type: OtpType;
}
export class PasswordUserWithOTP extends User {
private type: OtpType;
constructor(props: otpUserProps) {
super({
email: props.email,
firstName: props.firstName,
lastName: props.lastName,
organization: props.organization,
password: props.password,
phone: props.phone,
});
this.type = props.type;
}
async ensure(page: Page) {
await super.ensure(page);
await activateOTP(this.getUserId(), this.type);
// wait for projection of user
await page.waitForTimeout(2000);
}
}
export class PasswordUserWithTOTP extends User {
private secret: string;
async ensure(page: Page) {
await super.ensure(page);
this.secret = await addTOTP(this.getUserId());
// wait for projection of user
await page.waitForTimeout(2000);
}
public getSecret(): string {
return this.secret;
}
}
export interface passkeyUserProps {
email: string;
firstName: string;
lastName: string;
organization: string;
phone: string;
}
export class PasskeyUser extends User {
private authenticatorId: string;
constructor(props: passkeyUserProps) {
super({
email: props.email,
firstName: props.firstName,
lastName: props.lastName,
organization: props.organization,
password: "",
phone: props.phone,
});
}
public async ensure(page: Page) {
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
this.authenticatorId = authId;
// wait for projection of user
await page.waitForTimeout(2000);
}
async cleanup() {
const resp: any = await getUserByUsername(this.getUsername());
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
}
public getAuthenticatorId(): string {
return this.authenticatorId;
}
}

View File

@@ -0,0 +1,40 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPasskey } from "./login";
import { PasskeyUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasskeyUser }>({
user: async ({ page }, use) => {
const user = new PasskeyUser({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and passkey login", async ({ user, page }) => {
await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername());
await loginScreenExpect(page, user.getFullName());
});
test("username and passkey login, multiple auth methods", async ({ page }) => {
// Given passkey and password is enabled on the organization of the user
// Given the user has password and passkey registered
// enter username
// passkey popup is directly shown
// user aborts passkey authentication
// user switches to password authentication
// user enters password
// user is redirected to app
});

View File

@@ -0,0 +1,53 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginWithPassword } from "./login";
import { startChangePassword } from "./password";
import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password changed login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807
/*
const changedPw = "ChangedPw1!";
await loginWithPassword(page, user.getUsername(), user.getPassword());
// wait for projection of token
await page.waitForTimeout(2000);
await changePassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
*/
});
test("password change not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change";
const changedPw2 = "chang";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await startChangePassword(page, user.getUsername());
await changePasswordScreen(page, changedPw1, changedPw2);
await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false);
});

View File

@@ -0,0 +1,93 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code, codeResend, otpFromSink } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
type: OtpType.email,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and email otp login, enter code manually", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and email otp login, click link in email", async ({ page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User clicks link in the email
// User is redirected to the app (default redirect url)
});
test("username, password and email otp login, resend code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User clicks resend code
// User receives a new email with a verification code
// User enters the new code in the ui
// User is redirected to the app (default redirect url)
await loginWithPassword(page, user.getUsername(), user.getPassword());
await codeResend(page);
await otpFromSink(page, user.getUsername());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and email otp login, wrong code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User enters a wrong code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});
test("username, password and email otp login, multiple mfa options", async ({ page }) => {
// Given email otp and sms otp is enabled on the organization of the user
// Given the user has email and sms otp configured as second factor
// User enters username
// User enters password
// User receives an email with a verification code
// User clicks button to use sms otp as second factor
// User receives a sms with a verification code
// User enters code in ui
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,68 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
type: OtpType.sms,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and sms otp login, enter code manually", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
// User enters password
// User receives a sms with a verification code
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and sms otp login, resend code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
// User enters password
// User receives a sms with a verification code
// User clicks resend code
// User receives a new sms with a verification code
// User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and sms otp login, wrong code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor
// User enters username
// User enters password
// User receives a sms with a verification code
// User enters a wrong code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});

View File

@@ -0,0 +1,51 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPassword, startLogin } from "./login";
import { loginname } from "./loginname";
import { resetPassword, startResetPassword } from "./password";
import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password set login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807
const changedPw = "ChangedPw1!";
await startLogin(page);
await loginname(page, user.getUsername());
await resetPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
});
test("password set not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change";
const changedPw2 = "chang";
await startLogin(page);
await loginname(page, user.getUsername());
await startResetPassword(page);
await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2);
await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false);
});

View File

@@ -0,0 +1,67 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login";
import { PasswordUserWithTOTP } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithTOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and totp login", async ({ user, page }) => {
// Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor
// User enters username
// User enters password
// Screen for entering the code is shown directly
// User enters the code into the ui
// User is redirected to the app (default redirect url)
await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and totp otp login, wrong code", async ({ user, page }) => {
// Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor
// User enters username
// User enters password
// Screen for entering the code is shown directly
// User enters a wrond code
// Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});
test("username, password and totp login, multiple mfa options", async ({ page }) => {
// Given totp and email otp is enabled on the organization of the user
// Given the user has totp and email otp configured as second factor
// User enters username
// User enters password
// Screen for entering the code is shown directly
// Button to switch to email otp is shown
// User clicks button to use email otp instead
// User receives an email with a verification code
// User enters code in ui
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,24 @@
import { test } from "@playwright/test";
test("username, password and u2f login", async ({ page }) => {
// Given u2f is enabled on the organization of the user
// Given the user has only u2f configured as second factor
// User enters username
// User enters password
// Popup for u2f is directly opened
// User verifies u2f
// User is redirected to the app (default redirect url)
});
test("username, password and u2f login, multiple mfa options", async ({ page }) => {
// Given u2f and semailms otp is enabled on the organization of the user
// Given the user has u2f and email otp configured as second factor
// User enters username
// User enters password
// Popup for u2f is directly opened
// User aborts u2f verification
// User clicks button to use email otp as second factor
// User receives an email with a verification code
// User enters code in ui
// User is redirected to the app (default redirect url)
});

View File

@@ -0,0 +1,142 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPassword, startLogin } from "./login";
import { loginname } from "./loginname";
import { loginnameScreenExpect } from "./loginname-screen";
import { password } from "./password";
import { passwordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password login", async ({ user, page }) => {
await loginWithPassword(page, user.getUsername(), user.getPassword());
await loginScreenExpect(page, user.getFullName());
});
test("username and password login, unknown username", async ({ page }) => {
const username = "unknown";
await startLogin(page);
await loginname(page, username);
await loginnameScreenExpect(page, username);
});
test("username and password login, wrong password", async ({ user, page }) => {
await startLogin(page);
await loginname(page, user.getUsername());
await password(page, "wrong");
await passwordScreenExpect(page, "wrong");
});
test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => {
// Given user doesn't exist but ignore unknown usernames setting is set to true
// Given username password login is enabled on the users organization
// enter login name
// enter password
// redirect to loginname page --> error message username or password wrong
});
test("username and password login, initial password change", async ({ user, page }) => {
// Given user is created and has changePassword set to true
// Given username password login is enabled on the users organization
// enter login name
// enter password
// create new password
});
test("username and password login, reset password hidden", async ({ user, page }) => {
// Given the organization has enabled "Password reset hidden" in the login policy
// Given username password login is enabled on the users organization
// enter login name
// password reset link should not be shown on password screen
});
test("username and password login, reset password - enter code manually", async ({ user, page }) => {
// Given user has forgotten password and clicks the forgot password button
// Given username password login is enabled on the users organization
// enter login name
// click password forgotten
// enter code from email
// user is redirected to app (default redirect url)
});
test("username and password login, reset password - click link", async ({ user, page }) => {
// Given user has forgotten password and clicks the forgot password button, and then the link in the email
// Given username password login is enabled on the users organization
// enter login name
// click password forgotten
// click link in email
// set new password
// redirect to app (default redirect url)
});
test("username and password login, reset password, resend code", async ({ user, page }) => {
// Given user has forgotten password and clicks the forgot password button and then resend code
// Given username password login is enabled on the users organization
// enter login name
// click password forgotten
// click resend code
// enter code from second email
// user is redirected to app (default redirect url)
});
test("email login enabled", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given no other user with the same email address exists
// enter email address "test@zitadel.com " in login screen
// user will get to password screen
});
test("email login disabled", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given no other user with the same email address exists
// enter email address "test@zitadel.com" in login screen
// user will see error message "user not found"
});
test("email login enabled - multiple users", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists
// enter email address "test@zitadel.com" in login screen
// user will see error message "user not found"
});
test("phone login enabled", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given no other user with the same phon number exists
// enter phone number "0711111111" in login screen
// user will get to password screen
});
test("phone login disabled", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given no other user with the same phone number exists
// enter phone number "0711111111" in login screen
// user will see error message "user not found"
});
test("phone login enabled - multiple users", async ({ user, page }) => {
// Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists
// Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists
// enter phone number "0711111111" in login screen
// user will see error message "user not found"
});

View File

@@ -0,0 +1,6 @@
import { test } from "@playwright/test";
test("login is accessible", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByRole("heading", { name: "Welcome back!" }).isVisible();
});

159
acceptance/tests/zitadel.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Authenticator } from "@otplib/core";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin
import axios from "axios";
import { OtpType, userProps } from "./user";
export async function addUser(props: userProps) {
const body = {
username: props.email,
organization: {
orgId: props.organization,
},
profile: {
givenName: props.firstName,
familyName: props.lastName,
},
email: {
email: props.email,
isVerified: true,
},
phone: {
phone: props.phone!,
isVerified: true,
},
password: {
password: props.password!,
},
};
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body);
}
export async function removeUserByUsername(username: string) {
const resp = await getUserByUsername(username);
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
}
export async function removeUser(id: string) {
await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`);
}
async function deleteCall(url: string) {
try {
const response = await axios.delete(url, {
headers: {
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400 && response.status !== 404) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function getUserByUsername(username: string): Promise<any> {
const listUsersBody = {
queries: [
{
userNameQuery: {
userName: username,
},
},
],
};
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody);
}
async function listCall(url: string, data: any): Promise<any> {
try {
const response = await axios.post(url, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function activateOTP(userId: string, type: OtpType) {
let url = "otp_";
switch (type) {
case OtpType.sms:
url = url + "sms";
break;
case OtpType.email:
url = url + "email";
break;
}
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {});
}
async function pushCall(url: string, data: any) {
try {
const response = await axios.post(url, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function addTOTP(userId: string): Promise<string> {
const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {});
const code = totp(response.secret);
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code });
return response.secret;
}
export function totp(secret: string) {
const authenticator = new Authenticator({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
});
// google authenticator usage
const token = authenticator.generate(secret);
// check if token can be used
if (!authenticator.verify({ token: token, secret: secret })) {
const error = `Generated token could not be verified`;
console.error(error);
throw new Error(error);
}
return token;
}

View File

@@ -1,22 +1,49 @@
FirstInstance:
MachineKeyPath: /machinekey/zitadel-admin-sa.json
PatPath: /pat/zitadel-admin-sa.pat
Org:
Human:
UserName: zitadel-admin
FirstName: ZITADEL
LastName: Admin
Password: Password1!
PasswordChangeRequired: false
PreferredLanguage: en
Machine:
Machine:
Username: zitadel-admin-sa
Name: Admin
MachineKey:
Type: 1
Pat:
ExpirationDate: 2099-01-01T00:00:00Z
DefaultInstance:
PrivacyPolicy:
TOSLink: "https://zitadel.com/docs/legal/terms-of-service"
PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy"
HelpLink: "https://zitadel.com/docs"
SupportEmail: "support@zitadel.com"
DocsLink: "https://zitadel.com/docs"
Database:
Cockroach:
EventPushConnRatio: 0.2 # 4
ProjectionSpoolerConnRatio: 0.3 # 6
postgres:
Host: db
Port: 5432
Database: zitadel
MaxOpenConns: 20
MaxIdleConns: 20
MaxConnLifetime: 1h
MaxConnIdleTime: 5m
User:
Username: zitadel
SSL:
Mode: disable
Admin:
Username: zitadel
SSL:
Mode: disable
Logstore:
Access:
Stdout:
Enabled: true
DefaultInstance:
LoginPolicy:
MfaInitSkipLifetime: 0h

View File

@@ -1 +1,3 @@
ZITADEL_API_URL=http://localhost:22222
ZITADEL_API_URL=http://localhost:22222
EMAIL_VERIFICATION=true
DEBUG=true

View File

@@ -4,4 +4,9 @@ module.exports = {
rules: {
"@next/next/no-html-link-for-pages": "off",
},
settings: {
react: {
version: "detect",
},
},
};

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

@@ -0,0 +1,2 @@
custom-config.js
.env.local

View File

@@ -0,0 +1,114 @@
import { stub } from "../support/mock";
describe("verify invite", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [], // user with no auth methods was invited
},
});
stub("zitadel.user.v2.UserService", "GetUserByID", {
data: {
user: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
isVerified: false,
},
},
},
},
});
stub("zitadel.session.v2.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
changeDate: new Date("2024-04-04T09:40:55.577Z"),
resourceOwner: "220516472055706145",
},
sessionId: "221394658884845598",
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
stub("zitadel.session.v2.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
creationDate: new Date("2024-04-04T09:40:55.577Z"),
changeDate: new Date("2024-04-04T09:40:55.577Z"),
sequence: 859,
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
},
password: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
},
},
});
stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
allowUsernamePassword: true,
},
},
});
});
it.only("shows authenticators after successful invite verification", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode");
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/authenticator/set",
);
});
it("shows an error if invite code validation failed", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode", {
code: 3,
error: "error validating code",
});
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.contains("Could not verify invite", { timeout: 10_000 });
});
});

View File

@@ -2,6 +2,14 @@ import { stub } from "../support/mock";
describe("login", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.session.v2.SessionService", "CreateSession", {
data: {
details: {
@@ -41,6 +49,7 @@ describe("login", () => {
data: {
settings: {
passkeysType: 1,
allowUsernamePassword: true,
},
},
});
@@ -104,16 +113,16 @@ describe("login", () => {
},
});
});
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",
);
});
// 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/set",
// );
// });
});
});
describe("passkey login", () => {
@@ -156,12 +165,10 @@ describe("login", () => {
},
});
});
it("should redirect a user with passwordless authentication to /passkey/login", () => {
it("should redirect a user with passwordless authentication to /passkey", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/passkey/login",
);
cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey");
});
});
});

View File

@@ -2,21 +2,75 @@ import { stub } from "../support/mock";
describe("register", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
allowRegister: true,
allowUsernamePassword: true,
defaultRedirectUri: "",
},
},
});
stub("zitadel.user.v2.UserService", "AddHumanUser", {
data: {
userId: "123",
userId: "221394658884845598",
},
});
stub("zitadel.session.v2.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
changeDate: new Date("2024-04-04T09:40:55.577Z"),
resourceOwner: "220516472055706145",
},
sessionId: "221394658884845598",
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
stub("zitadel.session.v2.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
creationDate: new Date("2024-04-04T09:40:55.577Z"),
changeDate: new Date("2024-04-04T09:40:55.577Z"),
sequence: 859,
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
},
password: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
},
},
});
});
it("should redirect a user who selects passwordless on register to /passkeys/add", () => {
it("should redirect a user who selects passwordless on register to /passkey/set", () => {
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[data-testid="firstname-text-input"]').focus().type("John");
cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe");
cy.get('input[data-testid="email-text-input"]')
.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");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey/set");
});
});

View File

@@ -1,19 +1,96 @@
import { stub } from "../support/mock";
describe("/verify", () => {
it("redirects after successful email verification", () => {
stub("zitadel.user.v2.UserService", "VerifyEmail");
cy.visit("/verify?userId=123&code=abc&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/loginname");
describe("verify email", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [1], // set one method such that we know that the user was not invited
},
});
stub("zitadel.user.v2.UserService", "SendEmailCode");
stub("zitadel.user.v2.UserService", "GetUserByID", {
data: {
user: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
isVerified: false, // email is not verified yet
},
},
},
},
});
stub("zitadel.session.v2.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
changeDate: new Date("2024-04-04T09:40:55.577Z"),
resourceOwner: "220516472055706145",
},
sessionId: "221394658884845598",
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
stub("zitadel.session.v2.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
creationDate: new Date("2024-04-04T09:40:55.577Z"),
changeDate: new Date("2024-04-04T09:40:55.577Z"),
sequence: 859,
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
},
password: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
},
},
});
});
it("shows an error if validation failed", () => {
it("shows an error if email code validation failed", () => {
stub("zitadel.user.v2.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");
cy.visit("/verify?userId=221394658884845598&code=abc");
cy.contains("Could not verify email", { timeout: 10_000 });
});
});

195
apps/login/locales/de.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"back": "Zurück"
},
"accounts": {
"title": "Konten",
"description": "Wählen Sie das Konto aus, das Sie verwenden möchten.",
"addAnother": "Ein weiteres Konto hinzufügen",
"noResults": "Keine Konten gefunden"
},
"loginname": {
"title": "Willkommen zurück!",
"description": "Geben Sie Ihre Anmeldedaten ein.",
"register": "Neuen Benutzer registrieren"
},
"password": {
"verify": {
"title": "Passwort",
"description": "Geben Sie Ihr Passwort ein.",
"resetPassword": "Passwort zurücksetzen",
"submit": "Weiter"
},
"set": {
"title": "Passwort festlegen",
"description": "Legen Sie das Passwort für Ihr Konto fest",
"codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.",
"noCodeReceived": "Keinen Code erhalten?",
"resend": "Erneut senden",
"submit": "Weiter"
},
"change": {
"title": "Passwort ändern",
"description": "Legen Sie das Passwort für Ihr Konto fest",
"submit": "Weiter"
}
},
"idp": {
"title": "Mit SSO anmelden",
"description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden",
"signInWithApple": "Mit Apple anmelden",
"signInWithGoogle": "Mit Google anmelden",
"signInWithAzureAD": "Mit AzureAD anmelden",
"signInWithGithub": "Mit GitHub anmelden",
"signInWithGitlab": "Mit GitLab anmelden",
"loginSuccess": {
"title": "Anmeldung erfolgreich",
"description": "Sie haben sich erfolgreich angemeldet!"
},
"linkingSuccess": {
"title": "Konto verknüpft",
"description": "Sie haben Ihr Konto erfolgreich verknüpft!"
},
"registerSuccess": {
"title": "Registrierung erfolgreich",
"description": "Sie haben sich erfolgreich registriert!"
},
"loginError": {
"title": "Anmeldung fehlgeschlagen",
"description": "Beim Anmelden ist ein Fehler aufgetreten."
},
"linkingError": {
"title": "Konto-Verknüpfung fehlgeschlagen",
"description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten."
}
},
"mfa": {
"verify": {
"title": "Bestätigen Sie Ihre Identität",
"description": "Wählen Sie einen der folgenden Faktoren.",
"noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten."
},
"set": {
"title": "2-Faktor einrichten",
"description": "Wählen Sie einen der folgenden zweiten Faktoren."
}
},
"otp": {
"verify": {
"title": "2-Faktor bestätigen",
"totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.",
"smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.",
"emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.",
"noCodeReceived": "Keinen Code erhalten?",
"resendCode": "Code erneut senden",
"submit": "Weiter"
},
"set": {
"title": "2-Faktor einrichten",
"totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.",
"smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.",
"emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.",
"totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.",
"submit": "Weiter"
}
},
"passkey": {
"verify": {
"title": "Mit einem Passkey authentifizieren",
"description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen",
"usePassword": "Passwort verwenden",
"submit": "Weiter"
},
"set": {
"title": "Passkey einrichten",
"description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen",
"info": {
"description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.",
"link": "Passwortlose Authentifizierung"
},
"skip": "Überspringen",
"submit": "Weiter"
}
},
"u2f": {
"verify": {
"title": "2-Faktor bestätigen",
"description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät."
},
"set": {
"title": "2-Faktor einrichten",
"description": "Richten Sie ein Gerät als zweiten Faktor ein.",
"submit": "Weiter"
}
},
"register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registrierung deaktiviert",
"description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator."
},
"missingdata": {
"title": "Registrierung fehlgeschlagen",
"description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben."
},
"title": "Registrieren",
"description": "Erstellen Sie Ihr ZITADEL-Konto.",
"selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten",
"agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen",
"termsOfService": "Nutzungsbedingungen",
"privacyPolicy": "Datenschutzrichtlinie",
"submit": "Weiter",
"password": {
"title": "Passwort festlegen",
"description": "Legen Sie das Passwort für Ihr Konto fest",
"submit": "Weiter"
}
},
"invite": {
"title": "Benutzer einladen",
"description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.",
"info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.",
"notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.",
"submit": "Einladen",
"success": {
"title": "Einladung erfolgreich",
"description": "Der Benutzer wurde erfolgreich eingeladen.",
"verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.",
"notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.",
"submit": "Weiteren Benutzer einladen"
}
},
"signedin": {
"title": "Willkommen {user}!",
"description": "Sie sind angemeldet.",
"continue": "Weiter"
},
"verify": {
"userIdMissing": "Keine Benutzer-ID angegeben!",
"success": "Erfolgreich verifiziert",
"setupAuthenticator": "Authentifikator einrichten",
"verify": {
"title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"noCodeReceived": "Keinen Code erhalten?",
"resendCode": "Code erneut senden",
"submit": "Weiter"
}
},
"authenticator": {
"title": "Authentifizierungsmethode auswählen",
"description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.",
"noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar",
"allSetup": "Sie haben bereits einen Authentifikator eingerichtet!",
"linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter"
},
"error": {
"unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.",
"sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"tryagain": "Erneut versuchen"
}
}

195
apps/login/locales/en.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"back": "Back"
},
"accounts": {
"title": "Accounts",
"description": "Select the account you want to use.",
"addAnother": "Add another account",
"noResults": "No accounts found"
},
"loginname": {
"title": "Welcome back!",
"description": "Enter your login data.",
"register": "Register new user"
},
"password": {
"verify": {
"title": "Password",
"description": "Enter your password.",
"resetPassword": "Reset Password",
"submit": "Continue"
},
"set": {
"title": "Set Password",
"description": "Set the password for your account",
"codeSent": "A code has been sent to your email address.",
"noCodeReceived": "Didn't receive a code?",
"resend": "Resend code",
"submit": "Continue"
},
"change": {
"title": "Change Password",
"description": "Set the password for your account",
"submit": "Continue"
}
},
"idp": {
"title": "Sign in with SSO",
"description": "Select one of the following providers to sign in",
"signInWithApple": "Sign in with Apple",
"signInWithGoogle": "Sign in with Google",
"signInWithAzureAD": "Sign in with AzureAD",
"signInWithGithub": "Sign in with GitHub",
"signInWithGitlab": "Sign in with GitLab",
"loginSuccess": {
"title": "Login successful",
"description": "You have successfully been loggedIn!"
},
"linkingSuccess": {
"title": "Account linked",
"description": "You have successfully linked your account!"
},
"registerSuccess": {
"title": "Registration successful",
"description": "You have successfully registered!"
},
"loginError": {
"title": "Login failed",
"description": "An error occurred while trying to login."
},
"linkingError": {
"title": "Account linking failed",
"description": "An error occurred while trying to link your account."
}
},
"mfa": {
"verify": {
"title": "Verify your identity",
"description": "Choose one of the following factors.",
"noResults": "No second factors available to setup."
},
"set": {
"title": "Set up 2-Factor",
"description": "Choose one of the following second factors."
}
},
"otp": {
"verify": {
"title": "Verify 2-Factor",
"totpDescription": "Enter the code from your authenticator app.",
"smsDescription": "Enter the code you received via SMS.",
"emailDescription": "Enter the code you received via email.",
"noCodeReceived": "Didn't receive a code?",
"resendCode": "Resend code",
"submit": "Continue"
},
"set": {
"title": "Set up 2-Factor",
"totpDescription": "Scan the QR code with your authenticator app.",
"smsDescription": "Enter your phone number to receive a code via SMS.",
"emailDescription": "Enter your email address to receive a code via email.",
"totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.",
"submit": "Continue"
}
},
"passkey": {
"verify": {
"title": "Authenticate with a passkey",
"description": "Your device will ask for your fingerprint, face, or screen lock",
"usePassword": "Use password",
"submit": "Continue"
},
"set": {
"title": "Setup a passkey",
"description": "Your device will ask for your fingerprint, face, or screen lock",
"info": {
"description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ",
"link": "Passwordless Authentication"
},
"skip": "Skip",
"submit": "Continue"
}
},
"u2f": {
"verify": {
"title": "Verify 2-Factor",
"description": "Verify your account with your device."
},
"set": {
"title": "Set up 2-Factor",
"description": "Set up a device as a second factor.",
"submit": "Continue"
}
},
"register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registration disabled",
"description": "The registration is disabled. Please contact your administrator."
},
"missingdata": {
"title": "Missing data",
"description": "Provide email, first and last name to register."
},
"title": "Register",
"description": "Create your ZITADEL account.",
"selectMethod": "Select the method you would like to authenticate",
"agreeTo": "To register you must agree to the terms and conditions",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"submit": "Continue",
"password": {
"title": "Set Password",
"description": "Set the password for your account",
"submit": "Continue"
}
},
"invite": {
"title": "Invite User",
"description": "Provide the email address and the name of the user you want to invite.",
"info": "The user will receive an email with further instructions.",
"notAllowed": "Your settings do not allow you to invite users.",
"submit": "Continue",
"success": {
"title": "User invited",
"description": "The email has successfully been sent.",
"verified": "The user has been invited and has already verified his email.",
"notVerifiedYet": "The user has been invited. They will receive an email with further instructions.",
"submit": "Invite another user"
}
},
"signedin": {
"title": "Welcome {user}!",
"description": "You are signed in.",
"continue": "Continue"
},
"verify": {
"userIdMissing": "No userId provided!",
"success": "The user has been verified successfully.",
"setupAuthenticator": "Setup authenticator",
"verify": {
"title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"noCodeReceived": "Didn't receive a code?",
"resendCode": "Resend code",
"submit": "Continue"
}
},
"authenticator": {
"title": "Choose authentication method",
"description": "Select the method you would like to authenticate",
"noMethodsAvailable": "No authentication methods available",
"allSetup": "You have already setup an authenticator!",
"linkWithIDP": "or link with an Identity Provider"
},
"error": {
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
"sessionExpired": "Your current session has expired. Please login again.",
"failedLoading": "Failed to load data. Please try again.",
"tryagain": "Try Again"
}
}

195
apps/login/locales/es.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"back": "Atrás"
},
"accounts": {
"title": "Cuentas",
"description": "Selecciona la cuenta que deseas usar.",
"addAnother": "Agregar otra cuenta",
"noResults": "No se encontraron cuentas"
},
"loginname": {
"title": "¡Bienvenido de nuevo!",
"description": "Introduce tus datos de acceso.",
"register": "Registrar nuevo usuario"
},
"password": {
"verify": {
"title": "Contraseña",
"description": "Introduce tu contraseña.",
"resetPassword": "Restablecer contraseña",
"submit": "Continuar"
},
"set": {
"title": "Establecer Contraseña",
"description": "Establece la contraseña para tu cuenta",
"codeSent": "Se ha enviado un código a su correo electrónico.",
"noCodeReceived": "¿No recibiste un código?",
"resend": "Reenviar código",
"submit": "Continuar"
},
"change": {
"title": "Cambiar Contraseña",
"description": "Establece la contraseña para tu cuenta",
"submit": "Continuar"
}
},
"idp": {
"title": "Iniciar sesión con SSO",
"description": "Selecciona uno de los siguientes proveedores para iniciar sesión",
"signInWithApple": "Iniciar sesión con Apple",
"signInWithGoogle": "Iniciar sesión con Google",
"signInWithAzureAD": "Iniciar sesión con AzureAD",
"signInWithGithub": "Iniciar sesión con GitHub",
"signInWithGitlab": "Iniciar sesión con GitLab",
"loginSuccess": {
"title": "Inicio de sesión exitoso",
"description": "¡Has iniciado sesión con éxito!"
},
"linkingSuccess": {
"title": "Cuenta vinculada",
"description": "¡Has vinculado tu cuenta con éxito!"
},
"registerSuccess": {
"title": "Registro exitoso",
"description": "¡Te has registrado con éxito!"
},
"loginError": {
"title": "Error de inicio de sesión",
"description": "Ocurrió un error al intentar iniciar sesión."
},
"linkingError": {
"title": "Error al vincular la cuenta",
"description": "Ocurrió un error al intentar vincular tu cuenta."
}
},
"mfa": {
"verify": {
"title": "Verifica tu identidad",
"description": "Elige uno de los siguientes factores.",
"noResults": "No hay factores secundarios disponibles para configurar."
},
"set": {
"title": "Configurar autenticación de 2 factores",
"description": "Elige uno de los siguientes factores secundarios."
}
},
"otp": {
"verify": {
"title": "Verificar autenticación de 2 factores",
"totpDescription": "Introduce el código de tu aplicación de autenticación.",
"smsDescription": "Introduce el código que recibiste por SMS.",
"emailDescription": "Introduce el código que recibiste por correo electrónico.",
"noCodeReceived": "¿No recibiste un código?",
"resendCode": "Reenviar código",
"submit": "Continuar"
},
"set": {
"title": "Configurar autenticación de 2 factores",
"totpDescription": "Escanea el código QR con tu aplicación de autenticación.",
"smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.",
"emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.",
"totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.",
"submit": "Continuar"
}
},
"passkey": {
"verify": {
"title": "Autenticar con una clave de acceso",
"description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla",
"usePassword": "Usar contraseña",
"submit": "Continuar"
},
"set": {
"title": "Configurar una clave de acceso",
"description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla",
"info": {
"description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.",
"link": "Autenticación sin contraseña"
},
"skip": "Omitir",
"submit": "Continuar"
}
},
"u2f": {
"verify": {
"title": "Verificar autenticación de 2 factores",
"description": "Verifica tu cuenta con tu dispositivo."
},
"set": {
"title": "Configurar autenticación de 2 factores",
"description": "Configura un dispositivo como segundo factor.",
"submit": "Continuar"
}
},
"register": {
"methods": {
"passkey": "Clave de acceso",
"password": "Contraseña"
},
"disabled": {
"title": "Registro deshabilitado",
"description": "Registrarse está deshabilitado en este momento."
},
"missingdata": {
"title": "Datos faltantes",
"description": "No se proporcionaron datos suficientes para el registro."
},
"title": "Registrarse",
"description": "Crea tu cuenta ZITADEL.",
"selectMethod": "Selecciona el método con el que deseas autenticarte",
"agreeTo": "Para registrarte debes aceptar los términos y condiciones",
"termsOfService": "Términos de Servicio",
"privacyPolicy": "Política de Privacidad",
"submit": "Continuar",
"password": {
"title": "Establecer Contraseña",
"description": "Establece la contraseña para tu cuenta",
"submit": "Continuar"
}
},
"invite": {
"title": "Invitar usuario",
"description": "Introduce el correo electrónico del usuario que deseas invitar.",
"info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.",
"notAllowed": "No tienes permiso para invitar usuarios.",
"submit": "Invitar usuario",
"success": {
"title": "¡Usuario invitado!",
"description": "El usuario ha sido invitado.",
"verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.",
"notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.",
"submit": "Invitar a otro usuario"
}
},
"signedin": {
"title": "¡Bienvenido {user}!",
"description": "Has iniciado sesión.",
"continue": "Continuar"
},
"verify": {
"userIdMissing": "¡No se proporcionó userId!",
"success": "¡Verificación exitosa!",
"setupAuthenticator": "Configurar autenticador",
"verify": {
"title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"noCodeReceived": "¿No recibiste un código?",
"resendCode": "Reenviar código",
"submit": "Continuar"
}
},
"authenticator": {
"title": "Seleccionar método de autenticación",
"description": "Selecciona el método con el que deseas autenticarte",
"noMethodsAvailable": "No hay métodos de autenticación disponibles",
"allSetup": "¡Ya has configurado un autenticador!",
"linkWithIDP": "o vincúlalo con un proveedor de identidad"
},
"error": {
"unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.",
"sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.",
"failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.",
"tryagain": "Intentar de nuevo"
}
}

195
apps/login/locales/it.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"back": "Indietro"
},
"accounts": {
"title": "Account",
"description": "Seleziona l'account che desideri utilizzare.",
"addAnother": "Aggiungi un altro account",
"noResults": "Nessun account trovato"
},
"loginname": {
"title": "Bentornato!",
"description": "Inserisci i tuoi dati di accesso.",
"register": "Registrati come nuovo utente"
},
"password": {
"verify": {
"title": "Password",
"description": "Inserisci la tua password.",
"resetPassword": "Reimposta Password",
"submit": "Continua"
},
"set": {
"title": "Imposta Password",
"description": "Imposta la password per il tuo account",
"codeSent": "Un codice è stato inviato al tuo indirizzo email.",
"noCodeReceived": "Non hai ricevuto un codice?",
"resend": "Invia di nuovo",
"submit": "Continua"
},
"change": {
"title": "Cambia Password",
"description": "Imposta la password per il tuo account",
"submit": "Continua"
}
},
"idp": {
"title": "Accedi con SSO",
"description": "Seleziona uno dei seguenti provider per accedere",
"signInWithApple": "Accedi con Apple",
"signInWithGoogle": "Accedi con Google",
"signInWithAzureAD": "Accedi con AzureAD",
"signInWithGithub": "Accedi con GitHub",
"signInWithGitlab": "Accedi con GitLab",
"loginSuccess": {
"title": "Accesso riuscito",
"description": "Accesso effettuato con successo!"
},
"linkingSuccess": {
"title": "Account collegato",
"description": "Hai collegato con successo il tuo account!"
},
"registerSuccess": {
"title": "Registrazione riuscita",
"description": "Registrazione effettuata con successo!"
},
"loginError": {
"title": "Accesso fallito",
"description": "Si è verificato un errore durante il tentativo di accesso."
},
"linkingError": {
"title": "Collegamento account fallito",
"description": "Si è verificato un errore durante il tentativo di collegare il tuo account."
}
},
"mfa": {
"verify": {
"title": "Verifica la tua identità",
"description": "Scegli uno dei seguenti fattori.",
"noResults": "Nessun secondo fattore disponibile per la configurazione."
},
"set": {
"title": "Configura l'autenticazione a 2 fattori",
"description": "Scegli uno dei seguenti secondi fattori."
}
},
"otp": {
"verify": {
"title": "Verifica l'autenticazione a 2 fattori",
"totpDescription": "Inserisci il codice dalla tua app di autenticazione.",
"smsDescription": "Inserisci il codice ricevuto via SMS.",
"emailDescription": "Inserisci il codice ricevuto via email.",
"noCodeReceived": "Non hai ricevuto un codice?",
"resendCode": "Invia di nuovo il codice",
"submit": "Continua"
},
"set": {
"title": "Configura l'autenticazione a 2 fattori",
"totpDescription": "Scansiona il codice QR con la tua app di autenticazione.",
"smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.",
"emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.",
"totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.",
"submit": "Continua"
}
},
"passkey": {
"verify": {
"title": "Autenticati con una passkey",
"description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo",
"usePassword": "Usa password",
"submit": "Continua"
},
"set": {
"title": "Configura una passkey",
"description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo",
"info": {
"description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.",
"link": "Autenticazione senza password"
},
"skip": "Salta",
"submit": "Continua"
}
},
"u2f": {
"verify": {
"title": "Verifica l'autenticazione a 2 fattori",
"description": "Verifica il tuo account con il tuo dispositivo."
},
"set": {
"title": "Configura l'autenticazione a 2 fattori",
"description": "Configura un dispositivo come secondo fattore.",
"submit": "Continua"
}
},
"register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registration disabled",
"description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza."
},
"missingdata": {
"title": "Registrazione",
"description": "Inserisci i tuoi dati per registrarti."
},
"title": "Registrati",
"description": "Crea il tuo account ZITADEL.",
"selectMethod": "Seleziona il metodo con cui desideri autenticarti",
"agreeTo": "Per registrarti devi accettare i termini e le condizioni",
"termsOfService": "Termini di Servizio",
"privacyPolicy": "Informativa sulla Privacy",
"submit": "Continua",
"password": {
"title": "Imposta Password",
"description": "Imposta la password per il tuo account",
"submit": "Continua"
}
},
"invite": {
"title": "Invita Utente",
"description": "Inserisci l'indirizzo email dell'utente che desideri invitare.",
"info": "L'utente riceverà un'email con ulteriori istruzioni.",
"notAllowed": "Non hai i permessi per invitare un utente.",
"submit": "Invita Utente",
"success": {
"title": "Invito inviato",
"description": "L'utente è stato invitato con successo.",
"verified": "L'utente è stato invitato e ha già verificato la sua email.",
"notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.",
"submit": "Invita un altro utente"
}
},
"signedin": {
"title": "Benvenuto {user}!",
"description": "Sei connesso.",
"continue": "Continua"
},
"verify": {
"userIdMissing": "Nessun userId fornito!",
"success": "Verifica effettuata con successo!",
"setupAuthenticator": "Configura autenticatore",
"verify": {
"title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"noCodeReceived": "Non hai ricevuto un codice?",
"resendCode": "Invia di nuovo il codice",
"submit": "Continua"
}
},
"authenticator": {
"title": "Seleziona metodo di autenticazione",
"description": "Seleziona il metodo con cui desideri autenticarti",
"noMethodsAvailable": "Nessun metodo di autenticazione disponibile",
"allSetup": "Hai già configurato un autenticatore!",
"linkWithIDP": "o collega con un Identity Provider"
},
"error": {
"unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.",
"sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.",
"failedLoading": "Impossibile caricare i dati. Riprova.",
"tryagain": "Riprova"
}
}

195
apps/login/locales/zh.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"back": "返回"
},
"accounts": {
"title": "账户",
"description": "选择您想使用的账户。",
"addAnother": "添加另一个账户",
"noResults": "未找到账户"
},
"loginname": {
"title": "欢迎回来!",
"description": "请输入您的登录信息。",
"register": "注册新用户"
},
"password": {
"verify": {
"title": "密码",
"description": "请输入您的密码。",
"resetPassword": "重置密码",
"submit": "继续"
},
"set": {
"title": "设置密码",
"description": "为您的账户设置密码",
"codeSent": "验证码已发送到您的邮箱。",
"noCodeReceived": "没有收到验证码?",
"resend": "重发验证码",
"submit": "继续"
},
"change": {
"title": "更改密码",
"description": "为您的账户设置密码",
"submit": "继续"
}
},
"idp": {
"title": "使用 SSO 登录",
"description": "选择以下提供商中的一个进行登录",
"signInWithApple": "用 Apple 登录",
"signInWithGoogle": "用 Google 登录",
"signInWithAzureAD": "用 AzureAD 登录",
"signInWithGithub": "用 GitHub 登录",
"signInWithGitlab": "用 GitLab 登录",
"loginSuccess": {
"title": "登录成功",
"description": "您已成功登录!"
},
"linkingSuccess": {
"title": "账户已链接",
"description": "您已成功链接您的账户!"
},
"registerSuccess": {
"title": "注册成功",
"description": "您已成功注册!"
},
"loginError": {
"title": "登录失败",
"description": "登录时发生错误。"
},
"linkingError": {
"title": "账户链接失败",
"description": "链接账户时发生错误。"
}
},
"mfa": {
"verify": {
"title": "验证您的身份",
"description": "选择以下的一个因素。",
"noResults": "没有可设置的第二因素。"
},
"set": {
"title": "设置双因素认证",
"description": "选择以下的一个第二因素。"
}
},
"otp": {
"verify": {
"title": "验证双因素",
"totpDescription": "请输入认证应用程序中的验证码。",
"smsDescription": "输入通过短信收到的验证码。",
"emailDescription": "输入通过电子邮件收到的验证码。",
"noCodeReceived": "没有收到验证码?",
"resendCode": "重发验证码",
"submit": "继续"
},
"set": {
"title": "设置双因素认证",
"totpDescription": "使用认证应用程序扫描二维码。",
"smsDescription": "输入您的电话号码以接收短信验证码。",
"emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。",
"totpRegisterDescription": "扫描二维码或手动导航到URL。",
"submit": "继续"
}
},
"passkey": {
"verify": {
"title": "使用密钥认证",
"description": "您的设备将请求指纹、面部识别或屏幕锁",
"usePassword": "使用密码",
"submit": "继续"
},
"set": {
"title": "设置密钥",
"description": "您的设备将请求指纹、面部识别或屏幕锁",
"info": {
"description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。",
"link": "无密码认证"
},
"skip": "跳过",
"submit": "继续"
}
},
"u2f": {
"verify": {
"title": "验证双因素",
"description": "使用您的设备验证帐户。"
},
"set": {
"title": "设置双因素认证",
"description": "设置设备为第二因素。",
"submit": "继续"
}
},
"register": {
"methods": {
"passkey": "密钥",
"password": "密码"
},
"disabled": {
"title": "注册已禁用",
"description": "您的设置不允许注册新用户。"
},
"missingdata": {
"title": "缺少数据",
"description": "请提供所有必需的数据。"
},
"title": "注册",
"description": "创建您的 ZITADEL 账户。",
"selectMethod": "选择您想使用的认证方法",
"agreeTo": "注册即表示您同意条款和条件",
"termsOfService": "服务条款",
"privacyPolicy": "隐私政策",
"submit": "继续",
"password": {
"title": "设置密码",
"description": "为您的账户设置密码",
"submit": "继续"
}
},
"invite": {
"title": "邀请用户",
"description": "提供您想邀请的用户的电子邮箱地址和姓名。",
"info": "用户将收到一封包含进一步说明的电子邮件。",
"notAllowed": "您的设置不允许邀请用户。",
"submit": "继续",
"success": {
"title": "用户已邀请",
"description": "邮件已成功发送。",
"verified": "用户已被邀请并已验证其电子邮件。",
"notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。",
"submit": "邀请另一位用户"
}
},
"signedin": {
"title": "欢迎 {user}",
"description": "您已登录。",
"continue": "继续"
},
"verify": {
"userIdMissing": "未提供用户 ID",
"success": "用户验证成功。",
"setupAuthenticator": "设置认证器",
"verify": {
"title": "验证用户",
"description": "输入验证邮件中的验证码。",
"noCodeReceived": "没有收到验证码?",
"resendCode": "重发验证码",
"submit": "继续"
}
},
"authenticator": {
"title": "选择认证方式",
"description": "选择您想使用的认证方法",
"noMethodsAvailable": "没有可用的认证方法",
"allSetup": "您已经设置好了一个认证器!",
"linkWithIDP": "或将其与身份提供者关联"
},
"error": {
"unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。",
"sessionExpired": "当前会话已过期,请重新登录。",
"failedLoading": "加载数据失败,请再试一次。",
"tryagain": "重试"
}
}

View File

@@ -2,7 +2,9 @@
{
"service": "zitadel.settings.v2.SettingsService",
"method": "GetBrandingSettings",
"out": {}
"out": {
"data": {}
}
},
{
"service": "zitadel.settings.v2.SettingsService",

View File

@@ -1,4 +1,5 @@
zitadel/user/v2/user_service.proto
zitadel/org/v2/org_service.proto
zitadel/session/v2/session_service.proto
zitadel/settings/v2/settings_service.proto
zitadel/management.proto

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,3 +1,7 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const secureHeaders = [
@@ -32,9 +36,8 @@ const secureHeaders = [
const nextConfig = {
reactStrictMode: true, // Recommended for the `pages` directory, default in `app`.
swcMinify: true,
experimental: {
serverActions: true,
dynamicIO: true,
},
images: {
remotePatterns: [
@@ -62,4 +65,4 @@ const nextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@@ -1,14 +1,16 @@
{
"name": "@zitadel/login",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev": "next dev --turbopack",
"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": "vitest",
"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:watch:run": "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:watch:open": "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:open\\\"\"'",
"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",
@@ -23,6 +25,7 @@
"build": "next build",
"prestart": "pnpm build",
"start": "next start",
"start:built": "next start",
"clean": "pnpm mock:destroy && rm -rf .turbo && rm -rf node_modules && rm -rf .next"
},
"git": {
@@ -32,57 +35,58 @@
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@headlessui/react": "^1.7.18",
"@headlessui/react": "^2.1.9",
"@heroicons/react": "2.1.3",
"@tailwindcss/forms": "0.5.7",
"@vercel/analytics": "^1.2.2",
"@zitadel/client": "workspace:*",
"@zitadel/node": "workspace:*",
"@zitadel/proto": "workspace:*",
"@zitadel/react": "workspace:*",
"clsx": "1.2.1",
"copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1",
"moment": "^2.29.4",
"next": "14.2.3",
"next": "15.0.4-canary.23",
"next-intl": "^3.25.1",
"next-themes": "^0.2.1",
"nice-grpc": "2.0.1",
"qrcode.react": "^3.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-hook-form": "7.39.5",
"swr": "^2.2.0",
"tinycolor2": "1.4.2"
},
"devDependencies": {
"@bufbuild/buf": "^1.35.1",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@types/ms": "0.7.31",
"@types/node": "18.11.9",
"@types/react": "18.2.8",
"@types/react-dom": "18.0.9",
"@bufbuild/buf": "^1.46.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/ms": "0.7.34",
"@types/node": "22.9.0",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "^9.0.1",
"@types/uuid": "^10.0.0",
"@vercel/git-hooks": "1.0.0",
"@zitadel/prettier-config": "workspace:*",
"@zitadel/tsconfig": "workspace:*",
"autoprefixer": "10.4.13",
"concurrently": "^8.1.0",
"cypress": "^13.9.0",
"del-cli": "5.0.0",
"autoprefixer": "10.4.20",
"concurrently": "^9.1.0",
"cypress": "^13.15.2",
"del-cli": "6.0.0",
"env-cmd": "^10.1.0",
"eslint-config-zitadel": "workspace:*",
"grpc-tools": "1.11.3",
"lint-staged": "13.0.3",
"make-dir-cli": "3.0.0",
"nodemon": "^2.0.22",
"postcss": "8.4.21",
"prettier-plugin-tailwindcss": "0.1.13",
"sass": "^1.77.1",
"start-server-and-test": "^2.0.0",
"tailwindcss": "3.2.4",
"ts-proto": "^1.139.0",
"typescript": "^5.4.5",
"grpc-tools": "1.12.4",
"jsdom": "^25.0.1",
"lint-staged": "15.2.10",
"make-dir-cli": "4.0.0",
"nodemon": "^3.1.7",
"postcss": "8.4.49",
"prettier-plugin-tailwindcss": "0.6.8",
"sass": "^1.80.7",
"start-server-and-test": "^2.0.8",
"tailwindcss": "3.4.14",
"ts-proto": "^2.2.7",
"typescript": "^5.6.3",
"zitadel-tailwind-config": "workspace:*"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='white'><path d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z' /></svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 467 467" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="100%" height="100%" viewBox="0 0 467 467" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g id="zitadel-logo-solo-darkdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,0)">
<rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/>
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5923.46,-2258.26)">

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 467 468" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="100%" height="100%" viewBox="0 0 467 468" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-492)">
<g id="zitadel-logo-solo-lightdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,492.925)">
<rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 295 81" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="100%" height="100%" viewBox="0 0 295 81" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-107)">
<g id="zitadel-logo-dark" transform="matrix(1,0,0,1,-20.9181,18.2562)">
<rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 295 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<svg width="100%" height="100%" viewBox="0 0 295 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g id="zitadel-logo-light" transform="matrix(1,0,0,1,-20.9181,-89.5699)">
<rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/>
<g transform="matrix(2.73883,0,0,1.55076,-35267,23.6366)">

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -2,24 +2,399 @@
This is going to be our next UI for the hosted login. It's based on Next.js 13 and its introduced `app/` directory.
The Login UI should provide the following functionality:
## Flow Diagram
- **Login API:** Uses the new ZITADEL Login API
- **Server Components:** Making server-first components
This diagram shows the available pages and flows.
## Running Locally
> Note that back navigation or retries are not displayed.
1. Install dependencies: `yarn install`
1. Start the dev server: `yarn dev`
```mermaid
flowchart TD
A[Start] --> register
A[Start] --> accounts
A[Start] --> loginname
loginname -- signInWithIDP --> idp-success
loginname -- signInWithIDP --> idp-failure
idp-success --> B[signedin]
loginname --> password
loginname -- hasPasskey --> passkey
loginname -- allowRegister --> register
passkey-add --passwordAllowed --> password
passkey -- hasPassword --> password
passkey --> B[signedin]
password -- hasMFA --> mfa
password -- allowPasskeys --> passkey-add
password -- reset --> password-set
email -- reset --> password-set
password-set --> B[signedin]
password-change --> B[signedin]
password -- userstate=initial --> password-change
## Documentation
mfa --> otp
otp --> B[signedin]
mfa--> u2f
u2f -->B[signedin]
register -- password/passkey --> B[signedin]
password --> B[signedin]
password-- forceMFA -->mfaset
mfaset --> u2fset
mfaset --> otpset
u2fset --> B[signedin]
otpset --> B[signedin]
accounts--> loginname
password -- not verified yet -->verify
register-- withpassword -->verify
passkey-- notVerified --> verify
verify --> B[signedin]
```
https://beta.nextjs.org/docs
### /loginname
<!--
This page shows a loginname field and Identity Providers to login or register.
If `loginSettings(org?).allowRegister` is `true`, it also shows a link to jump to /register
This can be uncommented once @zitadel/... packages are available in the public npm registry
<img src="./screenshots/loginname.png" alt="/loginame" width="400px" />
## Deploy your own
Requests to the APIs made:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript%2Ftree%2Fmain%2Fapps%2Flogin&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&demo-title=Next.js%20Login&demo-description=A%20Login%20Application%20built%20with%20Next.js) -->
- `getLoginSettings(org?)`
- `getLegalAndSupportSettings(org?)`
- `getIdentityProviders(org?)`
- `getBrandingSettings(org?)`
- `getActiveIdentityProviders(org?)`
- `startIdentityProviderFlow`
- `listUsers(org?)`
- `listAuthenticationMethodTypes`
- `getOrgsByDomain`
- `createSession()`
- `getSession()`
After a loginname is entered, a `listUsers` request is made using the loginName query to identify already registered users.
**USER FOUND:** If only one user is found, we query `listAuthenticationMethodTypes` to identify future steps.
If no authentication methods are found, we render an error stating: _User has no available authentication methods._ (exception see below.)
Now if only one method is found, we continue with the corresponding step (/password, /passkey).
If multiple methods are set, we prefer passkeys over any other method, so we redirect to /passkey, second option is IDP, and third is password.
If password is the next step, we check `loginSettings.passkeysType` for PasskeysType.ALLOWED, and prompt the user to setup passkeys afterwards.
**NO USER FOUND:** If no user is found, we check whether registering is allowed using `loginSettings.allowRegister`.
If `loginSettings?.allowUsernamePassword` is not allowed we continue to check for available IDPs. If a single IDP is available, we directly redirect the user to signup.
If no single IDP is set, we check for `loginSettings.allowUsernamePassword` and if no organization is set as context, we check whether we can discover a organization from the loginname of the user (using: `getOrgsByDomain`). Then if an organization is found, we check whether domainDiscovery is allowed on it and redirect the user to /register page including the discovered domain or without.
If no previous condition is met we throw an error stating the user was not found.
**EXCEPTIONS:** If the outcome after this order produces a no authentication methods found, or user not found, we check whether `loginSettings?.ignoreUnknownUsernames` is set to `true` as in this case we redirect to the /password page regardless (to prevent username guessing).
> NOTE: This page at this stage beeing ignores local sessions and executes a reauthentication. This is a feature which is not implemented yet.
> NOTE: We ignore `loginSettings.allowExternalIdp` as the information whether IDPs are available comes as response from `getActiveIdentityProviders(org?)`. If a user has a cookie for the same loginname, a new session is created regardless and overwrites the old session. The old session is not deleted from the login as for now.
> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615)
### /password
This page shows a password field to hydrate the current session with password as a factor.
Below the password field, a reset password link is shown which allows to send a reset email.
<img src="./screenshots/password.png" alt="/password" width="400px" />
Requests to the APIs made:
- `getLoginSettings(org?)`
- `getBrandingSettings(org?)`
- `listAuthenticationMethodTypes`
- `getSession()`
- `updateSession()`
- `listUsers()`
- `getUserById()`
**MFA AVAILABLE:** After the password has been submitted, additional authentication methods are loaded.
If the user has set up an additional **single** second factor, it is redirected to add the next factor. Depending on the available method he is redirected to `/otp/time-based`,`/otp/sms?`, `/otp/email?` or `/u2f?`. If the user has multiple second factors, he is redirected to `/mfa` to select his preferred method to continue.
**NO MFA, USER STATE INITIAL** If the user has no MFA methods and is in an initial state, we redirect to `/password/change` where a new password can be set.
**NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor.
**PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType == PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped.
If none of the previous conditions apply, we continue to sign in.
> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615)
### /password/change
This page allows to change the password. It is used after a user is in an initial state and is required to change the password, or it can be directly invoked with an active session.
<img src="./screenshots/password_change.png" alt="/password/change" width="400px" />
Requests to the APIs made:
- `getLoginSettings(org?)`
- `getPasswordComplexitySettings(user?)`
- `getBrandingSettings(org?)`
- `getSession()`
- `setPassword()`
> NOTE: The request to change the password is using the session of the user itself not the service user, therefore no code is required.
### /password/set
This page allows to set a password. It is used after a user has requested to reset the password on the `/password` page.
<img src="./screenshots/password_set.png" alt="/password/set" width="400px" />
Requests to the APIs made:
- `getLoginSettings(org?)`
- `getPasswordComplexitySettings(user?)`
- `getBrandingSettings(org?)`
- `getUserByID()`
- `setPassword()`
The page allows to enter a code or be invoked directly from a email link which prefills the code. The user can enter a new password and submit.
### /otp/[method]
This page shows a code field to check an otp method. The session of the user is then hydrated with the respective factor. Supported methods are `time-based`, `sms` and `email`.
<img src="./screenshots/otp.png" alt="/otp/[method]" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `updateSession()`
If `email` or `sms` is requested as method, the current session of the user is updated to request the challenge. This will trigger an email or sms which can be entered in the code field.
The `time-based` (TOTP) method does not require a trigger, therefore no `updateSession()` is performed and no resendLink under the code field is shown.
The submission of the code updates the session and continues to sign in the user.
### /u2f
This page requests a webAuthN challenge for the user and updates the session afterwards.
<img src="./screenshots/u2f.png" alt="/u2f" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `updateSession()`
When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.DISCOURAGED` as this will request the webAuthN method as second factor and not as primary method.
After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a u2f method.
### /passkey
This page requests a webAuthN challenge for the user and updates the session afterwards.
It is invoked directly after setting up a passkey `/passkey/set` or when loggin in a user after `/loginname`.
<img src="./screenshots/passkey.png" alt="/passkey" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `updateSession()`
When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.REQUIRED` as this will request the webAuthN method as primary method to login.
After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a passkey
> NOTE: This page currently does not check whether a user contains passkeys. If this method is not available, this page should not be used.
### /mfa/set
This page loads login settings and the authentication methods for a user and shows setup options.
<img src="./screenshots/mfaset.png" alt="/mfa/set" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getLoginSettings(user.org)` :warning: context taken from session
- `getSession()`
- `listAuthenticationMethodTypes()`
- `getUserByID()`
If a user has already setup a certain method, a checkbox is shown alongside the button and the button is disabled.
OTP Email and OTP SMS only show up if the user has verified email or phone.
If the user chooses a method he is redirected to one of `/otp/time-based/set`, `/u2f/set`, `/otp/email/set`, or `/otp/sms/set`.
At the moment, U2F methods are hidden if a method is already added on the users resource. Reasoning is that the page should only be invoked for prompts. A self service page which shows up multiple u2f factors is implemented at a later stage.
> NOTE: The session and therefore the user factor defines which login settings are checked for available options.
> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f or passkeys. The check whether a user should be redirected to one of the pages `/passkey/set` or `/u2f/set`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615)
### /passkey/set
This page sets a passkey method for a user. This page can be either enforced, or optional depending on the Login Settings.
<!-- screen is the same -->
<img src="./screenshots/u2fset.png" alt="/passkey/set" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `createPasskeyRegistrationLink()` TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0))
- `registerPasskey()`
- `verifyPasskey()`
If the loginname decides to redirect the user to this page, a button to skip appears which will sign the user in afterwards.
After a passkey is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow.
> NOTE: this page allows passkeys to be created only if the current session is valid (self service), or no authentication method is set (register). TODO: to be implemented.
> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611)
### /otp/time-based/set
This page registers a time based OTP method for a user.
<img src="./screenshots/otpset.png" alt="/otp/time-based/set" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `registerTOTP()`
- `verifyTOTP()`
After the setup is done, the user is redirected to verify the TOTP method on `/otp/time-based`.
> NOTE: Redirecting the user to `/otp/time-based` will not be required in future and the currently used session will be hydrated directly. (https://github.com/zitadel/zitadel/issues/8611)
### /otp/email/set /otp/sms/set
This page registers either an Email OTP method or SMS OTP method for a user.
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `addOTPEmail()` / `addOTPSMS()`
This page directly calls `addOTPEmail()` or `addOTPSMS()` when invoked and shows a success message.
Right afterwards, redirects to verify the method.
### /u2f/set
This page registers a U2F method for a user.
<img src="./screenshots/u2fset.png" alt="/u2f/set" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getSession()`
- `registerU2F()` :warning: TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0))
- `verifyU2FRegistration()`
After a u2f method is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow.
> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611)
### /register
This page shows a register page, which gets firstname and lastname of a user as well as the email. It offers to setup a user, using password or passkeys.
<img src="./screenshots/register.png" alt="/register" width="400px" />
<img src="./screenshots/register_password.png" alt="register with password" width="400px" />
Requests to the APIs made:
- `listOrganizations()` :warning: TODO: determine the default organization if no context is set
- `getLegalAndSupportSettings(org)`
- `getPasswordComplexitySettings()`
- `getBrandingSettings()`
- `addHumanUser()`
- `createSession()`
- `getSession()`
To register a user, the organization where the resource will be created is determined first. If no context is provided via url, we fall back to the default organization of the instance.
**PASSWORD:** If a password is set, the user is created as a resource, then a session using the password check is created immediately. After creating the session, the user is directly logged in and eventually redirected back to the application.
**PASSKEY:** If passkey is selected, the user is created as a resource first, then a session using the userId is created immediately. This session does not yet contain a check, we therefore redirect the user to setup a passkey at `/passkey/set`. As the passkey set page verifies the passkey right afterwards, the process ends with a signed in user.
> NOTE: https://github.com/zitadel/zitadel/issues/8616 to determine the default organization of an instance must be implemented in order to correctly use the legal-, login-, branding- and complexitysettings.
> NOTE: TODO: check which methods are allowed in the login settings, loginSettings.allowUsernamePassword / check for passkey
### /idp
This page doubles as /loginname but limits it to choose from IDPs
<img src="./screenshots/idp.png" alt="/idp" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getActiveIdentityProviders(org?)`
- `startIdentityProviderFlow()`
### /idp/[method]/success /idp/[method]/failure
Both /success and /failure pages are designed to intercept the responses from the IDPs and decide on how to continue with the process.
### /verify
This page verifies the email to be valid. It page of the login can also be invoked without an active session.
The context of the user is taken from the url and is set in the email template.
<img src="./screenshots/accounts.png" alt="/accounts" width="400px" />
Requests to the APIs made:
- `getBrandingSettings(org?)`
- `getLoginSettings(org?)`
- `verifyEmail()`
If the page is invoked with an active session (right after a register with password), the user is signed in or redirected to the loginname if no context is known.
> NOTE: This page will be extended to support invitations. In such case, authentication methods of the user are loaded and if none available, shown as possible next step (`/passkey/set`, `password/set`).
### /accounts
This page shows an overview of all current sessions.
Sessions with invalid token show a red dot on the right side, Valid session a green dot, and its last verified date.
<img src="./screenshots/accounts.png" alt="/accounts" width="400px" />
This page is a starting point for self management, reauthentication, or can be used to clear local sessions.
This page is also shown if used with OIDC and `prompt: select_account`.
On all pages, where the current user is shown, you can jump to this page. This way, a session can quickly be reused if valid.
<img src="./screenshots/accounts_jumpto.png" alt="jump to accounts" width="250px" />
### /signedin
This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest.
<img src="./screenshots/signedin.png" alt="/signedin" width="400px" />
In future, self service options to jump to are shown below, like:
- change password
- setup passkeys
- setup mfa
- change profile
- logout
> NOTE: This page has to be explicitly enabled or act as a fallback if no default redirect is set.
## Currently NOT Supported
Timebased features like the multifactor init prompt or password expiry, are not supported due to a current limitation in the API. Lockout settings which keeps track of the password retries, will also be implemented in a later stage.
- Lockout Settings
- Password Expiry Settings
- Login Settings: multifactor init prompt
- forceMFA on login settings is not checked for IDPs
- disablePhone / disableEmail from loginSettings will be implemented right after https://github.com/zitadel/zitadel/issues/9016 is merged
Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced.

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,9 +1,15 @@
import { getBrandingSettings, listSessions } from "@/lib/zitadel";
import { getAllSessionCookieIds } from "@/utils/cookies";
import { DynamicTheme } from "@/components/dynamic-theme";
import { SessionsList } from "@/components/sessions-list";
import { getAllSessionCookieIds } from "@/lib/cookies";
import {
getBrandingSettings,
getDefaultOrg,
listSessions,
} from "@/lib/zitadel";
import { UserPlusIcon } from "@heroicons/react/24/outline";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link";
import SessionsList from "@/ui/SessionsList";
import DynamicTheme from "@/ui/DynamicTheme";
async function loadSessions() {
const ids = await getAllSessionCookieIds();
@@ -19,41 +25,54 @@ async function loadSessions() {
}
}
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "accounts" });
const authRequestId = searchParams?.authRequestId;
const organization = searchParams?.organization;
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg();
if (org) {
defaultOrganization = org.id;
}
}
let sessions = await loadSessions();
const branding = await getBrandingSettings(organization);
const branding = await getBrandingSettings(
organization ?? defaultOrganization,
);
const params = new URLSearchParams();
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Accounts</h1>
<p className="ztdl-p mb-6 block">Use your ZITADEL Account</p>
<h1>{t("title")}</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p>
<div className="flex flex-col w-full space-y-2">
<SessionsList sessions={sessions} authRequestId={authRequestId} />
<Link
href={
authRequestId
? `/loginname?` +
new URLSearchParams({
authRequestId,
})
: "/loginname"
}
>
<Link href={`/loginname?` + params}>
<div className="flex flex-row items-center py-3 px-4 hover:bg-black/10 dark:hover:bg-white/10 rounded-md transition-all">
<div className="w-8 h-8 mr-4 flex flex-row justify-center items-center rounded-full bg-black/5 dark:bg-white/5">
<UserPlusIcon className="h-5 w-5" />
</div>
<span className="text-sm">Add another account</span>
<span className="text-sm">{t("addAnother")}</span>
</div>
</Link>
</div>

View File

@@ -0,0 +1,156 @@
import { Alert } from "@/components/alert";
import { BackButton } from "@/components/back-button";
import { ChooseAuthenticatorToSetup } from "@/components/choose-authenticator-to-setup";
import { DynamicTheme } from "@/components/dynamic-theme";
import { SignInWithIdp } from "@/components/sign-in-with-idp";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { loadMostRecentSession } from "@/lib/session";
import {
getActiveIdentityProviders,
getBrandingSettings,
getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "authenticator" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, authRequestId, organization, sessionId } = searchParams;
const sessionWithData = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser(session?: Session) {
const userId = session?.factors?.user?.id;
if (!userId) {
throw Error("Could not get user id from session");
}
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
const humanUser =
user.user?.type.case === "human" ? user.user?.type.value : undefined;
return {
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
expirationDate: session?.expirationDate,
};
});
});
}
async function loadSessionByLoginname(
loginName?: string,
organization?: string,
) {
return loadMostRecentSession({
loginName,
organization,
}).then((session) => {
return getAuthMethodsAndUser(session);
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
sessionId: recent.id,
sessionToken: recent.token,
}).then((sessionResponse) => {
return getAuthMethodsAndUser(sessionResponse.session);
});
}
if (!sessionWithData) {
return <Alert>{tError("unknownContext")}</Alert>;
}
const branding = await getBrandingSettings(
sessionWithData.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(
sessionWithData.factors?.user?.organizationId,
);
const identityProviders = await getActiveIdentityProviders(
sessionWithData.factors?.user?.organizationId,
true,
).then((resp) => {
return resp.identityProviders;
});
const params = new URLSearchParams({
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (sessionWithData.factors?.user?.loginName) {
params.set("loginName", sessionWithData.factors?.user?.loginName);
}
if (sessionWithData.factors?.user?.organizationId) {
params.set("organization", sessionWithData.factors?.user?.organizationId);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p>
<UserAvatar
loginName={sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
{loginSettings && (
<ChooseAuthenticatorToSetup
authMethods={sessionWithData.authMethods}
loginSettings={loginSettings}
params={params}
></ChooseAuthenticatorToSetup>
)}
<div className="py-3 flex flex-col">
<p className="ztdl-p text-center">{t("linkWithIDP")}</p>
</div>
{loginSettings?.allowExternalIdp && identityProviders && (
<SignInWithIdp
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={sessionWithData.factors?.user?.organizationId}
linkOnly={true} // tell the callback function to just link the IDP and not login, to get an error when user is already available
></SignInWithIdp>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
</div>
</div>
</DynamicTheme>
);
}

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