mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-23 04:07:37 +00:00
Merge branch 'main' into translation
This commit is contained in:
64
.github/workflows/test.yml
vendored
64
.github/workflows/test.yml
vendored
@@ -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,15 +44,13 @@ jobs:
|
||||
permissions:
|
||||
contents: "read"
|
||||
|
||||
needs:
|
||||
- matrix
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- format --check
|
||||
- lint
|
||||
- test:unit
|
||||
- test:integration
|
||||
- test:acceptance
|
||||
command: ${{ fromJson( needs.matrix.outputs.matrix ) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
@@ -55,7 +84,7 @@ jobs:
|
||||
# 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: ${{ matrix.command == 'test:acceptance' }}
|
||||
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
|
||||
|
||||
- uses: actions/cache@v4.0.2
|
||||
name: Setup Playwright binary cache
|
||||
@@ -65,24 +94,33 @@ jobs:
|
||||
key: ${{ runner.os }}-playwright-binary-${{ env.PLAYWRIGHT_VERSION }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-binary-
|
||||
if: ${{ matrix.command == 'test:acceptance' }}
|
||||
if: ${{ startsWith(matrix.command, 'test:acceptance') }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
if: ${{ matrix.command == 'test:acceptance' && steps.playwright-cache.outputs.cache-hit != 'true' }}
|
||||
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-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: ${{ matrix.command == 'test:acceptance' }}
|
||||
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 }}
|
||||
|
18
README.md
18
README.md
@@ -230,6 +230,24 @@ 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
|
||||
|
||||
To deploy your own version on Vercel, navigate to your instance and create a service user.
|
||||
|
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
zitadel:
|
||||
user: "${ZITADEL_DEV_UID}"
|
||||
image: ghcr.io/zitadel/zitadel:v2.65.0
|
||||
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"
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
- 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-SHELL", "pg_isready"]
|
||||
test: [ "CMD-SHELL", "pg_isready" ]
|
||||
interval: "10s"
|
||||
timeout: "30s"
|
||||
retries: 5
|
||||
@@ -45,6 +45,9 @@ services:
|
||||
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:
|
||||
- "./pat:/pat"
|
||||
- "../apps/login:/apps/login"
|
||||
@@ -52,3 +55,15 @@ services:
|
||||
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"
|
||||
|
@@ -8,6 +8,9 @@ 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"}"
|
||||
|
||||
if [ -z "${PAT}" ]; then
|
||||
echo "Reading PAT from file ${PAT_FILE}"
|
||||
@@ -24,6 +27,10 @@ if [ -z "${ZITADEL_SERVICE_USER_ID}" ]; then
|
||||
ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub')
|
||||
fi
|
||||
|
||||
#################################################################
|
||||
# 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}
|
||||
@@ -32,6 +39,7 @@ echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done."
|
||||
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}
|
||||
@@ -39,6 +47,54 @@ cat ${WRITE_ENVIRONMENT_FILE}
|
||||
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
|
||||
cat ${WRITE_TEST_ENVIRONMENT_FILE}
|
||||
|
||||
#################################################################
|
||||
# SMS provider with HTTP
|
||||
#################################################################
|
||||
|
||||
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}"
|
||||
|
||||
SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id')
|
||||
echo "Received SMS HTTP ID: ${SMSHTTP_ID}"
|
||||
|
||||
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}"
|
||||
|
||||
#################################################################
|
||||
# Email provider with HTTP
|
||||
#################################################################
|
||||
|
||||
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}"
|
||||
|
||||
EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id')
|
||||
echo "Received Email HTTP ID: ${EMAILHTTP_ID}"
|
||||
|
||||
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 ]
|
||||
@@ -53,3 +109,4 @@ do
|
||||
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
|
||||
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
|
||||
done
|
||||
|
||||
|
3
acceptance/sink/go.mod
Normal file
3
acceptance/sink/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/zitadel/typescript/acceptance/sink
|
||||
|
||||
go 1.22.6
|
104
acceptance/sink/main.go
Normal file
104
acceptance/sink/main.go
Normal 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())
|
||||
}
|
||||
}
|
12
acceptance/tests/code-screen.ts
Normal file
12
acceptance/tests/code-screen.ts
Normal 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
19
acceptance/tests/code.ts
Normal 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();
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
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");
|
||||
@@ -23,6 +25,17 @@ export async function loginScreenExpect(page: Page, fullName: string) {
|
||||
await expect(page.getByRole("heading")).toContainText(fullName);
|
||||
}
|
||||
|
||||
export async function loginWithOTP(page: Page, username: string, password: string) {
|
||||
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));
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
const usernameUserInput = "username-text-input";
|
||||
const usernameTextInput = "username-text-input";
|
||||
|
||||
export async function loginnameScreen(page: Page, username: string) {
|
||||
await page.getByTestId(usernameUserInput).pressSequentially(username);
|
||||
await page.getByTestId(usernameTextInput).pressSequentially(username);
|
||||
}
|
||||
|
||||
export async function loginnameScreenExpect(page: Page, username: string) {
|
||||
await expect(page.getByTestId(usernameUserInput)).toHaveValue(username);
|
||||
await expect(page.getByTestId(usernameTextInput)).toHaveValue(username);
|
||||
await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user");
|
||||
}
|
||||
|
@@ -1,31 +0,0 @@
|
||||
import * as http from "node:http";
|
||||
|
||||
let messages = new Map<string, any>();
|
||||
|
||||
export function startSink() {
|
||||
const hostname = "127.0.0.1";
|
||||
const port = 3030;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log("Sink received message: ");
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
console.log(body);
|
||||
const data = JSON.parse(body);
|
||||
messages.set(data.contextInfo.recipientEmailAddress, data.args.code);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.write("OK");
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
console.log(`Sink running at http://${hostname}:${port}/`);
|
||||
});
|
||||
return server;
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
@@ -55,3 +57,26 @@ async function checkContent(page: Page, testid: string, match: boolean) {
|
||||
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);
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { changePasswordScreen, passwordScreen } from "./password-screen";
|
||||
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 }));
|
||||
@@ -17,3 +18,13 @@ 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();
|
||||
}
|
||||
|
@@ -1,27 +1,39 @@
|
||||
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";
|
||||
|
||||
test("register with password", async ({ page }) => {
|
||||
const username = "register-password@example.com";
|
||||
const password = "Password1!";
|
||||
const firstname = "firstname";
|
||||
const lastname = "lastname";
|
||||
// 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 removeUserByUsername(username);
|
||||
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 = "register-passkey@example.com";
|
||||
const firstname = "firstname";
|
||||
const lastname = "lastname";
|
||||
const username = faker.internet.email();
|
||||
const firstname = faker.person.firstName();
|
||||
const lastname = faker.person.lastName();
|
||||
|
||||
await removeUserByUsername(username);
|
||||
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 }) => {
|
||||
|
@@ -21,5 +21,9 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam
|
||||
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
55
acceptance/tests/sink.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import axios from "axios";
|
||||
import { registerWithPasskey } from "./register";
|
||||
import { getUserByUsername, removeUser } from "./zitadel";
|
||||
import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel";
|
||||
|
||||
export interface userProps {
|
||||
email: string;
|
||||
@@ -9,6 +8,7 @@ export interface userProps {
|
||||
lastName: string;
|
||||
organization: string;
|
||||
password: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
class User {
|
||||
@@ -20,54 +20,13 @@ class User {
|
||||
}
|
||||
|
||||
async ensure(page: Page) {
|
||||
await this.remove();
|
||||
const response = await addUser(this.props);
|
||||
|
||||
const body = {
|
||||
username: this.props.email,
|
||||
organization: {
|
||||
orgId: this.props.organization,
|
||||
},
|
||||
profile: {
|
||||
givenName: this.props.firstName,
|
||||
familyName: this.props.lastName,
|
||||
},
|
||||
email: {
|
||||
email: this.props.email,
|
||||
isVerified: true,
|
||||
},
|
||||
password: {
|
||||
password: this.props.password!,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users/human`, body, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400 && response.status !== 409) {
|
||||
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;
|
||||
}
|
||||
|
||||
// wait for projection of user
|
||||
await page.waitForTimeout(3000);
|
||||
this.setUserId(response.userId);
|
||||
}
|
||||
|
||||
async remove() {
|
||||
const resp: any = await getUserByUsername(this.getUsername());
|
||||
if (!resp || !resp.result || !resp.result[0]) {
|
||||
return;
|
||||
}
|
||||
await removeUser(resp.result[0].userId);
|
||||
async cleanup() {
|
||||
await removeUser(this.getUserId());
|
||||
}
|
||||
|
||||
public setUserId(userId: string) {
|
||||
@@ -94,12 +53,22 @@ class User {
|
||||
return this.props.lastName;
|
||||
}
|
||||
|
||||
public getPhone() {
|
||||
return this.props.phone;
|
||||
}
|
||||
|
||||
public getFullName() {
|
||||
return `${this.props.firstName} ${this.props.lastName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordUser extends User {}
|
||||
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",
|
||||
@@ -112,12 +81,12 @@ export interface otpUserProps {
|
||||
lastName: string;
|
||||
organization: string;
|
||||
password: string;
|
||||
phone: string;
|
||||
type: OtpType;
|
||||
}
|
||||
|
||||
export class PasswordUserWithOTP extends User {
|
||||
private type: OtpType;
|
||||
private code: string;
|
||||
|
||||
constructor(props: otpUserProps) {
|
||||
super({
|
||||
@@ -126,6 +95,7 @@ export class PasswordUserWithOTP extends User {
|
||||
lastName: props.lastName,
|
||||
organization: props.organization,
|
||||
password: props.password,
|
||||
phone: props.phone,
|
||||
});
|
||||
this.type = props.type;
|
||||
}
|
||||
@@ -133,47 +103,27 @@ export class PasswordUserWithOTP extends User {
|
||||
async ensure(page: Page) {
|
||||
await super.ensure(page);
|
||||
|
||||
let url = "otp_";
|
||||
switch (this.type) {
|
||||
case OtpType.sms:
|
||||
url = url + "sms";
|
||||
break;
|
||||
case OtpType.email:
|
||||
url = url + "email";
|
||||
break;
|
||||
}
|
||||
await activateOTP(this.getUserId(), this.type);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${process.env.ZITADEL_API_URL}/v2/users/${this.getUserId()}/${url}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
// wait for projection of user
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status !== 409) {
|
||||
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
|
||||
console.error(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
export class PasswordUserWithTOTP extends User {
|
||||
private secret: string;
|
||||
|
||||
// TODO: get code from SMS or Email provider
|
||||
this.code = "";
|
||||
} catch (error) {
|
||||
console.error("Error making request:", error);
|
||||
throw error;
|
||||
}
|
||||
async ensure(page: Page) {
|
||||
await super.ensure(page);
|
||||
|
||||
this.secret = await addTOTP(this.getUserId());
|
||||
|
||||
// wait for projection of user
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
public getCode() {
|
||||
return this.code;
|
||||
public getSecret(): string {
|
||||
return this.secret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +132,7 @@ export interface passkeyUserProps {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
organization: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export class PasskeyUser extends User {
|
||||
@@ -194,11 +145,11 @@ export class PasskeyUser extends User {
|
||||
lastName: props.lastName,
|
||||
organization: props.organization,
|
||||
password: "",
|
||||
phone: props.phone,
|
||||
});
|
||||
}
|
||||
|
||||
public async ensure(page: Page) {
|
||||
await this.remove();
|
||||
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
|
||||
this.authenticatorId = authId;
|
||||
|
||||
@@ -206,8 +157,12 @@ export class PasskeyUser extends User {
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
public async remove() {
|
||||
await super.remove();
|
||||
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 {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { test as base } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
@@ -10,13 +11,15 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
||||
const test = base.extend<{ user: PasskeyUser }>({
|
||||
user: async ({ page }, use) => {
|
||||
const user = new PasskeyUser({
|
||||
email: "passkey@example.com",
|
||||
firstName: "first",
|
||||
lastName: "last",
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,16 +28,7 @@ test("username and passkey login", async ({ user, page }) => {
|
||||
await loginScreenExpect(page, user.getFullName());
|
||||
});
|
||||
|
||||
test("username and passkey login, if passkey enabled", async ({ user, page }) => {
|
||||
// Given passkey is enabled on the organization of the user
|
||||
// Given the user has only passkey enabled as authentication
|
||||
// enter username
|
||||
// passkey popup is directly shown
|
||||
// user verifies passkey
|
||||
// user is redirected to app
|
||||
});
|
||||
|
||||
test("username and passkey login, multiple auth methods", async ({ user, page }) => {
|
||||
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
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { test as base } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
@@ -12,14 +13,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
||||
const test = base.extend<{ user: PasswordUser }>({
|
||||
user: async ({ page }, use) => {
|
||||
const user = new PasswordUser({
|
||||
email: "password-changed@example.com",
|
||||
firstName: "first",
|
||||
lastName: "last",
|
||||
password: "Password1!",
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +43,7 @@ test("username and password changed login", async ({ user, page }) => {
|
||||
*/
|
||||
});
|
||||
|
||||
test("password not with desired complexity", async ({ user, page }) => {
|
||||
test("password change not with desired complexity", async ({ user, page }) => {
|
||||
const changedPw1 = "change";
|
||||
const changedPw2 = "chang";
|
||||
await loginWithPassword(page, user.getUsername(), user.getPassword());
|
||||
|
@@ -1,6 +1,34 @@
|
||||
import { test } from "@playwright/test";
|
||||
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";
|
||||
|
||||
test("username, password and email otp login, enter code manually", async ({ page }) => {
|
||||
// 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
|
||||
@@ -8,6 +36,8 @@ test("username, password and email otp login, enter code manually", async ({ pag
|
||||
// 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 }) => {
|
||||
@@ -20,7 +50,7 @@ test("username, password and email otp login, click link in email", async ({ pag
|
||||
// User is redirected to the app (default redirect url)
|
||||
});
|
||||
|
||||
test("username, password and email otp login, resend code", async ({ page }) => {
|
||||
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
|
||||
@@ -30,16 +60,24 @@ test("username, password and email otp login, resend code", async ({ page }) =>
|
||||
// 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 ({ page }) => {
|
||||
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 wrond 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 }) => {
|
||||
@@ -49,7 +87,7 @@ test("username, password and email otp login, multiple mfa options", async ({ pa
|
||||
// User enters password
|
||||
// User receives an email with a verification code
|
||||
// User clicks button to use sms otp as second factor
|
||||
// User receives an sms with a verification code
|
||||
// User receives a sms with a verification code
|
||||
// User enters code in ui
|
||||
// User is redirected to the app (default redirect url)
|
||||
});
|
||||
|
@@ -1,6 +1,34 @@
|
||||
import { test } from "@playwright/test";
|
||||
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";
|
||||
|
||||
test("username, password and sms otp login", async ({ page }) => {
|
||||
// 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
|
||||
@@ -8,9 +36,11 @@ test("username, password and sms otp login", async ({ page }) => {
|
||||
// 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 ({ page }) => {
|
||||
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
|
||||
@@ -19,9 +49,11 @@ test("username, password and sms otp login, resend code", async ({ page }) => {
|
||||
// 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 ({ page }) => {
|
||||
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
|
||||
@@ -29,4 +61,8 @@ test("username, password and sms otp login, wrong code", async ({ page }) => {
|
||||
// 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);
|
||||
});
|
||||
|
51
acceptance/tests/username-password-set.spec.ts
Normal file
51
acceptance/tests/username-password-set.spec.ts
Normal 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);
|
||||
});
|
@@ -1,6 +1,33 @@
|
||||
import { test } from "@playwright/test";
|
||||
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";
|
||||
|
||||
test("username, password and totp login", async ({ page }) => {
|
||||
// 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
|
||||
@@ -8,9 +35,11 @@ test("username, password and totp login", async ({ page }) => {
|
||||
// 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 ({ page }) => {
|
||||
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
|
||||
@@ -18,6 +47,10 @@ test("username, password and totp otp login, wrong code", async ({ page }) => {
|
||||
// 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 }) => {
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { test as base } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
@@ -14,14 +15,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
||||
const test = base.extend<{ user: PasswordUser }>({
|
||||
user: async ({ page }, use) => {
|
||||
const user = new PasswordUser({
|
||||
email: "password@example.com",
|
||||
firstName: "first",
|
||||
lastName: "last",
|
||||
password: "Password1!",
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -1,4 +1,34 @@
|
||||
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);
|
||||
@@ -9,8 +39,12 @@ export async function removeUserByUsername(username: string) {
|
||||
}
|
||||
|
||||
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(`${process.env.ZITADEL_API_URL}/v2/users/${id}`, {
|
||||
const response = await axios.delete(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||
},
|
||||
@@ -27,7 +61,7 @@ export async function removeUser(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
export async function getUserByUsername(username: string): Promise<any> {
|
||||
const listUsersBody = {
|
||||
queries: [
|
||||
{
|
||||
@@ -38,8 +72,12 @@ export async function getUserByUsername(username: string) {
|
||||
],
|
||||
};
|
||||
|
||||
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(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody, {
|
||||
const response = await axios.post(url, data, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||
@@ -58,3 +96,64 @@ export async function getUserByUsername(username: string) {
|
||||
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;
|
||||
}
|
||||
|
@@ -49,6 +49,7 @@ describe("login", () => {
|
||||
data: {
|
||||
settings: {
|
||||
passkeysType: 1,
|
||||
allowUsernamePassword: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@@ -389,7 +389,10 @@ In future, self service options to jump to are shown below, like:
|
||||
|
||||
## Currently NOT Supported
|
||||
|
||||
- loginSettings.disableLoginWithEmail
|
||||
- loginSettings.disableLoginWithPhone
|
||||
- loginSettings.allowExternalIdp - this will be deprecated with the new login as it can be determined by the available IDPs
|
||||
- loginSettings.forceMfaLocalOnly
|
||||
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
|
||||
|
@@ -74,6 +74,10 @@ export default async function Page(props: {
|
||||
});
|
||||
}
|
||||
|
||||
if (!sessionWithData) {
|
||||
return <Alert>{tError("unknownContext")}</Alert>;
|
||||
}
|
||||
|
||||
const branding = await getBrandingSettings(
|
||||
sessionWithData.factors?.user?.organizationId,
|
||||
);
|
||||
@@ -82,22 +86,34 @@ export default async function Page(props: {
|
||||
sessionWithData.factors?.user?.organizationId,
|
||||
);
|
||||
|
||||
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
|
||||
|
||||
// const identityProviders = await getActiveIdentityProviders(
|
||||
// sessionWithData.factors?.user?.organizationId,
|
||||
// ).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 (loginName) {
|
||||
params.set("loginName", loginName);
|
||||
if (sessionWithData.factors?.user?.loginName) {
|
||||
params.set("loginName", sessionWithData.factors?.user?.loginName);
|
||||
}
|
||||
|
||||
if (organization) {
|
||||
params.set("organization", organization);
|
||||
if (sessionWithData.factors?.user?.organizationId) {
|
||||
params.set("organization", sessionWithData.factors?.user?.organizationId);
|
||||
}
|
||||
|
||||
if (authRequestId) {
|
||||
params.set("authRequestId", authRequestId);
|
||||
}
|
||||
|
||||
const host = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "http://localhost:3000";
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
@@ -105,18 +121,14 @@ export default async function Page(props: {
|
||||
|
||||
<p className="ztdl-p">{t("description")}</p>
|
||||
|
||||
{sessionWithData && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionWithData.factors?.user?.loginName}
|
||||
displayName={sessionWithData.factors?.user?.displayName}
|
||||
showDropdown
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
<UserAvatar
|
||||
loginName={sessionWithData.factors?.user?.loginName}
|
||||
displayName={sessionWithData.factors?.user?.displayName}
|
||||
showDropdown
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
|
||||
{!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>}
|
||||
|
||||
{loginSettings && sessionWithData && (
|
||||
{loginSettings && (
|
||||
<ChooseAuthenticatorToSetup
|
||||
authMethods={sessionWithData.authMethods}
|
||||
loginSettings={loginSettings}
|
||||
@@ -124,6 +136,22 @@ export default async function Page(props: {
|
||||
></ChooseAuthenticatorToSetup>
|
||||
)}
|
||||
|
||||
{/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */}
|
||||
|
||||
{/* <p className="ztdl-p text-center">
|
||||
or sign in with an Identity Provider
|
||||
</p>
|
||||
|
||||
{loginSettings?.allowExternalIdp && identityProviders && (
|
||||
<SignInWithIdp
|
||||
host={host}
|
||||
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>
|
||||
|
@@ -37,7 +37,7 @@ export default async function Page(props: {
|
||||
const searchParams = await props.searchParams;
|
||||
const locale = getLocale();
|
||||
const t = await getTranslations({ locale, namespace: "idp" });
|
||||
const { id, token, authRequestId, organization } = searchParams;
|
||||
const { id, token, authRequestId, organization, link } = searchParams;
|
||||
const { provider } = params;
|
||||
|
||||
const branding = await getBrandingSettings(organization);
|
||||
@@ -50,7 +50,8 @@ export default async function Page(props: {
|
||||
|
||||
const { idpInformation, userId } = intent;
|
||||
|
||||
if (userId) {
|
||||
// sign in user. If user should be linked continue
|
||||
if (userId && !link) {
|
||||
// TODO: update user if idp.options.isAutoUpdate is true
|
||||
|
||||
return (
|
||||
|
@@ -24,10 +24,6 @@ export default async function Page(props: {
|
||||
|
||||
const identityProviders = await getIdentityProviders(organization);
|
||||
|
||||
const host = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "http://localhost:3000";
|
||||
|
||||
const branding = await getBrandingSettings(organization);
|
||||
|
||||
return (
|
||||
@@ -38,7 +34,6 @@ export default async function Page(props: {
|
||||
|
||||
{identityProviders && (
|
||||
<SignInWithIdp
|
||||
host={host}
|
||||
identityProviders={identityProviders}
|
||||
authRequestId={authRequestId}
|
||||
organization={organization}
|
||||
|
@@ -2,23 +2,14 @@ import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { SignInWithIdp } from "@/components/sign-in-with-idp";
|
||||
import { UsernameForm } from "@/components/username-form";
|
||||
import {
|
||||
getActiveIdentityProviders,
|
||||
getBrandingSettings,
|
||||
getDefaultOrg,
|
||||
getLoginSettings,
|
||||
settingsService,
|
||||
} from "@/lib/zitadel";
|
||||
import { makeReqCtx } from "@zitadel/client/v2";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
|
||||
function getIdentityProviders(orgId?: string) {
|
||||
return settingsService
|
||||
.getActiveIdentityProviders({ ctx: makeReqCtx(orgId) }, {})
|
||||
.then((resp) => {
|
||||
return resp.identityProviders;
|
||||
});
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
@@ -39,17 +30,15 @@ export default async function Page(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const host = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "http://localhost:3000";
|
||||
|
||||
const loginSettings = await getLoginSettings(
|
||||
organization ?? defaultOrganization,
|
||||
);
|
||||
|
||||
const identityProviders = await getIdentityProviders(
|
||||
const identityProviders = await getActiveIdentityProviders(
|
||||
organization ?? defaultOrganization,
|
||||
);
|
||||
).then((resp) => {
|
||||
return resp.identityProviders;
|
||||
});
|
||||
|
||||
const branding = await getBrandingSettings(
|
||||
organization ?? defaultOrganization,
|
||||
@@ -68,9 +57,8 @@ export default async function Page(props: {
|
||||
submit={submit}
|
||||
allowRegister={!!loginSettings?.allowRegister}
|
||||
>
|
||||
{identityProviders && process.env.ZITADEL_API_URL && (
|
||||
{identityProviders && (
|
||||
<SignInWithIdp
|
||||
host={host}
|
||||
identityProviders={identityProviders}
|
||||
authRequestId={authRequestId}
|
||||
organization={organization ?? defaultOrganization} // use the organization from the searchParams here otherwise fallback to the default organization
|
||||
|
@@ -2,9 +2,15 @@ import { Alert } from "@/components/alert";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { LoginOTP } from "@/components/login-otp";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getSessionCookieById } from "@/lib/cookies";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
getSession,
|
||||
} from "@/lib/zitadel";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
@@ -16,19 +22,44 @@ export default async function Page(props: {
|
||||
const t = await getTranslations({ locale, namespace: "otp" });
|
||||
const tError = await getTranslations({ locale, namespace: "error" });
|
||||
|
||||
const { loginName, authRequestId, sessionId, organization, code, submit } =
|
||||
searchParams;
|
||||
const {
|
||||
loginName, // send from password page
|
||||
userId, // send from email link
|
||||
authRequestId,
|
||||
sessionId,
|
||||
organization,
|
||||
code,
|
||||
submit,
|
||||
} = searchParams;
|
||||
|
||||
const { method } = params;
|
||||
|
||||
const session = await loadMostRecentSession({
|
||||
loginName,
|
||||
organization,
|
||||
});
|
||||
const session = sessionId
|
||||
? await loadSessionById(sessionId, organization)
|
||||
: await loadMostRecentSession({ loginName, organization });
|
||||
|
||||
const branding = await getBrandingSettings(organization);
|
||||
async function loadSessionById(sessionId: string, organization?: string) {
|
||||
const recent = await getSessionCookieById({ sessionId, organization });
|
||||
return getSession({
|
||||
sessionId: recent.id,
|
||||
sessionToken: recent.token,
|
||||
}).then((response) => {
|
||||
if (response?.session) {
|
||||
return response.session;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const loginSettings = await getLoginSettings(organization);
|
||||
// email links do not come with organization, thus we need to use the session's organization
|
||||
const branding = await getBrandingSettings(
|
||||
organization ?? session?.factors?.user?.organizationId,
|
||||
);
|
||||
|
||||
const loginSettings = await getLoginSettings(
|
||||
organization ?? session?.factors?.user?.organizationId,
|
||||
);
|
||||
|
||||
const host = (await headers()).get("host");
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
@@ -59,14 +90,18 @@ export default async function Page(props: {
|
||||
></UserAvatar>
|
||||
)}
|
||||
|
||||
{method && (
|
||||
{method && session && (
|
||||
<LoginOTP
|
||||
loginName={loginName}
|
||||
loginName={loginName ?? session.factors?.user?.loginName}
|
||||
sessionId={sessionId}
|
||||
authRequestId={authRequestId}
|
||||
organization={organization}
|
||||
organization={
|
||||
organization ?? session?.factors?.user?.organizationId
|
||||
}
|
||||
method={method}
|
||||
loginSettings={loginSettings}
|
||||
host={host}
|
||||
code={code}
|
||||
></LoginOTP>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -1,14 +1,17 @@
|
||||
import { getAllSessions } from "@/lib/cookies";
|
||||
import { idpTypeToSlug } from "@/lib/idp";
|
||||
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
|
||||
import {
|
||||
createCallback,
|
||||
getActiveIdentityProviders,
|
||||
getAuthRequest,
|
||||
getLoginSettings,
|
||||
getOrgsByDomain,
|
||||
listAuthenticationMethodTypes,
|
||||
listSessions,
|
||||
startIdentityProviderFlow,
|
||||
} from "@/lib/zitadel";
|
||||
import { create } from "@zitadel/client";
|
||||
import { create, timestampDate } from "@zitadel/client";
|
||||
import {
|
||||
AuthRequest,
|
||||
Prompt,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
SessionSchema,
|
||||
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -36,23 +40,143 @@ const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
|
||||
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options
|
||||
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
|
||||
|
||||
function findSession(
|
||||
sessions: Session[],
|
||||
authRequest: AuthRequest,
|
||||
): Session | undefined {
|
||||
if (authRequest.hintUserId) {
|
||||
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
|
||||
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
|
||||
/**
|
||||
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
|
||||
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
|
||||
**/
|
||||
async function isSessionValid(session: Session): Promise<boolean> {
|
||||
// session can't be checked without user
|
||||
if (!session.factors?.user) {
|
||||
console.warn("Session has no user");
|
||||
return false;
|
||||
}
|
||||
if (authRequest.loginHint) {
|
||||
console.log(`find session for loginHint: ${authRequest.loginHint}`);
|
||||
return sessions.find(
|
||||
(s) => s.factors?.user?.loginName === authRequest.loginHint,
|
||||
|
||||
let mfaValid = true;
|
||||
|
||||
const authMethodTypes = await listAuthenticationMethodTypes(
|
||||
session.factors.user.id,
|
||||
);
|
||||
|
||||
const authMethods = authMethodTypes.authMethodTypes;
|
||||
if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) {
|
||||
mfaValid = !!session.factors.totp?.verifiedAt;
|
||||
if (!mfaValid) {
|
||||
console.warn(
|
||||
"Session has no valid totpEmail factor",
|
||||
session.factors.totp?.verifiedAt,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
authMethods &&
|
||||
authMethods.includes(AuthenticationMethodType.OTP_EMAIL)
|
||||
) {
|
||||
mfaValid = !!session.factors.otpEmail?.verifiedAt;
|
||||
if (!mfaValid) {
|
||||
console.warn(
|
||||
"Session has no valid otpEmail factor",
|
||||
session.factors.otpEmail?.verifiedAt,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
authMethods &&
|
||||
authMethods.includes(AuthenticationMethodType.OTP_SMS)
|
||||
) {
|
||||
mfaValid = !!session.factors.otpSms?.verifiedAt;
|
||||
if (!mfaValid) {
|
||||
console.warn(
|
||||
"Session has no valid otpSms factor",
|
||||
session.factors.otpSms?.verifiedAt,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
authMethods &&
|
||||
authMethods.includes(AuthenticationMethodType.U2F)
|
||||
) {
|
||||
mfaValid = !!session.factors.webAuthN?.verifiedAt;
|
||||
if (!mfaValid) {
|
||||
console.warn(
|
||||
"Session has no valid u2f factor",
|
||||
session.factors.webAuthN?.verifiedAt,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// only check settings if no auth methods are available, as this would require a setup
|
||||
const loginSettings = await getLoginSettings(
|
||||
session.factors?.user?.organizationId,
|
||||
);
|
||||
if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) {
|
||||
const otpEmail = session.factors.otpEmail?.verifiedAt;
|
||||
const otpSms = session.factors.otpSms?.verifiedAt;
|
||||
const totp = session.factors.totp?.verifiedAt;
|
||||
const webAuthN = session.factors.webAuthN?.verifiedAt;
|
||||
|
||||
// must have one single check
|
||||
mfaValid = !!(otpEmail || otpSms || totp || webAuthN);
|
||||
if (!mfaValid) {
|
||||
console.warn(
|
||||
"Session has no valid multifactor",
|
||||
JSON.stringify(session.factors),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mfaValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
const validPassword = session?.factors?.password?.verifiedAt;
|
||||
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
|
||||
const validIDP = session?.factors?.intent?.verifiedAt;
|
||||
|
||||
const stillValid = session.expirationDate
|
||||
? timestampDate(session.expirationDate).getTime() > new Date().getTime()
|
||||
: true;
|
||||
|
||||
if (!stillValid) {
|
||||
console.warn(
|
||||
"Session is expired",
|
||||
session.expirationDate
|
||||
? timestampDate(session.expirationDate).toDateString()
|
||||
: "no expiration date",
|
||||
);
|
||||
}
|
||||
if (sessions.length) {
|
||||
return sessions[0];
|
||||
|
||||
const validChecks = !!(validPassword || validPasskey || validIDP);
|
||||
|
||||
return stillValid && validChecks && mfaValid;
|
||||
}
|
||||
|
||||
async function findValidSession(
|
||||
sessions: Session[],
|
||||
authRequest: AuthRequest,
|
||||
): Promise<Session | undefined> {
|
||||
const sessionsWithHint = sessions.filter((s) => {
|
||||
if (authRequest.hintUserId) {
|
||||
return s.factors?.user?.id === authRequest.hintUserId;
|
||||
}
|
||||
if (authRequest.loginHint) {
|
||||
return s.factors?.user?.loginName === authRequest.loginHint;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sessionsWithHint.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// sort by change date descending
|
||||
sessionsWithHint.sort((a, b) => {
|
||||
const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0;
|
||||
const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// return the first valid session according to settings
|
||||
for (const session of sessionsWithHint) {
|
||||
if (await isSessionValid(session)) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -74,22 +198,34 @@ export async function GET(request: NextRequest) {
|
||||
sessions = await loadSessions(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: before automatically redirecting to the callbackUrl, check if the session is still valid
|
||||
* possible scenaio:
|
||||
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
|
||||
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
|
||||
**/
|
||||
|
||||
if (authRequestId && sessionId) {
|
||||
console.log(
|
||||
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
|
||||
);
|
||||
|
||||
let selectedSession = sessions.find((s) => s.id === sessionId);
|
||||
const selectedSession = sessions.find((s) => s.id === sessionId);
|
||||
|
||||
if (selectedSession && selectedSession.id) {
|
||||
console.log(`Found session ${selectedSession.id}`);
|
||||
|
||||
const isValid = await isSessionValid(selectedSession);
|
||||
|
||||
if (!isValid && selectedSession.factors?.user) {
|
||||
// if the session is not valid anymore, we need to redirect the user to re-authenticate
|
||||
const command: SendLoginnameCommand = {
|
||||
loginName: selectedSession.factors.user?.loginName,
|
||||
organization: selectedSession.factors?.user?.organizationId,
|
||||
authRequestId: authRequestId,
|
||||
};
|
||||
|
||||
const res = await sendLoginname(command);
|
||||
|
||||
if (res?.redirect) {
|
||||
const absoluteUrl = new URL(res.redirect, request.url);
|
||||
return NextResponse.redirect(absoluteUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = sessionCookies.find(
|
||||
(cookie) => cookie.id === selectedSession?.id,
|
||||
);
|
||||
@@ -119,8 +255,41 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error }, { status: 500 });
|
||||
} catch (error: unknown) {
|
||||
// handle already handled gracefully as these could come up if old emails with authRequestId are used (reset password, register emails etc.)
|
||||
console.error(error);
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error?.code === 9
|
||||
) {
|
||||
const loginSettings = await getLoginSettings(
|
||||
selectedSession.factors?.user?.organizationId,
|
||||
);
|
||||
|
||||
if (loginSettings?.defaultRedirectUri) {
|
||||
return NextResponse.redirect(loginSettings.defaultRedirectUri);
|
||||
}
|
||||
|
||||
const signedinUrl = new URL("/signedin", request.url);
|
||||
|
||||
if (selectedSession.factors?.user?.loginName) {
|
||||
signedinUrl.searchParams.set(
|
||||
"loginName",
|
||||
selectedSession.factors?.user?.loginName,
|
||||
);
|
||||
}
|
||||
if (selectedSession.factors?.user?.organizationId) {
|
||||
signedinUrl.searchParams.set(
|
||||
"organization",
|
||||
selectedSession.factors?.user?.organizationId,
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(signedinUrl);
|
||||
} else {
|
||||
return NextResponse.json({ error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +343,7 @@ export async function GET(request: NextRequest) {
|
||||
const idp = identityProviders.find((idp) => idp.id === idpId);
|
||||
|
||||
if (idp) {
|
||||
const host = request.nextUrl.origin;
|
||||
const origin = request.nextUrl.origin;
|
||||
|
||||
const identityProviderType = identityProviders[0].type;
|
||||
let provider = idpTypeToSlug(identityProviderType);
|
||||
@@ -193,10 +362,10 @@ export async function GET(request: NextRequest) {
|
||||
idpId,
|
||||
urls: {
|
||||
successUrl:
|
||||
`${host}/idp/${provider}/success?` +
|
||||
`${origin}/idp/${provider}/success?` +
|
||||
new URLSearchParams(params),
|
||||
failureUrl:
|
||||
`${host}/idp/${provider}/failure?` +
|
||||
`${origin}/idp/${provider}/failure?` +
|
||||
new URLSearchParams(params),
|
||||
},
|
||||
}).then((resp) => {
|
||||
@@ -225,8 +394,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
|
||||
const registerUrl = new URL("/register", request.url);
|
||||
if (authRequest?.id) {
|
||||
registerUrl.searchParams.set("authRequestId", authRequest?.id);
|
||||
if (authRequest.id) {
|
||||
registerUrl.searchParams.set("authRequestId", authRequest.id);
|
||||
}
|
||||
if (organization) {
|
||||
registerUrl.searchParams.set("organization", organization);
|
||||
@@ -241,10 +410,36 @@ export async function GET(request: NextRequest) {
|
||||
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
|
||||
return gotoAccounts();
|
||||
} else if (authRequest.prompt.includes(Prompt.LOGIN)) {
|
||||
// if prompt is login
|
||||
/**
|
||||
* The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated
|
||||
*/
|
||||
|
||||
// if a hint is provided, skip loginname page and jump to the next page
|
||||
if (authRequest.loginHint) {
|
||||
try {
|
||||
let command: SendLoginnameCommand = {
|
||||
loginName: authRequest.loginHint,
|
||||
authRequestId: authRequest.id,
|
||||
};
|
||||
|
||||
if (organization) {
|
||||
command = { ...command, organization };
|
||||
}
|
||||
|
||||
const res = await sendLoginname(command);
|
||||
|
||||
if (res?.redirect) {
|
||||
const absoluteUrl = new URL(res.redirect, request.url);
|
||||
return NextResponse.redirect(absoluteUrl.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to execute sendLoginname:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const loginNameUrl = new URL("/loginname", request.url);
|
||||
if (authRequest?.id) {
|
||||
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
|
||||
if (authRequest.id) {
|
||||
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
|
||||
}
|
||||
if (authRequest.loginHint) {
|
||||
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
|
||||
@@ -254,82 +449,87 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.redirect(loginNameUrl);
|
||||
} else if (authRequest.prompt.includes(Prompt.NONE)) {
|
||||
// NONE prompt - silent authentication
|
||||
/**
|
||||
* With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages.
|
||||
* This means that the user should not be prompted to enter their password again.
|
||||
* Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction
|
||||
**/
|
||||
const selectedSession = await findValidSession(sessions, authRequest);
|
||||
|
||||
let selectedSession = findSession(sessions, authRequest);
|
||||
|
||||
if (selectedSession && selectedSession.id) {
|
||||
const cookie = sessionCookies.find(
|
||||
(cookie) => cookie.id === selectedSession?.id,
|
||||
);
|
||||
|
||||
if (cookie && cookie.id && cookie.token) {
|
||||
const session = {
|
||||
sessionId: cookie?.id,
|
||||
sessionToken: cookie?.token,
|
||||
};
|
||||
const { callbackUrl } = await createCallback(
|
||||
create(CreateCallbackRequestSchema, {
|
||||
authRequestId,
|
||||
callbackKind: {
|
||||
case: "session",
|
||||
value: create(SessionSchema, session),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "No active session found" },
|
||||
{ status: 400 }, // TODO: check for correct status code
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!selectedSession || !selectedSession.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active session found" },
|
||||
{ status: 400 }, // TODO: check for correct status code
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// check for loginHint, userId hint sessions
|
||||
let selectedSession = findSession(sessions, authRequest);
|
||||
|
||||
if (selectedSession && selectedSession.id) {
|
||||
const cookie = sessionCookies.find(
|
||||
(cookie) => cookie.id === selectedSession?.id,
|
||||
const cookie = sessionCookies.find(
|
||||
(cookie) => cookie.id === selectedSession.id,
|
||||
);
|
||||
|
||||
if (!cookie || !cookie.id || !cookie.token) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active session found" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (cookie && cookie.id && cookie.token) {
|
||||
const session = {
|
||||
sessionId: cookie?.id,
|
||||
sessionToken: cookie?.token,
|
||||
};
|
||||
try {
|
||||
const { callbackUrl } = await createCallback(
|
||||
create(CreateCallbackRequestSchema, {
|
||||
authRequestId,
|
||||
callbackKind: {
|
||||
case: "session",
|
||||
value: create(SessionSchema, session),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (callbackUrl) {
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
console.log(
|
||||
"could not create callback, redirect user to choose other account",
|
||||
);
|
||||
return gotoAccounts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return gotoAccounts();
|
||||
}
|
||||
const session = {
|
||||
sessionId: cookie.id,
|
||||
sessionToken: cookie.token,
|
||||
};
|
||||
|
||||
const { callbackUrl } = await createCallback(
|
||||
create(CreateCallbackRequestSchema, {
|
||||
authRequestId,
|
||||
callbackKind: {
|
||||
case: "session",
|
||||
value: create(SessionSchema, session),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
// check for loginHint, userId hint and valid sessions
|
||||
let selectedSession = await findValidSession(sessions, authRequest);
|
||||
|
||||
if (!selectedSession || !selectedSession.id) {
|
||||
return gotoAccounts();
|
||||
}
|
||||
|
||||
const cookie = sessionCookies.find(
|
||||
(cookie) => cookie.id === selectedSession.id,
|
||||
);
|
||||
|
||||
if (!cookie || !cookie.id || !cookie.token) {
|
||||
return gotoAccounts();
|
||||
}
|
||||
|
||||
const session = {
|
||||
sessionId: cookie.id,
|
||||
sessionToken: cookie.token,
|
||||
};
|
||||
|
||||
try {
|
||||
const { callbackUrl } = await createCallback(
|
||||
create(CreateCallbackRequestSchema, {
|
||||
authRequestId,
|
||||
callbackKind: {
|
||||
case: "session",
|
||||
value: create(SessionSchema, session),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (callbackUrl) {
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
console.log(
|
||||
"could not create callback, redirect user to choose other account",
|
||||
);
|
||||
return gotoAccounts();
|
||||
}
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return gotoAccounts();
|
||||
}
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { Alert } from "./alert";
|
||||
@@ -44,6 +45,7 @@ export function ChangePasswordForm({
|
||||
organization,
|
||||
}: Props) {
|
||||
const t = useTranslations("password");
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
@@ -107,6 +109,14 @@ export function ChangePasswordForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
passwordResponse &&
|
||||
"redirect" in passwordResponse &&
|
||||
passwordResponse.redirect
|
||||
) {
|
||||
return router.push(passwordResponse.redirect);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -25,6 +25,7 @@ type Props = {
|
||||
method: string;
|
||||
code?: string;
|
||||
loginSettings?: LoginSettings;
|
||||
host: string | null;
|
||||
};
|
||||
|
||||
type Inputs = {
|
||||
@@ -39,6 +40,7 @@ export function LoginOTP({
|
||||
method,
|
||||
code,
|
||||
loginSettings,
|
||||
host,
|
||||
}: Props) {
|
||||
const t = useTranslations("otp");
|
||||
|
||||
@@ -57,7 +59,7 @@ export function LoginOTP({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current && ["email", "sms"].includes(method)) {
|
||||
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
|
||||
initialized.current = true;
|
||||
setLoading(true);
|
||||
updateSessionForOTPChallenge()
|
||||
@@ -76,13 +78,24 @@ export function LoginOTP({
|
||||
|
||||
if (method === "email") {
|
||||
challenges = create(RequestChallengesSchema, {
|
||||
otpEmail: { deliveryType: { case: "sendCode", value: {} } },
|
||||
otpEmail: {
|
||||
deliveryType: {
|
||||
case: "sendCode",
|
||||
value: host
|
||||
? {
|
||||
urlTemplate:
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
||||
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "sms") {
|
||||
challenges = create(RequestChallengesSchema, {
|
||||
otpSms: { returnCode: true },
|
||||
otpSms: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,14 +107,19 @@ export function LoginOTP({
|
||||
challenges,
|
||||
authRequestId,
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error.message ?? "Could not request OTP challenge");
|
||||
.catch(() => {
|
||||
setError("Could not request OTP challenge");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -154,12 +172,21 @@ export function LoginOTP({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function setCodeAndContinue(values: Inputs, organization?: string) {
|
||||
return submitCode(values, organization).then(async (response) => {
|
||||
if (response) {
|
||||
if (response && "sessionId" in response) {
|
||||
setLoading(true);
|
||||
// Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const url =
|
||||
authRequestId && response.sessionId
|
||||
? await getNextUrl(
|
||||
@@ -180,6 +207,7 @@ export function LoginOTP({
|
||||
)
|
||||
: null;
|
||||
|
||||
setLoading(false);
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
@@ -198,6 +226,7 @@ export function LoginOTP({
|
||||
<button
|
||||
aria-label="Resend OTP Code"
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
@@ -210,6 +239,7 @@ export function LoginOTP({
|
||||
setLoading(false);
|
||||
});
|
||||
}}
|
||||
data-testid="resend-button"
|
||||
>
|
||||
{t("verify.resendCode")}
|
||||
</button>
|
||||
@@ -222,11 +252,12 @@ export function LoginOTP({
|
||||
{...register("code", { required: "This field is required" })}
|
||||
label="Code"
|
||||
autoComplete="one-time-code"
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
@@ -110,6 +110,11 @@ export function LoginPasskey({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (session && "error" in session && session.error) {
|
||||
setError(session.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -132,6 +137,11 @@ export function LoginPasskey({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
@@ -59,7 +59,6 @@ export function PasswordForm({
|
||||
password: { password: values.password },
|
||||
}),
|
||||
authRequestId,
|
||||
forceMfa: loginSettings?.forceMfa,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify password");
|
||||
@@ -87,6 +86,7 @@ export function PasswordForm({
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
authRequestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not reset password");
|
||||
@@ -134,6 +134,7 @@ export function PasswordForm({
|
||||
onClick={() => resetPasswordAndContinue()}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
data-testid="reset-button"
|
||||
>
|
||||
{t("verify.resetPassword")}
|
||||
</button>
|
||||
|
@@ -16,12 +16,14 @@ export function isSessionValid(session: Partial<Session>): {
|
||||
} {
|
||||
const validPassword = session?.factors?.password?.verifiedAt;
|
||||
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
|
||||
const validIDP = session?.factors?.intent?.verifiedAt;
|
||||
|
||||
const stillValid = session.expirationDate
|
||||
? timestampDate(session.expirationDate) > new Date()
|
||||
: true;
|
||||
|
||||
const verifiedAt = validPassword || validPasskey;
|
||||
const valid = !!((validPassword || validPasskey) && stillValid);
|
||||
const verifiedAt = validPassword || validPasskey || validIDP;
|
||||
const valid = !!((validPassword || validPasskey || validIDP) && stillValid);
|
||||
|
||||
return { valid, verifiedAt };
|
||||
}
|
||||
@@ -63,10 +65,14 @@ export function SessionItem({
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (valid && session?.factors?.user) {
|
||||
return continueWithSession({
|
||||
const resp = await continueWithSession({
|
||||
...session,
|
||||
authRequestId: authRequestId,
|
||||
});
|
||||
|
||||
if (resp?.redirect) {
|
||||
return router.push(resp.redirect);
|
||||
}
|
||||
} else if (session.factors?.user) {
|
||||
setLoading(true);
|
||||
const res = await sendLoginname({
|
||||
@@ -102,15 +108,23 @@ export function SessionItem({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="">{session.factors?.user?.displayName}</span>
|
||||
<span className="text-xs opacity-80 text-ellipsis">
|
||||
{session.factors?.user?.loginName}
|
||||
</span>
|
||||
{valid && (
|
||||
{valid ? (
|
||||
<span className="text-xs opacity-80 text-ellipsis">
|
||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||
</span>
|
||||
) : (
|
||||
verifiedAt && (
|
||||
<span className="text-xs opacity-80 text-ellipsis">
|
||||
expired{" "}
|
||||
{session.expirationDate &&
|
||||
moment(timestampDate(session.expirationDate)).fromNow()}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -126,6 +140,7 @@ export function SessionItem({
|
||||
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearSession(session.id).then(() => {
|
||||
reload();
|
||||
});
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { timestampMs } from "@zitadel/client";
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
@@ -12,14 +12,6 @@ type Props = {
|
||||
authRequestId?: string;
|
||||
};
|
||||
|
||||
function sortFc(a: Session, b: Session) {
|
||||
if (a.changeDate && b.changeDate) {
|
||||
return timestampMs(a.changeDate) - timestampMs(b.changeDate);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionsList({ sessions, authRequestId }: Props) {
|
||||
const t = useTranslations("accounts");
|
||||
const [list, setList] = useState<Session[]>(sessions);
|
||||
@@ -27,7 +19,17 @@ export function SessionsList({ sessions, authRequestId }: Props) {
|
||||
<div className="flex flex-col space-y-2">
|
||||
{list
|
||||
.filter((session) => session?.factors?.user?.loginName)
|
||||
.sort(sortFc)
|
||||
// sort by change date descending
|
||||
.sort((a, b) => {
|
||||
const dateA = a.changeDate
|
||||
? timestampDate(a.changeDate).getTime()
|
||||
: 0;
|
||||
const dateB = b.changeDate
|
||||
? timestampDate(b.changeDate).getTime()
|
||||
: 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
// TODO: add sorting to move invalid sessions to the bottom
|
||||
.map((session, index) => {
|
||||
return (
|
||||
<SessionItem
|
||||
|
@@ -6,7 +6,11 @@ import {
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "@/helpers/validators";
|
||||
import { changePassword, sendPassword } from "@/lib/server/password";
|
||||
import {
|
||||
changePassword,
|
||||
resetPassword,
|
||||
sendPassword,
|
||||
} from "@/lib/server/password";
|
||||
import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
@@ -62,6 +66,29 @@ export function SetPasswordForm({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function resendCode() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
authRequestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not reset password");
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPassword(values: Inputs) {
|
||||
setLoading(true);
|
||||
let payload: { userId: string; password: string; code?: string } = {
|
||||
@@ -176,11 +203,17 @@ export function SetPasswordForm({
|
||||
label="Code"
|
||||
autoComplete="one-time-code"
|
||||
error={errors.code?.message as string}
|
||||
data-testid="code-text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 mb-1">
|
||||
<Button variant={ButtonVariants.Secondary}>
|
||||
<Button
|
||||
variant={ButtonVariants.Secondary}
|
||||
data-testid="resend-button"
|
||||
onClick={() => resendCode()}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("set.resend")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -196,6 +229,7 @@ export function SetPasswordForm({
|
||||
})}
|
||||
label="New Password"
|
||||
error={errors.password?.message as string}
|
||||
data-testid="password-text-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
@@ -208,6 +242,7 @@ export function SetPasswordForm({
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
data-testid="password-confirm-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -18,17 +18,17 @@ import { SignInWithGoogle } from "./idps/sign-in-with-google";
|
||||
|
||||
export interface SignInWithIDPProps {
|
||||
children?: ReactNode;
|
||||
host: string;
|
||||
identityProviders: IdentityProvider[];
|
||||
authRequestId?: string;
|
||||
organization?: string;
|
||||
linkOnly?: boolean;
|
||||
}
|
||||
|
||||
export function SignInWithIdp({
|
||||
host,
|
||||
identityProviders,
|
||||
authRequestId,
|
||||
organization,
|
||||
linkOnly,
|
||||
}: SignInWithIDPProps) {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -39,6 +39,10 @@ export function SignInWithIdp({
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (linkOnly) {
|
||||
params.set("link", "true");
|
||||
}
|
||||
|
||||
if (authRequestId) {
|
||||
params.set("authRequestId", authRequestId);
|
||||
}
|
||||
@@ -49,10 +53,8 @@ export function SignInWithIdp({
|
||||
|
||||
const response = await startIDPFlow({
|
||||
idpId,
|
||||
successUrl:
|
||||
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
|
||||
failureUrl:
|
||||
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
|
||||
successUrl: `/idp/${provider}/success?` + new URLSearchParams(params),
|
||||
failureUrl: `/idp/${provider}/failure?` + new URLSearchParams(params),
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not start IDP flow");
|
||||
@@ -62,6 +64,11 @@ export function SignInWithIdp({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response && response?.error) {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response && "redirect" in response && response?.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
@@ -70,121 +77,136 @@ export function SignInWithIdp({
|
||||
return (
|
||||
<div className="flex flex-col w-full space-y-2 text-sm">
|
||||
{identityProviders &&
|
||||
identityProviders.map((idp, i) => {
|
||||
switch (idp.type) {
|
||||
case IdentityProviderType.APPLE:
|
||||
return (
|
||||
<SignInWithApple
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.APPLE))
|
||||
}
|
||||
></SignInWithApple>
|
||||
);
|
||||
case IdentityProviderType.OAUTH:
|
||||
return (
|
||||
<SignInWithGeneric
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OAUTH))
|
||||
}
|
||||
></SignInWithGeneric>
|
||||
);
|
||||
case IdentityProviderType.OIDC:
|
||||
return (
|
||||
<SignInWithGeneric
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OIDC))
|
||||
}
|
||||
></SignInWithGeneric>
|
||||
);
|
||||
case IdentityProviderType.GITHUB:
|
||||
return (
|
||||
<SignInWithGithub
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITHUB),
|
||||
)
|
||||
}
|
||||
></SignInWithGithub>
|
||||
);
|
||||
case IdentityProviderType.GITHUB_ES:
|
||||
return (
|
||||
<SignInWithGithub
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
|
||||
)
|
||||
}
|
||||
></SignInWithGithub>
|
||||
);
|
||||
case IdentityProviderType.AZURE_AD:
|
||||
return (
|
||||
<SignInWithAzureAd
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.AZURE_AD),
|
||||
)
|
||||
}
|
||||
></SignInWithAzureAd>
|
||||
);
|
||||
case IdentityProviderType.GOOGLE:
|
||||
return (
|
||||
<SignInWithGoogle
|
||||
key={`idp-${i}`}
|
||||
e2e="google"
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GOOGLE),
|
||||
)
|
||||
}
|
||||
></SignInWithGoogle>
|
||||
);
|
||||
case IdentityProviderType.GITLAB:
|
||||
return (
|
||||
<SignInWithGitlab
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITLAB),
|
||||
)
|
||||
}
|
||||
></SignInWithGitlab>
|
||||
);
|
||||
case IdentityProviderType.GITLAB_SELF_HOSTED:
|
||||
return (
|
||||
<SignInWithGitlab
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
|
||||
)
|
||||
}
|
||||
></SignInWithGitlab>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
identityProviders
|
||||
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
|
||||
|
||||
// .filter((idp) =>
|
||||
// linkOnly ? idp.config?.options.isLinkingAllowed : true,
|
||||
// )
|
||||
.map((idp, i) => {
|
||||
switch (idp.type) {
|
||||
case IdentityProviderType.APPLE:
|
||||
return (
|
||||
<SignInWithApple
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.APPLE),
|
||||
)
|
||||
}
|
||||
></SignInWithApple>
|
||||
);
|
||||
case IdentityProviderType.OAUTH:
|
||||
return (
|
||||
<SignInWithGeneric
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.OAUTH),
|
||||
)
|
||||
}
|
||||
></SignInWithGeneric>
|
||||
);
|
||||
case IdentityProviderType.OIDC:
|
||||
return (
|
||||
<SignInWithGeneric
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.OIDC),
|
||||
)
|
||||
}
|
||||
></SignInWithGeneric>
|
||||
);
|
||||
case IdentityProviderType.GITHUB:
|
||||
return (
|
||||
<SignInWithGithub
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITHUB),
|
||||
)
|
||||
}
|
||||
></SignInWithGithub>
|
||||
);
|
||||
case IdentityProviderType.GITHUB_ES:
|
||||
return (
|
||||
<SignInWithGithub
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
|
||||
)
|
||||
}
|
||||
></SignInWithGithub>
|
||||
);
|
||||
case IdentityProviderType.AZURE_AD:
|
||||
return (
|
||||
<SignInWithAzureAd
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.AZURE_AD),
|
||||
)
|
||||
}
|
||||
></SignInWithAzureAd>
|
||||
);
|
||||
case IdentityProviderType.GOOGLE:
|
||||
return (
|
||||
<SignInWithGoogle
|
||||
key={`idp-${i}`}
|
||||
e2e="google"
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GOOGLE),
|
||||
)
|
||||
}
|
||||
></SignInWithGoogle>
|
||||
);
|
||||
case IdentityProviderType.GITLAB:
|
||||
return (
|
||||
<SignInWithGitlab
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITLAB),
|
||||
)
|
||||
}
|
||||
></SignInWithGitlab>
|
||||
);
|
||||
case IdentityProviderType.GITLAB_SELF_HOSTED:
|
||||
return (
|
||||
<SignInWithGitlab
|
||||
key={`idp-${i}`}
|
||||
name={idp.name}
|
||||
onClick={() =>
|
||||
startFlow(
|
||||
idp.id,
|
||||
idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
|
||||
)
|
||||
}
|
||||
></SignInWithGitlab>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>{error}</Alert>
|
||||
|
@@ -15,24 +15,29 @@ export async function getNextUrl(
|
||||
defaultRedirectUri?: string,
|
||||
): Promise<string> {
|
||||
if ("sessionId" in command && "authRequestId" in command) {
|
||||
const url =
|
||||
`/login?` +
|
||||
new URLSearchParams({
|
||||
sessionId: command.sessionId,
|
||||
authRequest: command.authRequestId,
|
||||
});
|
||||
return url;
|
||||
const params = new URLSearchParams({
|
||||
sessionId: command.sessionId,
|
||||
authRequest: command.authRequestId,
|
||||
});
|
||||
|
||||
if (command.organization) {
|
||||
params.append("organization", command.organization);
|
||||
}
|
||||
|
||||
return `/login?` + params;
|
||||
}
|
||||
|
||||
if (defaultRedirectUri) {
|
||||
return defaultRedirectUri;
|
||||
}
|
||||
|
||||
const signedInUrl =
|
||||
`/signedin?` +
|
||||
new URLSearchParams({
|
||||
loginName: command.loginName,
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
loginName: command.loginName,
|
||||
});
|
||||
|
||||
return signedInUrl;
|
||||
if (command.organization) {
|
||||
params.append("organization", command.organization);
|
||||
}
|
||||
|
||||
return `/signedin?` + params;
|
||||
}
|
||||
|
@@ -142,7 +142,7 @@ export async function removeSessionFromCookie<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMostRecentSessionCookie<T>(): Promise<any> {
|
||||
export async function getMostRecentSessionCookie<T>(): Promise<Cookie> {
|
||||
const cookiesList = await cookies();
|
||||
const stringifiedCookie = cookiesList.get("sessions");
|
||||
|
||||
|
@@ -84,8 +84,8 @@ type resendVerifyEmailCommand = {
|
||||
|
||||
export async function resendVerification(command: resendVerifyEmailCommand) {
|
||||
return command.isInvite
|
||||
? resendEmailCode(command.userId)
|
||||
: resendInviteCode(command.userId);
|
||||
? resendInviteCode(command.userId)
|
||||
: resendEmailCode(command.userId);
|
||||
}
|
||||
|
||||
export async function sendVerificationRedirectWithoutCheck(command: {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { startIdentityProviderFlow } from "@/lib/zitadel";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export type StartIDPFlowCommand = {
|
||||
idpId: string;
|
||||
@@ -9,11 +10,17 @@ export type StartIDPFlowCommand = {
|
||||
};
|
||||
|
||||
export async function startIDPFlow(command: StartIDPFlowCommand) {
|
||||
const host = (await headers()).get("host");
|
||||
|
||||
if (!host) {
|
||||
return { error: "Could not get host" };
|
||||
}
|
||||
|
||||
return startIdentityProviderFlow({
|
||||
idpId: command.idpId,
|
||||
urls: {
|
||||
successUrl: command.successUrl,
|
||||
failureUrl: command.failureUrl,
|
||||
successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.successUrl}`,
|
||||
failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${command.failureUrl}`,
|
||||
},
|
||||
}).then((response) => {
|
||||
if (
|
||||
|
@@ -2,11 +2,12 @@
|
||||
|
||||
import { create } from "@zitadel/client";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
|
||||
|
||||
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||
import {
|
||||
getActiveIdentityProviders,
|
||||
getIDPByID,
|
||||
@@ -35,6 +36,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
const loginSettings = await getLoginSettings(command.organization);
|
||||
|
||||
const potentialUsers = users.result.filter((u) => {
|
||||
const human = u.type.case === "human" ? u.type.value : undefined;
|
||||
return loginSettings?.disableLoginWithEmail
|
||||
? human?.email?.isVerified && human?.email?.email !== command.loginName
|
||||
: loginSettings?.disableLoginWithPhone
|
||||
? human?.phone?.isVerified && human?.phone?.phone !== command.loginName
|
||||
: true;
|
||||
});
|
||||
|
||||
const redirectUserToSingleIDPIfAvailable = async () => {
|
||||
const identityProviders = await getActiveIdentityProviders(
|
||||
command.organization,
|
||||
@@ -44,6 +54,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
if (identityProviders.length === 1) {
|
||||
const host = (await headers()).get("host");
|
||||
|
||||
if (!host) {
|
||||
return { error: "Could not get host" };
|
||||
}
|
||||
|
||||
const identityProviderType = identityProviders[0].type;
|
||||
|
||||
const provider = idpTypeToSlug(identityProviderType);
|
||||
@@ -62,9 +77,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
idpId: identityProviders[0].id,
|
||||
urls: {
|
||||
successUrl:
|
||||
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
|
||||
new URLSearchParams(params),
|
||||
failureUrl:
|
||||
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
|
||||
new URLSearchParams(params),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,9 +98,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
if (identityProviders.length === 1) {
|
||||
const host = (await headers()).get("host");
|
||||
|
||||
if (!host) {
|
||||
return { error: "Could not get host" };
|
||||
}
|
||||
|
||||
const identityProviderId = identityProviders[0].idpId;
|
||||
|
||||
const idp = await getIDPByID(identityProviderId);
|
||||
|
||||
const idpType = idp?.type;
|
||||
|
||||
if (!idp || !idpType) {
|
||||
@@ -107,9 +130,11 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
idpId: idp.id,
|
||||
urls: {
|
||||
successUrl:
|
||||
`${host}/idp/${provider}/success?` + new URLSearchParams(params),
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/success?` +
|
||||
new URLSearchParams(params),
|
||||
failureUrl:
|
||||
`${host}/idp/${provider}/failure?` + new URLSearchParams(params),
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/idp/${provider}/failure?` +
|
||||
new URLSearchParams(params),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,8 +144,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
}
|
||||
};
|
||||
|
||||
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
|
||||
const userId = users.result[0].userId;
|
||||
if (potentialUsers.length == 1 && potentialUsers[0].userId) {
|
||||
const userId = potentialUsers[0].userId;
|
||||
|
||||
const checks = create(ChecksSchema, {
|
||||
user: { search: { case: "userId", value: userId } },
|
||||
@@ -136,24 +161,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
return { error: "Could not create session for user" };
|
||||
}
|
||||
|
||||
if (users.result[0].state === UserState.INITIAL) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
initial: "true", // this does not require a code to be set
|
||||
});
|
||||
|
||||
if (command.organization || session.factors?.user?.organizationId) {
|
||||
params.append(
|
||||
"organization",
|
||||
command.organization ?? session.factors?.user?.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (command.authRequestId) {
|
||||
params.append("authRequestid", command.authRequestId);
|
||||
}
|
||||
|
||||
return { redirect: "/password/set?" + params };
|
||||
// TODO: check if handling of userstate INITIAL is needed
|
||||
if (potentialUsers[0].state === UserState.INITIAL) {
|
||||
return { error: "Initial User not supported" };
|
||||
}
|
||||
|
||||
const methods = await listAuthenticationMethodTypes(
|
||||
@@ -162,9 +172,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
|
||||
if (
|
||||
users.result[0].type.case === "human" &&
|
||||
users.result[0].type.value.email &&
|
||||
!users.result[0].type.value.email.isVerified
|
||||
potentialUsers[0].type.case === "human" &&
|
||||
potentialUsers[0].type.value.email &&
|
||||
!potentialUsers[0].type.value.email.isVerified
|
||||
) {
|
||||
const paramsVerify = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
@@ -209,6 +219,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
const method = methods.authMethodTypes[0];
|
||||
switch (method) {
|
||||
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
|
||||
if (!loginSettings?.allowUsernamePassword) {
|
||||
return {
|
||||
error:
|
||||
"Username Password not allowed! Contact your administrator for more information.",
|
||||
};
|
||||
}
|
||||
|
||||
const paramsPassword: any = {
|
||||
loginName: session.factors?.user?.loginName,
|
||||
};
|
||||
@@ -229,6 +246,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
};
|
||||
|
||||
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
|
||||
if (loginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) {
|
||||
return {
|
||||
error:
|
||||
"Passkeys not allowed! Contact your administrator for more information.",
|
||||
};
|
||||
}
|
||||
|
||||
const paramsPasskey: any = { loginName: command.loginName };
|
||||
if (command.authRequestId) {
|
||||
paramsPasskey.authRequestId = command.authRequestId;
|
||||
@@ -262,7 +286,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
} else if (
|
||||
methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
|
||||
) {
|
||||
await redirectUserToIDP(userId);
|
||||
return redirectUserToIDP(userId);
|
||||
} else if (
|
||||
methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)
|
||||
) {
|
||||
@@ -288,8 +312,10 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
// user not found, check if register is enabled on organization
|
||||
if (loginSettings?.allowRegister && !loginSettings?.allowUsernamePassword) {
|
||||
// TODO: do we need to handle login hints for IDPs here?
|
||||
await redirectUserToSingleIDPIfAvailable();
|
||||
|
||||
const resp = await redirectUserToSingleIDPIfAvailable();
|
||||
if (resp) {
|
||||
return resp;
|
||||
}
|
||||
return { error: "Could not find user" };
|
||||
} else if (
|
||||
loginSettings?.allowRegister &&
|
||||
|
@@ -27,6 +27,7 @@ import { getSessionCookieByLoginName } from "../cookies";
|
||||
type ResetPasswordCommand = {
|
||||
loginName: string;
|
||||
organization?: string;
|
||||
authRequestId?: string;
|
||||
};
|
||||
|
||||
export async function resetPassword(command: ResetPasswordCommand) {
|
||||
@@ -46,7 +47,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
|
||||
}
|
||||
const userId = users.result[0].userId;
|
||||
|
||||
return passwordReset(userId, host);
|
||||
return passwordReset(userId, host, command.authRequestId);
|
||||
}
|
||||
|
||||
export type UpdateSessionCommand = {
|
||||
@@ -54,7 +55,6 @@ export type UpdateSessionCommand = {
|
||||
organization?: string;
|
||||
checks: Checks;
|
||||
authRequestId?: string;
|
||||
forceMfa?: boolean;
|
||||
};
|
||||
|
||||
export async function sendPassword(command: UpdateSessionCommand) {
|
||||
@@ -148,6 +148,27 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
||||
m !== AuthenticationMethodType.PASSKEY,
|
||||
);
|
||||
|
||||
const humanUser = user.type.case === "human" ? user.type.value : undefined;
|
||||
|
||||
if (
|
||||
availableSecondFactors?.length == 0 &&
|
||||
humanUser?.passwordChangeRequired
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName,
|
||||
});
|
||||
|
||||
if (command.organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", session.factors?.user?.organizationId);
|
||||
}
|
||||
|
||||
if (command.authRequestId) {
|
||||
params.append("authRequestId", command.authRequestId);
|
||||
}
|
||||
|
||||
return { redirect: "/password/change?" + params };
|
||||
}
|
||||
|
||||
if (availableSecondFactors?.length == 1) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user.loginName,
|
||||
@@ -192,24 +213,14 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
||||
}
|
||||
|
||||
return { redirect: `/mfa?` + params };
|
||||
} else if (user.state === UserState.INITIAL) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors.user.loginName,
|
||||
});
|
||||
|
||||
if (command.authRequestId) {
|
||||
params.append("authRequestId", command.authRequestId);
|
||||
}
|
||||
|
||||
if (command.organization || session.factors?.user?.organizationId) {
|
||||
params.append(
|
||||
"organization",
|
||||
command.organization ?? session.factors?.user?.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
return { redirect: `/password/change?` + params };
|
||||
} else if (command.forceMfa && !availableSecondFactors.length) {
|
||||
}
|
||||
// TODO: check if handling of userstate INITIAL is needed
|
||||
else if (user.state === UserState.INITIAL) {
|
||||
return { error: "Initial User not supported" };
|
||||
} else if (
|
||||
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
|
||||
!availableSecondFactors.length
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors.user.loginName,
|
||||
force: "true", // this defines if the mfa is forced in the settings
|
||||
|
@@ -15,7 +15,6 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getNextUrl } from "../client";
|
||||
import {
|
||||
getMostRecentSessionCookie,
|
||||
@@ -108,7 +107,7 @@ export async function continueWithSession({
|
||||
)
|
||||
: null;
|
||||
if (url) {
|
||||
return redirect(url);
|
||||
return { redirect: url };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,21 +131,23 @@ export async function updateSession(options: UpdateSessionCommand) {
|
||||
challenges,
|
||||
} = options;
|
||||
const recentSession = sessionId
|
||||
? await getSessionCookieById({ sessionId }).catch((error) => {
|
||||
return Promise.reject(error);
|
||||
})
|
||||
? await getSessionCookieById({ sessionId })
|
||||
: loginName
|
||||
? await getSessionCookieByLoginName({ loginName, organization }).catch(
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
)
|
||||
: await getMostRecentSessionCookie().catch((error) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
? await getSessionCookieByLoginName({ loginName, organization })
|
||||
: await getMostRecentSessionCookie();
|
||||
|
||||
if (!recentSession) {
|
||||
return {
|
||||
error: "Could not find session",
|
||||
};
|
||||
}
|
||||
|
||||
const host = (await headers()).get("host");
|
||||
|
||||
if (!host) {
|
||||
return { error: "Could not get host" };
|
||||
}
|
||||
|
||||
if (
|
||||
host &&
|
||||
challenges &&
|
||||
@@ -174,6 +175,10 @@ export async function updateSession(options: UpdateSessionCommand) {
|
||||
lifetime,
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return { error: "Could not update session" };
|
||||
}
|
||||
|
||||
// if password, check if user has MFA methods
|
||||
let authMethods;
|
||||
if (checks && checks.password && session.factors?.user?.id) {
|
||||
|
@@ -445,11 +445,6 @@ export async function verifyEmail(userId: string, verificationCode: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export async function resendEmailCode(userId: string) {
|
||||
return userService.resendEmailCode(
|
||||
{
|
||||
@@ -504,7 +499,11 @@ export function createUser(
|
||||
* @param userId the id of the user where the email should be set
|
||||
* @returns the newly set email
|
||||
*/
|
||||
export async function passwordReset(userId: string, host: string | null) {
|
||||
export async function passwordReset(
|
||||
userId: string,
|
||||
host: string | null,
|
||||
authRequestId?: string,
|
||||
) {
|
||||
let medium = create(SendPasswordResetLinkSchema, {
|
||||
notificationType: NotificationType.Email,
|
||||
});
|
||||
@@ -512,7 +511,9 @@ export async function passwordReset(userId: string, host: string | null) {
|
||||
if (host) {
|
||||
medium = {
|
||||
...medium,
|
||||
urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`,
|
||||
urlTemplate:
|
||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
|
||||
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -23,7 +23,9 @@
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"release": "turbo run build --filter=login^... && changeset publish",
|
||||
"run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup"
|
||||
"run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup",
|
||||
"run-sink": "docker compose -f ./acceptance/docker-compose.yaml up -d sink",
|
||||
"stop": "docker compose -f ./acceptance/docker-compose.yaml stop"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
@@ -33,6 +35,10 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@otplib/core": "^12.0.0",
|
||||
"@otplib/plugin-thirty-two": "^12.0.0",
|
||||
"@otplib/plugin-crypto": "^12.0.0",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@changesets/cli": "^2.27.9",
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@types/node": "^22.9.0",
|
||||
|
@@ -72,6 +72,7 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run local dev server before starting the tests */
|
||||
|
||||
webServer: {
|
||||
command: "pnpm start:built",
|
||||
url: "http://127.0.0.1:3000",
|
||||
|
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -16,6 +16,18 @@ importers:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.27.9
|
||||
version: 2.27.9
|
||||
'@faker-js/faker':
|
||||
specifier: ^9.2.0
|
||||
version: 9.2.0
|
||||
'@otplib/core':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.1
|
||||
'@otplib/plugin-crypto':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.1
|
||||
'@otplib/plugin-thirty-two':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.1
|
||||
'@playwright/test':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2
|
||||
@@ -919,6 +931,10 @@ packages:
|
||||
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
'@faker-js/faker@9.2.0':
|
||||
resolution: {integrity: sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
|
||||
|
||||
'@floating-ui/core@1.6.8':
|
||||
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
|
||||
|
||||
@@ -1211,6 +1227,15 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@otplib/core@12.0.1':
|
||||
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||
|
||||
'@otplib/plugin-crypto@12.0.1':
|
||||
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||
|
||||
'@otplib/plugin-thirty-two@12.0.1':
|
||||
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.0':
|
||||
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -4376,6 +4401,10 @@ packages:
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
thirty-two@1.0.2:
|
||||
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||
engines: {node: '>=0.2.6'}
|
||||
|
||||
throttleit@1.0.1:
|
||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||
|
||||
@@ -5438,6 +5467,8 @@ snapshots:
|
||||
|
||||
'@eslint/js@8.57.1': {}
|
||||
|
||||
'@faker-js/faker@9.2.0': {}
|
||||
|
||||
'@floating-ui/core@1.6.8':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.8
|
||||
@@ -5717,6 +5748,17 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@otplib/core@12.0.1': {}
|
||||
|
||||
'@otplib/plugin-crypto@12.0.1':
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
|
||||
'@otplib/plugin-thirty-two@12.0.1':
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
thirty-two: 1.0.2
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.0':
|
||||
optional: true
|
||||
|
||||
@@ -9136,6 +9178,8 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
thirty-two@1.0.2: {}
|
||||
|
||||
throttleit@1.0.1: {}
|
||||
|
||||
through@2.3.8: {}
|
||||
|
Reference in New Issue
Block a user