mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-14 05:47:43 +00:00
Merge branch 'main' into add-acceptance-to-pr-template
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -105,7 +105,7 @@ jobs:
|
|||||||
if: ${{ matrix.command == 'test:acceptance' }}
|
if: ${{ matrix.command == 'test:acceptance' }}
|
||||||
|
|
||||||
- name: Run ZITADEL
|
- name: Run ZITADEL
|
||||||
run: ZITADEL_DEV_UID=root pnpm run-zitadel
|
run: ZITADEL_DEV_UID=root pnpm run-sink
|
||||||
if: ${{ matrix.command == 'test:acceptance' }}
|
if: ${{ matrix.command == 'test:acceptance' }}
|
||||||
|
|
||||||
- name: Create Cloud Env File
|
- name: Create Cloud Env File
|
||||||
|
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.
|
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
|
### Deploy to Vercel
|
||||||
|
|
||||||
To deploy your own version on Vercel, navigate to your instance and create a service user.
|
To deploy your own version on Vercel, navigate to your instance and create a service user.
|
||||||
|
@@ -22,7 +22,7 @@ services:
|
|||||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
- 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
|
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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready"]
|
test: [ "CMD-SHELL", "pg_isready" ]
|
||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "30s"
|
timeout: "30s"
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -45,6 +45,9 @@ services:
|
|||||||
ZITADEL_API_INTERNAL_URL: http://zitadel:8080
|
ZITADEL_API_INTERNAL_URL: http://zitadel:8080
|
||||||
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
|
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
|
||||||
WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.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:
|
volumes:
|
||||||
- "./pat:/pat"
|
- "./pat:/pat"
|
||||||
- "../apps/login:/apps/login"
|
- "../apps/login:/apps/login"
|
||||||
@@ -52,3 +55,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
wait_for_zitadel:
|
wait_for_zitadel:
|
||||||
condition: "service_completed_successfully"
|
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_PORT="${ZITADEL_API_PORT:-8080}"
|
||||||
ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}"
|
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}}"
|
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
|
if [ -z "${PAT}" ]; then
|
||||||
echo "Reading PAT from file ${PAT_FILE}"
|
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')
|
ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
#################################################################
|
||||||
|
# Environment files
|
||||||
|
#################################################################
|
||||||
|
|
||||||
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
|
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
|
||||||
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
|
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
|
||||||
WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local}
|
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}
|
echo "ZITADEL_API_URL=${ZITADEL_API_URL}
|
||||||
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
|
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
|
||||||
ZITADEL_SERVICE_USER_TOKEN=${PAT}
|
ZITADEL_SERVICE_USER_TOKEN=${PAT}
|
||||||
|
SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL}
|
||||||
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
|
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
|
||||||
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
|
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
|
||||||
cat ${WRITE_ENVIRONMENT_FILE}
|
cat ${WRITE_ENVIRONMENT_FILE}
|
||||||
@@ -39,6 +47,54 @@ cat ${WRITE_ENVIRONMENT_FILE}
|
|||||||
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
|
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
|
||||||
cat ${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
|
DEFAULTORG_RESPONSE_RESULTS=0
|
||||||
# waiting for default organization
|
# waiting for default organization
|
||||||
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
|
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
|
||||||
@@ -53,3 +109,4 @@ do
|
|||||||
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
|
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
|
||||||
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
|
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
|
||||||
done
|
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 { expect, Page } from "@playwright/test";
|
||||||
|
import { code, otpFromSink } from "./code";
|
||||||
import { loginname } from "./loginname";
|
import { loginname } from "./loginname";
|
||||||
import { password } from "./password";
|
import { password } from "./password";
|
||||||
|
import { totp } from "./zitadel";
|
||||||
|
|
||||||
export async function startLogin(page: Page) {
|
export async function startLogin(page: Page) {
|
||||||
await page.goto("/loginname");
|
await page.goto("/loginname");
|
||||||
@@ -23,6 +25,17 @@ export async function loginScreenExpect(page: Page, fullName: string) {
|
|||||||
await expect(page.getByRole("heading")).toContainText(fullName);
|
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 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";
|
import { expect, Page } from "@playwright/test";
|
||||||
|
|
||||||
const usernameUserInput = "username-text-input";
|
const usernameTextInput = "username-text-input";
|
||||||
|
|
||||||
export async function loginnameScreen(page: Page, username: string) {
|
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) {
|
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");
|
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 { expect, Page } from "@playwright/test";
|
||||||
|
import { getCodeFromSink } from "./sink";
|
||||||
|
|
||||||
|
const codeField = "code-text-input";
|
||||||
const passwordField = "password-text-input";
|
const passwordField = "password-text-input";
|
||||||
const passwordConfirmField = "password-confirm-text-input";
|
const passwordConfirmField = "password-confirm-text-input";
|
||||||
const lengthCheck = "length-check";
|
const lengthCheck = "length-check";
|
||||||
@@ -55,3 +57,26 @@ async function checkContent(page: Page, testid: string, match: boolean) {
|
|||||||
await expect(page.getByTestId(testid)).toContainText(noMatchText);
|
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 { Page } from "@playwright/test";
|
||||||
import { changePasswordScreen, passwordScreen } from "./password-screen";
|
import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen";
|
||||||
|
|
||||||
const passwordSubmitButton = "submit-button";
|
const passwordSubmitButton = "submit-button";
|
||||||
|
const passwordResetButton = "reset-button";
|
||||||
|
|
||||||
export async function startChangePassword(page: Page, loginname: string) {
|
export async function startChangePassword(page: Page, loginname: string) {
|
||||||
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
|
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 passwordScreen(page, password);
|
||||||
await page.getByTestId(passwordSubmitButton).click();
|
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 { test } from "@playwright/test";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
import { loginScreenExpect } from "./login";
|
import { loginScreenExpect } from "./login";
|
||||||
import { registerWithPasskey, registerWithPassword } from "./register";
|
import { registerWithPasskey, registerWithPassword } from "./register";
|
||||||
import { removeUserByUsername } from "./zitadel";
|
import { removeUserByUsername } from "./zitadel";
|
||||||
|
|
||||||
test("register with password", async ({ page }) => {
|
// Read from ".env" file.
|
||||||
const username = "register-password@example.com";
|
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
||||||
const password = "Password1!";
|
|
||||||
const firstname = "firstname";
|
test("register with password", async ({ page }) => {
|
||||||
const lastname = "lastname";
|
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 registerWithPassword(page, firstname, lastname, username, password, password);
|
||||||
await loginScreenExpect(page, firstname + " " + lastname);
|
await loginScreenExpect(page, firstname + " " + lastname);
|
||||||
|
|
||||||
|
// wait for projection of user
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await removeUserByUsername(username);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("register with passkey", async ({ page }) => {
|
test("register with passkey", async ({ page }) => {
|
||||||
const username = "register-passkey@example.com";
|
const username = faker.internet.email();
|
||||||
const firstname = "firstname";
|
const firstname = faker.person.firstName();
|
||||||
const lastname = "lastname";
|
const lastname = faker.person.lastName();
|
||||||
|
|
||||||
await removeUserByUsername(username);
|
|
||||||
await registerWithPasskey(page, firstname, lastname, username);
|
await registerWithPasskey(page, firstname, lastname, username);
|
||||||
await loginScreenExpect(page, firstname + " " + lastname);
|
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 }) => {
|
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 page.goto("/register");
|
||||||
await registerUserScreenPasskey(page, firstname, lastname, email);
|
await registerUserScreenPasskey(page, firstname, lastname, email);
|
||||||
await page.getByTestId("submit-button").click();
|
await page.getByTestId("submit-button").click();
|
||||||
|
|
||||||
|
// wait for projection of user
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
return await passkeyRegister(page);
|
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 { Page } from "@playwright/test";
|
||||||
import axios from "axios";
|
|
||||||
import { registerWithPasskey } from "./register";
|
import { registerWithPasskey } from "./register";
|
||||||
import { getUserByUsername, removeUser } from "./zitadel";
|
import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel";
|
||||||
|
|
||||||
export interface userProps {
|
export interface userProps {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -9,6 +8,7 @@ export interface userProps {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
@@ -20,54 +20,13 @@ class User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensure(page: Page) {
|
async ensure(page: Page) {
|
||||||
await this.remove();
|
const response = await addUser(this.props);
|
||||||
|
|
||||||
const body = {
|
this.setUserId(response.userId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove() {
|
async cleanup() {
|
||||||
const resp: any = await getUserByUsername(this.getUsername());
|
await removeUser(this.getUserId());
|
||||||
if (!resp || !resp.result || !resp.result[0]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await removeUser(resp.result[0].userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setUserId(userId: string) {
|
public setUserId(userId: string) {
|
||||||
@@ -94,12 +53,22 @@ class User {
|
|||||||
return this.props.lastName;
|
return this.props.lastName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPhone() {
|
||||||
|
return this.props.phone;
|
||||||
|
}
|
||||||
|
|
||||||
public getFullName() {
|
public getFullName() {
|
||||||
return `${this.props.firstName} ${this.props.lastName}`;
|
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 {
|
export enum OtpType {
|
||||||
sms = "sms",
|
sms = "sms",
|
||||||
@@ -112,12 +81,12 @@ export interface otpUserProps {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
phone: string;
|
||||||
type: OtpType;
|
type: OtpType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PasswordUserWithOTP extends User {
|
export class PasswordUserWithOTP extends User {
|
||||||
private type: OtpType;
|
private type: OtpType;
|
||||||
private code: string;
|
|
||||||
|
|
||||||
constructor(props: otpUserProps) {
|
constructor(props: otpUserProps) {
|
||||||
super({
|
super({
|
||||||
@@ -126,6 +95,7 @@ export class PasswordUserWithOTP extends User {
|
|||||||
lastName: props.lastName,
|
lastName: props.lastName,
|
||||||
organization: props.organization,
|
organization: props.organization,
|
||||||
password: props.password,
|
password: props.password,
|
||||||
|
phone: props.phone,
|
||||||
});
|
});
|
||||||
this.type = props.type;
|
this.type = props.type;
|
||||||
}
|
}
|
||||||
@@ -133,47 +103,27 @@ export class PasswordUserWithOTP extends User {
|
|||||||
async ensure(page: Page) {
|
async ensure(page: Page) {
|
||||||
await super.ensure(page);
|
await super.ensure(page);
|
||||||
|
|
||||||
let url = "otp_";
|
await activateOTP(this.getUserId(), this.type);
|
||||||
switch (this.type) {
|
|
||||||
case OtpType.sms:
|
|
||||||
url = url + "sms";
|
|
||||||
break;
|
|
||||||
case OtpType.email:
|
|
||||||
url = url + "email";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// wait for projection of user
|
||||||
const response = await axios.post(
|
await page.waitForTimeout(2000);
|
||||||
`${process.env.ZITADEL_API_URL}/v2/users/${this.getUserId()}/${url}`,
|
}
|
||||||
{},
|
}
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status >= 400 && response.status !== 409) {
|
export class PasswordUserWithTOTP extends User {
|
||||||
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
|
private secret: string;
|
||||||
console.error(error);
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: get code from SMS or Email provider
|
async ensure(page: Page) {
|
||||||
this.code = "";
|
await super.ensure(page);
|
||||||
} catch (error) {
|
|
||||||
console.error("Error making request:", error);
|
this.secret = await addTOTP(this.getUserId());
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for projection of user
|
// wait for projection of user
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCode() {
|
public getSecret(): string {
|
||||||
return this.code;
|
return this.secret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +132,7 @@ export interface passkeyUserProps {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PasskeyUser extends User {
|
export class PasskeyUser extends User {
|
||||||
@@ -194,11 +145,11 @@ export class PasskeyUser extends User {
|
|||||||
lastName: props.lastName,
|
lastName: props.lastName,
|
||||||
organization: props.organization,
|
organization: props.organization,
|
||||||
password: "",
|
password: "",
|
||||||
|
phone: props.phone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ensure(page: Page) {
|
public async ensure(page: Page) {
|
||||||
await this.remove();
|
|
||||||
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
|
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
|
||||||
this.authenticatorId = authId;
|
this.authenticatorId = authId;
|
||||||
|
|
||||||
@@ -206,8 +157,12 @@ export class PasskeyUser extends User {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async remove() {
|
async cleanup() {
|
||||||
await super.remove();
|
const resp: any = await getUserByUsername(this.getUsername());
|
||||||
|
if (!resp || !resp.result || !resp.result[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await removeUser(resp.result[0].userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthenticatorId(): string {
|
public getAuthenticatorId(): string {
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { faker } from "@faker-js/faker";
|
||||||
import { test as base } from "@playwright/test";
|
import { test as base } from "@playwright/test";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -10,13 +11,15 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
|||||||
const test = base.extend<{ user: PasskeyUser }>({
|
const test = base.extend<{ user: PasskeyUser }>({
|
||||||
user: async ({ page }, use) => {
|
user: async ({ page }, use) => {
|
||||||
const user = new PasskeyUser({
|
const user = new PasskeyUser({
|
||||||
email: "passkey@example.com",
|
email: faker.internet.email(),
|
||||||
firstName: "first",
|
firstName: faker.person.firstName(),
|
||||||
lastName: "last",
|
lastName: faker.person.lastName(),
|
||||||
organization: "",
|
organization: "",
|
||||||
|
phone: faker.phone.number(),
|
||||||
});
|
});
|
||||||
await user.ensure(page);
|
await user.ensure(page);
|
||||||
await use(user);
|
await use(user);
|
||||||
|
await user.cleanup();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,16 +28,7 @@ test("username and passkey login", async ({ user, page }) => {
|
|||||||
await loginScreenExpect(page, user.getFullName());
|
await loginScreenExpect(page, user.getFullName());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("username and passkey login, if passkey enabled", async ({ user, page }) => {
|
test("username and passkey login, multiple auth methods", async ({ 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 }) => {
|
|
||||||
// Given passkey and password is enabled on the organization of the user
|
// Given passkey and password is enabled on the organization of the user
|
||||||
// Given the user has password and passkey registered
|
// Given the user has password and passkey registered
|
||||||
// enter username
|
// enter username
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { faker } from "@faker-js/faker";
|
||||||
import { test as base } from "@playwright/test";
|
import { test as base } from "@playwright/test";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -12,14 +13,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
|||||||
const test = base.extend<{ user: PasswordUser }>({
|
const test = base.extend<{ user: PasswordUser }>({
|
||||||
user: async ({ page }, use) => {
|
user: async ({ page }, use) => {
|
||||||
const user = new PasswordUser({
|
const user = new PasswordUser({
|
||||||
email: "password-changed@example.com",
|
email: faker.internet.email(),
|
||||||
firstName: "first",
|
firstName: faker.person.firstName(),
|
||||||
lastName: "last",
|
lastName: faker.person.lastName(),
|
||||||
password: "Password1!",
|
|
||||||
organization: "",
|
organization: "",
|
||||||
|
phone: faker.phone.number(),
|
||||||
|
password: "Password1!",
|
||||||
});
|
});
|
||||||
await user.ensure(page);
|
await user.ensure(page);
|
||||||
await use(user);
|
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 changedPw1 = "change";
|
||||||
const changedPw2 = "chang";
|
const changedPw2 = "chang";
|
||||||
await loginWithPassword(page, user.getUsername(), user.getPassword());
|
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 email otp is enabled on the organization of the user
|
||||||
// Given the user has only email otp configured as second factor
|
// Given the user has only email otp configured as second factor
|
||||||
// User enters username
|
// 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 receives an email with a verification code
|
||||||
// User enters the code into the ui
|
// User enters the code into the ui
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 }) => {
|
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)
|
// 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 email otp is enabled on the organization of the user
|
||||||
// Given the user has only email otp configured as second factor
|
// Given the user has only email otp configured as second factor
|
||||||
// User enters username
|
// 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 receives a new email with a verification code
|
||||||
// User enters the new code in the ui
|
// User enters the new code in the ui
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 email otp is enabled on the organization of the user
|
||||||
// Given the user has only email otp configured as second factor
|
// Given the user has only email otp configured as second factor
|
||||||
// User enters username
|
// User enters username
|
||||||
// User enters password
|
// User enters password
|
||||||
// User receives an email with a verification code
|
// User receives an email with a verification code
|
||||||
// User enters a wrond code
|
// User enters a wrong code
|
||||||
// Error message - "Invalid code" is shown
|
// 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 }) => {
|
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 enters password
|
||||||
// User receives an email with a verification code
|
// User receives an email with a verification code
|
||||||
// User clicks button to use sms otp as second factor
|
// 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 enters code in ui
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 sms otp is enabled on the organization of the user
|
||||||
// Given the user has only sms otp configured as second factor
|
// Given the user has only sms otp configured as second factor
|
||||||
// User enters username
|
// 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 receives a sms with a verification code
|
||||||
// User enters the code into the ui
|
// User enters the code into the ui
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 sms otp is enabled on the organization of the user
|
||||||
// Given the user has only sms otp configured as second factor
|
// Given the user has only sms otp configured as second factor
|
||||||
// User enters username
|
// User enters username
|
||||||
@@ -19,9 +49,11 @@ test("username, password and sms otp login, resend code", async ({ page }) => {
|
|||||||
// User clicks resend code
|
// User clicks resend code
|
||||||
// User receives a new sms with a verification code
|
// User receives a new sms with a verification code
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 sms otp is enabled on the organization of the user
|
||||||
// Given the user has only sms otp configured as second factor
|
// Given the user has only sms otp configured as second factor
|
||||||
// User enters username
|
// 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 receives a sms with a verification code
|
||||||
// User enters a wrong code
|
// User enters a wrong code
|
||||||
// Error message - "Invalid code" is shown
|
// 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 totp is enabled on the organization of the user
|
||||||
// Given the user has only totp configured as second factor
|
// Given the user has only totp configured as second factor
|
||||||
// User enters username
|
// User enters username
|
||||||
@@ -8,9 +35,11 @@ test("username, password and totp login", async ({ page }) => {
|
|||||||
// Screen for entering the code is shown directly
|
// Screen for entering the code is shown directly
|
||||||
// User enters the code into the ui
|
// User enters the code into the ui
|
||||||
// User is redirected to the app (default redirect url)
|
// 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 totp is enabled on the organization of the user
|
||||||
// Given the user has only totp configured as second factor
|
// Given the user has only totp configured as second factor
|
||||||
// User enters username
|
// 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
|
// Screen for entering the code is shown directly
|
||||||
// User enters a wrond code
|
// User enters a wrond code
|
||||||
// Error message - "Invalid code" is shown
|
// 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 }) => {
|
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 { test as base } from "@playwright/test";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -14,14 +15,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
|
|||||||
const test = base.extend<{ user: PasswordUser }>({
|
const test = base.extend<{ user: PasswordUser }>({
|
||||||
user: async ({ page }, use) => {
|
user: async ({ page }, use) => {
|
||||||
const user = new PasswordUser({
|
const user = new PasswordUser({
|
||||||
email: "password@example.com",
|
email: faker.internet.email(),
|
||||||
firstName: "first",
|
firstName: faker.person.firstName(),
|
||||||
lastName: "last",
|
lastName: faker.person.lastName(),
|
||||||
password: "Password1!",
|
|
||||||
organization: "",
|
organization: "",
|
||||||
|
phone: faker.phone.number(),
|
||||||
|
password: "Password1!",
|
||||||
});
|
});
|
||||||
await user.ensure(page);
|
await user.ensure(page);
|
||||||
await use(user);
|
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 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) {
|
export async function removeUserByUsername(username: string) {
|
||||||
const resp = await getUserByUsername(username);
|
const resp = await getUserByUsername(username);
|
||||||
@@ -9,8 +39,12 @@ export async function removeUserByUsername(username: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeUser(id: string) {
|
export async function removeUser(id: string) {
|
||||||
|
await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCall(url: string) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(`${process.env.ZITADEL_API_URL}/v2/users/${id}`, {
|
const response = await axios.delete(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
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 = {
|
const listUsersBody = {
|
||||||
queries: [
|
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 {
|
try {
|
||||||
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody, {
|
const response = await axios.post(url, data, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||||
@@ -58,3 +96,64 @@ export async function getUserByUsername(username: string) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function activateOTP(userId: string, type: OtpType) {
|
||||||
|
let url = "otp_";
|
||||||
|
switch (type) {
|
||||||
|
case OtpType.sms:
|
||||||
|
url = url + "sms";
|
||||||
|
break;
|
||||||
|
case OtpType.email:
|
||||||
|
url = url + "email";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushCall(url: string, data: any) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url, data, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
|
||||||
|
console.error(error);
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error making request:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTOTP(userId: string): Promise<string> {
|
||||||
|
const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {});
|
||||||
|
const code = totp(response.secret);
|
||||||
|
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code });
|
||||||
|
return response.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totp(secret: string) {
|
||||||
|
const authenticator = new Authenticator({
|
||||||
|
createDigest,
|
||||||
|
createRandomBytes,
|
||||||
|
keyDecoder,
|
||||||
|
keyEncoder,
|
||||||
|
});
|
||||||
|
// google authenticator usage
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
|
||||||
|
// check if token can be used
|
||||||
|
if (!authenticator.verify({ token: token, secret: secret })) {
|
||||||
|
const error = `Generated token could not be verified`;
|
||||||
|
console.error(error);
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
@@ -24,6 +24,7 @@
|
|||||||
"title": "Passwort festlegen",
|
"title": "Passwort festlegen",
|
||||||
"description": "Legen Sie das Passwort für Ihr Konto fest",
|
"description": "Legen Sie das Passwort für Ihr Konto fest",
|
||||||
"codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.",
|
"codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.",
|
||||||
|
"noCodeReceived": "Keinen Code erhalten?",
|
||||||
"resend": "Erneut senden",
|
"resend": "Erneut senden",
|
||||||
"submit": "Weiter"
|
"submit": "Weiter"
|
||||||
},
|
},
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"verify": {
|
"verify": {
|
||||||
"title": "Benutzer verifizieren",
|
"title": "Benutzer verifizieren",
|
||||||
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
|
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
|
||||||
|
"noCodeReceived": "Keinen Code erhalten?",
|
||||||
"resendCode": "Code erneut senden",
|
"resendCode": "Code erneut senden",
|
||||||
"submit": "Weiter"
|
"submit": "Weiter"
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@
|
|||||||
"title": "Set Password",
|
"title": "Set Password",
|
||||||
"description": "Set the password for your account",
|
"description": "Set the password for your account",
|
||||||
"codeSent": "A code has been sent to your email address.",
|
"codeSent": "A code has been sent to your email address.",
|
||||||
|
"noCodeReceived": "Didn't receive a code?",
|
||||||
"resend": "Resend code",
|
"resend": "Resend code",
|
||||||
"submit": "Continue"
|
"submit": "Continue"
|
||||||
},
|
},
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"verify": {
|
"verify": {
|
||||||
"title": "Verify user",
|
"title": "Verify user",
|
||||||
"description": "Enter the Code provided in the verification email.",
|
"description": "Enter the Code provided in the verification email.",
|
||||||
|
"noCodeReceived": "Didn't receive a code?",
|
||||||
"resendCode": "Resend code",
|
"resendCode": "Resend code",
|
||||||
"submit": "Continue"
|
"submit": "Continue"
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@
|
|||||||
"title": "Establecer Contraseña",
|
"title": "Establecer Contraseña",
|
||||||
"description": "Establece la contraseña para tu cuenta",
|
"description": "Establece la contraseña para tu cuenta",
|
||||||
"codeSent": "Se ha enviado un código a su correo electrónico.",
|
"codeSent": "Se ha enviado un código a su correo electrónico.",
|
||||||
|
"noCodeReceived": "¿No recibiste un código?",
|
||||||
"resend": "Reenviar código",
|
"resend": "Reenviar código",
|
||||||
"submit": "Continuar"
|
"submit": "Continuar"
|
||||||
},
|
},
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"verify": {
|
"verify": {
|
||||||
"title": "Verificar usuario",
|
"title": "Verificar usuario",
|
||||||
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
|
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
|
||||||
|
"noCodeReceived": "¿No recibiste un código?",
|
||||||
"resendCode": "Reenviar código",
|
"resendCode": "Reenviar código",
|
||||||
"submit": "Continuar"
|
"submit": "Continuar"
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@
|
|||||||
"title": "Imposta Password",
|
"title": "Imposta Password",
|
||||||
"description": "Imposta la password per il tuo account",
|
"description": "Imposta la password per il tuo account",
|
||||||
"codeSent": "Un codice è stato inviato al tuo indirizzo email.",
|
"codeSent": "Un codice è stato inviato al tuo indirizzo email.",
|
||||||
|
"noCodeReceived": "Non hai ricevuto un codice?",
|
||||||
"resend": "Invia di nuovo",
|
"resend": "Invia di nuovo",
|
||||||
"submit": "Continua"
|
"submit": "Continua"
|
||||||
},
|
},
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"verify": {
|
"verify": {
|
||||||
"title": "Verifica utente",
|
"title": "Verifica utente",
|
||||||
"description": "Inserisci il codice fornito nell'email di verifica.",
|
"description": "Inserisci il codice fornito nell'email di verifica.",
|
||||||
|
"noCodeReceived": "Non hai ricevuto un codice?",
|
||||||
"resendCode": "Invia di nuovo il codice",
|
"resendCode": "Invia di nuovo il codice",
|
||||||
"submit": "Continua"
|
"submit": "Continua"
|
||||||
}
|
}
|
||||||
|
194
apps/login/locales/zh.json
Normal file
194
apps/login/locales/zh.json
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"back": "返回"
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"title": "账户",
|
||||||
|
"description": "选择您想使用的账户。",
|
||||||
|
"addAnother": "添加另一个账户",
|
||||||
|
"noResults": "未找到账户"
|
||||||
|
},
|
||||||
|
"loginname": {
|
||||||
|
"title": "欢迎回来!",
|
||||||
|
"description": "请输入您的登录信息。",
|
||||||
|
"register": "注册新用户"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"verify": {
|
||||||
|
"title": "密码",
|
||||||
|
"description": "请输入您的密码。",
|
||||||
|
"resetPassword": "重置密码",
|
||||||
|
"submit": "继续"
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"title": "设置密码",
|
||||||
|
"description": "为您的账户设置密码",
|
||||||
|
"codeSent": "验证码已发送到您的邮箱。",
|
||||||
|
"noCodeReceived": "没有收到验证码?",
|
||||||
|
"resend": "重发验证码",
|
||||||
|
"submit": "继续"
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"title": "更改密码",
|
||||||
|
"description": "为您的账户设置密码",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idp": {
|
||||||
|
"title": "使用 SSO 登录",
|
||||||
|
"description": "选择以下提供商中的一个进行登录",
|
||||||
|
"signInWithApple": "用 Apple 登录",
|
||||||
|
"signInWithGoogle": "用 Google 登录",
|
||||||
|
"signInWithAzureAD": "用 AzureAD 登录",
|
||||||
|
"signInWithGithub": "用 GitHub 登录",
|
||||||
|
"signInWithGitlab": "用 GitLab 登录",
|
||||||
|
"loginSuccess": {
|
||||||
|
"title": "登录成功",
|
||||||
|
"description": "您已成功登录!"
|
||||||
|
},
|
||||||
|
"linkingSuccess": {
|
||||||
|
"title": "账户已链接",
|
||||||
|
"description": "您已成功链接您的账户!"
|
||||||
|
},
|
||||||
|
"registerSuccess": {
|
||||||
|
"title": "注册成功",
|
||||||
|
"description": "您已成功注册!"
|
||||||
|
},
|
||||||
|
"loginError": {
|
||||||
|
"title": "登录失败",
|
||||||
|
"description": "登录时发生错误。"
|
||||||
|
},
|
||||||
|
"linkingError": {
|
||||||
|
"title": "账户链接失败",
|
||||||
|
"description": "链接账户时发生错误。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"verify": {
|
||||||
|
"title": "验证您的身份",
|
||||||
|
"description": "选择以下的一个因素。",
|
||||||
|
"noResults": "没有可设置的第二因素。"
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"title": "设置双因素认证",
|
||||||
|
"description": "选择以下的一个第二因素。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"verify": {
|
||||||
|
"title": "验证双因素",
|
||||||
|
"totpDescription": "请输入认证应用程序中的验证码。",
|
||||||
|
"smsDescription": "输入通过短信收到的验证码。",
|
||||||
|
"emailDescription": "输入通过电子邮件收到的验证码。",
|
||||||
|
"noCodeReceived": "没有收到验证码?",
|
||||||
|
"resendCode": "重发验证码",
|
||||||
|
"submit": "继续"
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"title": "设置双因素认证",
|
||||||
|
"totpDescription": "使用认证应用程序扫描二维码。",
|
||||||
|
"smsDescription": "输入您的电话号码以接收短信验证码。",
|
||||||
|
"emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。",
|
||||||
|
"totpRegisterDescription": "扫描二维码或手动导航到URL。",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"passkey": {
|
||||||
|
"verify": {
|
||||||
|
"title": "使用密钥认证",
|
||||||
|
"description": "您的设备将请求指纹、面部识别或屏幕锁",
|
||||||
|
"usePassword": "使用密码",
|
||||||
|
"submit": "继续"
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"title": "设置密钥",
|
||||||
|
"description": "您的设备将请求指纹、面部识别或屏幕锁",
|
||||||
|
"info": {
|
||||||
|
"description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。",
|
||||||
|
"link": "无密码认证"
|
||||||
|
},
|
||||||
|
"skip": "跳过",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"u2f": {
|
||||||
|
"verify": {
|
||||||
|
"title": "验证双因素",
|
||||||
|
"description": "使用您的设备验证帐户。"
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"title": "设置双因素认证",
|
||||||
|
"description": "设置设备为第二因素。",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"methods": {
|
||||||
|
"passkey": "密钥",
|
||||||
|
"password": "密码"
|
||||||
|
},
|
||||||
|
"disabled": {
|
||||||
|
"title": "注册已禁用",
|
||||||
|
"description": "您的设置不允许注册新用户。"
|
||||||
|
},
|
||||||
|
"missingdata": {
|
||||||
|
"title": "缺少数据",
|
||||||
|
"description": "请提供所有必需的数据。"
|
||||||
|
},
|
||||||
|
"title": "注册",
|
||||||
|
"description": "创建您的 ZITADEL 账户。",
|
||||||
|
"selectMethod": "选择您想使用的认证方法",
|
||||||
|
"agreeTo": "注册即表示您同意条款和条件",
|
||||||
|
"termsOfService": "服务条款",
|
||||||
|
"privacyPolicy": "隐私政策",
|
||||||
|
"submit": "继续",
|
||||||
|
"password": {
|
||||||
|
"title": "设置密码",
|
||||||
|
"description": "为您的账户设置密码",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"invite": {
|
||||||
|
"title": "邀请用户",
|
||||||
|
"description": "提供您想邀请的用户的电子邮箱地址和姓名。",
|
||||||
|
"info": "用户将收到一封包含进一步说明的电子邮件。",
|
||||||
|
"notAllowed": "您的设置不允许邀请用户。",
|
||||||
|
"submit": "继续",
|
||||||
|
"success": {
|
||||||
|
"title": "用户已邀请",
|
||||||
|
"description": "邮件已成功发送。",
|
||||||
|
"verified": "用户已被邀请并已验证其电子邮件。",
|
||||||
|
"notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。",
|
||||||
|
"submit": "邀请另一位用户"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signedin": {
|
||||||
|
"title": "欢迎 {user}!",
|
||||||
|
"description": "您已登录。",
|
||||||
|
"continue": "继续"
|
||||||
|
},
|
||||||
|
"verify": {
|
||||||
|
"userIdMissing": "未提供用户 ID!",
|
||||||
|
"success": "用户验证成功。",
|
||||||
|
"setupAuthenticator": "设置认证器",
|
||||||
|
"verify": {
|
||||||
|
"title": "验证用户",
|
||||||
|
"description": "输入验证邮件中的验证码。",
|
||||||
|
"noCodeReceived": "没有收到验证码?",
|
||||||
|
"resendCode": "重发验证码",
|
||||||
|
"submit": "继续"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authenticator": {
|
||||||
|
"title": "选择认证方式",
|
||||||
|
"description": "选择您想使用的认证方法",
|
||||||
|
"noMethodsAvailable": "没有可用的认证方法",
|
||||||
|
"allSetup": "您已经设置好了一个认证器!"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。",
|
||||||
|
"sessionExpired": "当前会话已过期,请重新登录。",
|
||||||
|
"failedLoading": "加载数据失败,请再试一次。",
|
||||||
|
"tryagain": "重试"
|
||||||
|
}
|
||||||
|
}
|
@@ -394,3 +394,5 @@ Timebased features like the multifactor init prompt or password expiry, are not
|
|||||||
- Lockout Settings
|
- Lockout Settings
|
||||||
- Password Expiry Settings
|
- Password Expiry Settings
|
||||||
- Login Settings: multifactor init prompt
|
- 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
|
||||||
|
@@ -1,8 +1,13 @@
|
|||||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||||
import { SessionsList } from "@/components/sessions-list";
|
import { SessionsList } from "@/components/sessions-list";
|
||||||
import { getAllSessionCookieIds } from "@/lib/cookies";
|
import { getAllSessionCookieIds } from "@/lib/cookies";
|
||||||
import { getBrandingSettings, listSessions } from "@/lib/zitadel";
|
import {
|
||||||
|
getBrandingSettings,
|
||||||
|
getDefaultOrg,
|
||||||
|
listSessions,
|
||||||
|
} from "@/lib/zitadel";
|
||||||
import { UserPlusIcon } from "@heroicons/react/24/outline";
|
import { UserPlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -30,9 +35,19 @@ export default async function Page(props: {
|
|||||||
const authRequestId = searchParams?.authRequestId;
|
const authRequestId = searchParams?.authRequestId;
|
||||||
const organization = searchParams?.organization;
|
const organization = searchParams?.organization;
|
||||||
|
|
||||||
|
let defaultOrganization;
|
||||||
|
if (!organization) {
|
||||||
|
const org: Organization | null = await getDefaultOrg();
|
||||||
|
if (org) {
|
||||||
|
defaultOrganization = org.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sessions = await loadSessions();
|
let sessions = await loadSessions();
|
||||||
|
|
||||||
const branding = await getBrandingSettings(organization);
|
const branding = await getBrandingSettings(
|
||||||
|
organization ?? defaultOrganization,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTheme branding={branding}>
|
<DynamicTheme branding={branding}>
|
||||||
|
@@ -2,8 +2,13 @@ import { Alert } from "@/components/alert";
|
|||||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||||
import { LoginOTP } from "@/components/login-otp";
|
import { LoginOTP } from "@/components/login-otp";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
|
import { getSessionCookieById } from "@/lib/cookies";
|
||||||
import { loadMostRecentSession } from "@/lib/session";
|
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 { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
@@ -17,19 +22,42 @@ export default async function Page(props: {
|
|||||||
const t = await getTranslations({ locale, namespace: "otp" });
|
const t = await getTranslations({ locale, namespace: "otp" });
|
||||||
const tError = await getTranslations({ locale, namespace: "error" });
|
const tError = await getTranslations({ locale, namespace: "error" });
|
||||||
|
|
||||||
const { loginName, authRequestId, sessionId, organization, code, submit } =
|
const {
|
||||||
searchParams;
|
loginName, // send from password page
|
||||||
|
userId, // send from email link
|
||||||
|
authRequestId,
|
||||||
|
sessionId,
|
||||||
|
organization,
|
||||||
|
code,
|
||||||
|
submit,
|
||||||
|
} = searchParams;
|
||||||
|
|
||||||
const { method } = params;
|
const { method } = params;
|
||||||
|
|
||||||
const session = await loadMostRecentSession({
|
const session = sessionId
|
||||||
loginName,
|
? await loadSessionById(sessionId, organization)
|
||||||
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");
|
const host = (await headers()).get("host");
|
||||||
|
|
||||||
@@ -62,12 +90,14 @@ export default async function Page(props: {
|
|||||||
></UserAvatar>
|
></UserAvatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{method && (
|
{method && session && (
|
||||||
<LoginOTP
|
<LoginOTP
|
||||||
loginName={loginName}
|
loginName={loginName ?? session.factors?.user?.loginName}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
authRequestId={authRequestId}
|
authRequestId={authRequestId}
|
||||||
organization={organization}
|
organization={
|
||||||
|
organization ?? session?.factors?.user?.organizationId
|
||||||
|
}
|
||||||
method={method}
|
method={method}
|
||||||
loginSettings={loginSettings}
|
loginSettings={loginSettings}
|
||||||
host={host}
|
host={host}
|
||||||
|
@@ -32,7 +32,9 @@ export default async function Page(props: {
|
|||||||
sessionFactors?.factors?.user?.organizationId,
|
sessionFactors?.factors?.user?.organizationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loginSettings = await getLoginSettings(organization);
|
const loginSettings = await getLoginSettings(
|
||||||
|
sessionFactors?.factors?.user?.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTheme branding={branding}>
|
<DynamicTheme branding={branding}>
|
||||||
|
@@ -1,14 +1,17 @@
|
|||||||
import { getAllSessions } from "@/lib/cookies";
|
import { getAllSessions } from "@/lib/cookies";
|
||||||
import { idpTypeToSlug } from "@/lib/idp";
|
import { idpTypeToSlug } from "@/lib/idp";
|
||||||
|
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
|
||||||
import {
|
import {
|
||||||
createCallback,
|
createCallback,
|
||||||
getActiveIdentityProviders,
|
getActiveIdentityProviders,
|
||||||
getAuthRequest,
|
getAuthRequest,
|
||||||
|
getLoginSettings,
|
||||||
getOrgsByDomain,
|
getOrgsByDomain,
|
||||||
|
listAuthenticationMethodTypes,
|
||||||
listSessions,
|
listSessions,
|
||||||
startIdentityProviderFlow,
|
startIdentityProviderFlow,
|
||||||
} from "@/lib/zitadel";
|
} from "@/lib/zitadel";
|
||||||
import { create } from "@zitadel/client";
|
import { create, timestampDate } from "@zitadel/client";
|
||||||
import {
|
import {
|
||||||
AuthRequest,
|
AuthRequest,
|
||||||
Prompt,
|
Prompt,
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
SessionSchema,
|
SessionSchema,
|
||||||
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_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";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -36,23 +40,140 @@ 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 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:(.+)/;
|
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
|
||||||
|
|
||||||
function findSession(
|
/**
|
||||||
sessions: Session[],
|
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
|
||||||
authRequest: AuthRequest,
|
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
|
||||||
): Session | undefined {
|
**/
|
||||||
if (authRequest.hintUserId) {
|
async function isSessionValid(session: Session): Promise<boolean> {
|
||||||
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
|
// session can't be checked without user
|
||||||
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
|
if (!session.factors?.user) {
|
||||||
|
console.warn("Session has no user");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (authRequest.loginHint) {
|
|
||||||
console.log(`find session for loginHint: ${authRequest.loginHint}`);
|
let mfaValid = true;
|
||||||
return sessions.find(
|
|
||||||
(s) => s.factors?.user?.loginName === authRequest.loginHint,
|
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", 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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,22 +195,34 @@ export async function GET(request: NextRequest) {
|
|||||||
sessions = await loadSessions(ids);
|
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) {
|
if (authRequestId && sessionId) {
|
||||||
console.log(
|
console.log(
|
||||||
`Login with session: ${sessionId} and authRequest: ${authRequestId}`,
|
`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) {
|
if (selectedSession && selectedSession.id) {
|
||||||
console.log(`Found session ${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(
|
const cookie = sessionCookies.find(
|
||||||
(cookie) => cookie.id === selectedSession?.id,
|
(cookie) => cookie.id === selectedSession?.id,
|
||||||
);
|
);
|
||||||
@@ -119,8 +252,41 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return NextResponse.json({ error }, { status: 500 });
|
// 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,8 +391,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
|
if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) {
|
||||||
const registerUrl = new URL("/register", request.url);
|
const registerUrl = new URL("/register", request.url);
|
||||||
if (authRequest?.id) {
|
if (authRequest.id) {
|
||||||
registerUrl.searchParams.set("authRequestId", authRequest?.id);
|
registerUrl.searchParams.set("authRequestId", authRequest.id);
|
||||||
}
|
}
|
||||||
if (organization) {
|
if (organization) {
|
||||||
registerUrl.searchParams.set("organization", organization);
|
registerUrl.searchParams.set("organization", organization);
|
||||||
@@ -241,10 +407,36 @@ export async function GET(request: NextRequest) {
|
|||||||
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
|
if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) {
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
} else if (authRequest.prompt.includes(Prompt.LOGIN)) {
|
} 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);
|
const loginNameUrl = new URL("/loginname", request.url);
|
||||||
if (authRequest?.id) {
|
if (authRequest.id) {
|
||||||
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
|
loginNameUrl.searchParams.set("authRequestId", authRequest.id);
|
||||||
}
|
}
|
||||||
if (authRequest.loginHint) {
|
if (authRequest.loginHint) {
|
||||||
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
|
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
|
||||||
@@ -254,82 +446,87 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return NextResponse.redirect(loginNameUrl);
|
return NextResponse.redirect(loginNameUrl);
|
||||||
} else if (authRequest.prompt.includes(Prompt.NONE)) {
|
} 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) {
|
||||||
|
|
||||||
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 {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "No active session found" },
|
{ 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(
|
||||||
const cookie = sessionCookies.find(
|
(cookie) => cookie.id === selectedSession.id,
|
||||||
(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 = {
|
||||||
const session = {
|
sessionId: cookie.id,
|
||||||
sessionId: cookie?.id,
|
sessionToken: cookie.token,
|
||||||
sessionToken: cookie?.token,
|
};
|
||||||
};
|
|
||||||
try {
|
const { callbackUrl } = await createCallback(
|
||||||
const { callbackUrl } = await createCallback(
|
create(CreateCallbackRequestSchema, {
|
||||||
create(CreateCallbackRequestSchema, {
|
authRequestId,
|
||||||
authRequestId,
|
callbackKind: {
|
||||||
callbackKind: {
|
case: "session",
|
||||||
case: "session",
|
value: create(SessionSchema, session),
|
||||||
value: create(SessionSchema, session),
|
},
|
||||||
},
|
}),
|
||||||
}),
|
);
|
||||||
);
|
return NextResponse.redirect(callbackUrl);
|
||||||
if (callbackUrl) {
|
} else {
|
||||||
return NextResponse.redirect(callbackUrl);
|
// check for loginHint, userId hint and valid sessions
|
||||||
} else {
|
let selectedSession = await findValidSession(sessions, authRequest);
|
||||||
console.log(
|
|
||||||
"could not create callback, redirect user to choose other account",
|
if (!selectedSession || !selectedSession.id) {
|
||||||
);
|
return gotoAccounts();
|
||||||
return gotoAccounts();
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
const cookie = sessionCookies.find(
|
||||||
console.error(error);
|
(cookie) => cookie.id === selectedSession.id,
|
||||||
return gotoAccounts();
|
);
|
||||||
}
|
|
||||||
|
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 {
|
} else {
|
||||||
|
console.log(
|
||||||
|
"could not create callback, redirect user to choose other account",
|
||||||
|
);
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return gotoAccounts();
|
return gotoAccounts();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,30 +0,0 @@
|
|||||||
import { getAllSessions } from "@/lib/cookies";
|
|
||||||
import { listSessions } from "@/lib/zitadel";
|
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
async function loadSessions(ids: string[]): Promise<Session[]> {
|
|
||||||
const response = await listSessions(
|
|
||||||
ids.filter((id: string | undefined) => !!id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response?.sessions ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const sessionCookies = await getAllSessions();
|
|
||||||
const ids = sessionCookies.map((s) => s.id);
|
|
||||||
let sessions: Session[] = [];
|
|
||||||
if (ids && ids.length) {
|
|
||||||
sessions = await loadSessions(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
|
||||||
responseHeaders.set("Access-Control-Allow-Headers", "*");
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ sessions },
|
|
||||||
{ status: 200, headers: responseHeaders },
|
|
||||||
);
|
|
||||||
}
|
|
@@ -32,7 +32,7 @@ const LinkWrapper = ({
|
|||||||
|
|
||||||
export const TOTP = (alreadyAdded: boolean, link: string) => {
|
export const TOTP = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link} key={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
@@ -40,45 +40,12 @@ export const TOTP = (alreadyAdded: boolean, link: string) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-9 w-9 transform -translate-x-[2px] mr-4"
|
className="h-8 w-8 transform -translate-x-[2px] mr-4 fill-current text-black dark:text-white"
|
||||||
version="1.1"
|
|
||||||
baseProfile="basic"
|
|
||||||
id="Layer_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
viewBox="0 0 24 24"
|
||||||
x="0px"
|
|
||||||
y="0px"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
>
|
>
|
||||||
<path
|
<title>timer-lock-outline</title>
|
||||||
fill="#1A73E8"
|
<path d="M11 8H13V14H11V8M13 19.92C12.67 19.97 12.34 20 12 20C8.13 20 5 16.87 5 13S8.13 6 12 6C14.82 6 17.24 7.67 18.35 10.06C18.56 10.04 18.78 10 19 10C19.55 10 20.07 10.11 20.57 10.28C20.23 9.22 19.71 8.24 19.03 7.39L20.45 5.97C20 5.46 19.55 5 19.04 4.56L17.62 6C16.07 4.74 14.12 4 12 4C7.03 4 3 8.03 3 13S7.03 22 12 22C12.42 22 12.83 21.96 13.24 21.91C13.09 21.53 13 21.12 13 20.7V19.92M15 1H9V3H15V1M23 17.3V20.8C23 21.4 22.4 22 21.7 22H16.2C15.6 22 15 21.4 15 20.7V17.2C15 16.6 15.6 16 16.2 16V14.5C16.2 13.1 17.6 12 19 12S21.8 13.1 21.8 14.5V16C22.4 16 23 16.6 23 17.3M20.5 14.5C20.5 13.7 19.8 13.2 19 13.2S17.5 13.7 17.5 14.5V16H20.5V14.5Z" />
|
||||||
d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
|
||||||
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
|
||||||
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#EA4335"
|
|
||||||
d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
|
||||||
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
|
||||||
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
|
||||||
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#FBBC04"
|
|
||||||
d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
|
||||||
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
|
||||||
L256,193.98999z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="#34A853"
|
|
||||||
d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
|
||||||
C72,238.87917,85.87916,225,102.99997,225H248z"
|
|
||||||
/>
|
|
||||||
<polygon
|
|
||||||
fill="#185DB7"
|
|
||||||
points="309.70001,287 202.29999,287 256,193.98999 "
|
|
||||||
/>
|
|
||||||
</svg>{" "}
|
</svg>{" "}
|
||||||
<span>Authenticator App</span>
|
<span>Authenticator App</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +60,7 @@ C72,238.87917,85.87916,225,102.99997,225H248z"
|
|||||||
|
|
||||||
export const U2F = (alreadyAdded: boolean, link: string) => {
|
export const U2F = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
@@ -127,7 +94,7 @@ export const U2F = (alreadyAdded: boolean, link: string) => {
|
|||||||
|
|
||||||
export const EMAIL = (alreadyAdded: boolean, link: string) => {
|
export const EMAIL = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
@@ -162,7 +129,7 @@ export const EMAIL = (alreadyAdded: boolean, link: string) => {
|
|||||||
|
|
||||||
export const SMS = (alreadyAdded: boolean, link: string) => {
|
export const SMS = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
@@ -196,7 +163,7 @@ export const SMS = (alreadyAdded: boolean, link: string) => {
|
|||||||
|
|
||||||
export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
|
export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
@@ -230,7 +197,7 @@ export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
|
|||||||
|
|
||||||
export const PASSWORD = (alreadyAdded: boolean, link: string) => {
|
export const PASSWORD = (alreadyAdded: boolean, link: string) => {
|
||||||
return (
|
return (
|
||||||
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
|
<LinkWrapper key={link} alreadyAdded={alreadyAdded} link={link}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"font-medium flex items-center",
|
"font-medium flex items-center",
|
||||||
|
@@ -6,12 +6,15 @@ import {
|
|||||||
symbolValidator,
|
symbolValidator,
|
||||||
upperCaseValidator,
|
upperCaseValidator,
|
||||||
} from "@/helpers/validators";
|
} from "@/helpers/validators";
|
||||||
import { setMyPassword } from "@/lib/self";
|
import {
|
||||||
import { sendPassword } from "@/lib/server/password";
|
checkSessionAndSetPassword,
|
||||||
|
sendPassword,
|
||||||
|
} from "@/lib/server/password";
|
||||||
import { create } from "@zitadel/client";
|
import { create } from "@zitadel/client";
|
||||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldValues, useForm } from "react-hook-form";
|
import { FieldValues, useForm } from "react-hook-form";
|
||||||
import { Alert } from "./alert";
|
import { Alert } from "./alert";
|
||||||
@@ -44,6 +47,7 @@ export function ChangePasswordForm({
|
|||||||
organization,
|
organization,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("password");
|
const t = useTranslations("password");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
@@ -58,8 +62,9 @@ export function ChangePasswordForm({
|
|||||||
|
|
||||||
async function submitChange(values: Inputs) {
|
async function submitChange(values: Inputs) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const changeResponse = await setMyPassword({
|
|
||||||
sessionId: sessionId,
|
const changeResponse = checkSessionAndSetPassword({
|
||||||
|
sessionId,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -70,8 +75,12 @@ export function ChangePasswordForm({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changeResponse && "error" in changeResponse) {
|
if (changeResponse && "error" in changeResponse && changeResponse.error) {
|
||||||
setError(changeResponse.error);
|
setError(
|
||||||
|
typeof changeResponse.error === "string"
|
||||||
|
? changeResponse.error
|
||||||
|
: "Unknown error",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +116,14 @@ export function ChangePasswordForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
passwordResponse &&
|
||||||
|
"redirect" in passwordResponse &&
|
||||||
|
passwordResponse.redirect
|
||||||
|
) {
|
||||||
|
return router.push(passwordResponse.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -59,7 +59,7 @@ export function LoginOTP({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current && ["email", "sms"].includes(method)) {
|
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
updateSessionForOTPChallenge()
|
updateSessionForOTPChallenge()
|
||||||
@@ -84,7 +84,7 @@ export function LoginOTP({
|
|||||||
value: host
|
value: host
|
||||||
? {
|
? {
|
||||||
urlTemplate:
|
urlTemplate:
|
||||||
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/method=${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
`${host.includes("localhost") ? "http://" : "https://"}${host}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` +
|
||||||
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
@@ -95,7 +95,7 @@ export function LoginOTP({
|
|||||||
|
|
||||||
if (method === "sms") {
|
if (method === "sms") {
|
||||||
challenges = create(RequestChallengesSchema, {
|
challenges = create(RequestChallengesSchema, {
|
||||||
otpSms: { returnCode: true },
|
otpSms: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,11 @@ export function LoginOTP({
|
|||||||
|
|
||||||
function setCodeAndContinue(values: Inputs, organization?: string) {
|
function setCodeAndContinue(values: Inputs, organization?: string) {
|
||||||
return submitCode(values, organization).then(async (response) => {
|
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 =
|
const url =
|
||||||
authRequestId && response.sessionId
|
authRequestId && response.sessionId
|
||||||
? await getNextUrl(
|
? await getNextUrl(
|
||||||
@@ -203,6 +207,7 @@ export function LoginOTP({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
@@ -221,6 +226,7 @@ export function LoginOTP({
|
|||||||
<button
|
<button
|
||||||
aria-label="Resend OTP Code"
|
aria-label="Resend OTP Code"
|
||||||
disabled={loading}
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -233,6 +239,7 @@ export function LoginOTP({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
data-testid="resend-button"
|
||||||
>
|
>
|
||||||
{t("verify.resendCode")}
|
{t("verify.resendCode")}
|
||||||
</button>
|
</button>
|
||||||
@@ -245,11 +252,12 @@ export function LoginOTP({
|
|||||||
{...register("code", { required: "This field is required" })}
|
{...register("code", { required: "This field is required" })}
|
||||||
label="Code"
|
label="Code"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
data-testid="code-text-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="py-4">
|
<div className="py-4" data-testid="error">
|
||||||
<Alert>{error}</Alert>
|
<Alert>{error}</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@@ -134,6 +134,7 @@ export function PasswordForm({
|
|||||||
onClick={() => resetPasswordAndContinue()}
|
onClick={() => resetPasswordAndContinue()}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
data-testid="reset-button"
|
||||||
>
|
>
|
||||||
{t("verify.resetPassword")}
|
{t("verify.resetPassword")}
|
||||||
</button>
|
</button>
|
||||||
|
@@ -65,10 +65,14 @@ export function SessionItem({
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (valid && session?.factors?.user) {
|
if (valid && session?.factors?.user) {
|
||||||
return continueWithSession({
|
const resp = await continueWithSession({
|
||||||
...session,
|
...session,
|
||||||
authRequestId: authRequestId,
|
authRequestId: authRequestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp?.redirect) {
|
||||||
|
return router.push(resp.redirect);
|
||||||
|
}
|
||||||
} else if (session.factors?.user) {
|
} else if (session.factors?.user) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await sendLoginname({
|
const res = await sendLoginname({
|
||||||
@@ -114,11 +118,13 @@ export function SessionItem({
|
|||||||
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
{verifiedAt && moment(timestampDate(verifiedAt)).fromNow()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs opacity-80 text-ellipsis">
|
verifiedAt && (
|
||||||
expired{" "}
|
<span className="text-xs opacity-80 text-ellipsis">
|
||||||
{session.expirationDate &&
|
expired{" "}
|
||||||
moment(timestampDate(session.expirationDate)).fromNow()}
|
{session.expirationDate &&
|
||||||
</span>
|
moment(timestampDate(session.expirationDate)).fromNow()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,6 +140,7 @@ export function SessionItem({
|
|||||||
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
|
className="hidden group-hover:block h-5 w-5 transition-all opacity-50 hover:opacity-100"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
clearSession(session.id).then(() => {
|
clearSession(session.id).then(() => {
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { timestampMs } from "@zitadel/client";
|
import { timestampDate } from "@zitadel/client";
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,14 +12,6 @@ type Props = {
|
|||||||
authRequestId?: string;
|
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) {
|
export function SessionsList({ sessions, authRequestId }: Props) {
|
||||||
const t = useTranslations("accounts");
|
const t = useTranslations("accounts");
|
||||||
const [list, setList] = useState<Session[]>(sessions);
|
const [list, setList] = useState<Session[]>(sessions);
|
||||||
@@ -27,7 +19,17 @@ export function SessionsList({ sessions, authRequestId }: Props) {
|
|||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
{list
|
{list
|
||||||
.filter((session) => session?.factors?.user?.loginName)
|
.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) => {
|
.map((session, index) => {
|
||||||
return (
|
return (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
|
@@ -6,7 +6,11 @@ import {
|
|||||||
symbolValidator,
|
symbolValidator,
|
||||||
upperCaseValidator,
|
upperCaseValidator,
|
||||||
} from "@/helpers/validators";
|
} from "@/helpers/validators";
|
||||||
import { changePassword, sendPassword } from "@/lib/server/password";
|
import {
|
||||||
|
changePassword,
|
||||||
|
resetPassword,
|
||||||
|
sendPassword,
|
||||||
|
} from "@/lib/server/password";
|
||||||
import { create } from "@zitadel/client";
|
import { create } from "@zitadel/client";
|
||||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||||
@@ -14,7 +18,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldValues, useForm } from "react-hook-form";
|
import { FieldValues, useForm } from "react-hook-form";
|
||||||
import { Alert } from "./alert";
|
import { Alert, AlertType } from "./alert";
|
||||||
import { BackButton } from "./back-button";
|
import { BackButton } from "./back-button";
|
||||||
import { Button, ButtonVariants } from "./button";
|
import { Button, ButtonVariants } from "./button";
|
||||||
import { TextInput } from "./input";
|
import { TextInput } from "./input";
|
||||||
@@ -62,6 +66,29 @@ export function SetPasswordForm({
|
|||||||
|
|
||||||
const router = useRouter();
|
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) {
|
async function submitPassword(values: Inputs) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let payload: { userId: string; password: string; code?: string } = {
|
let payload: { userId: string; password: string; code?: string } = {
|
||||||
@@ -165,28 +192,42 @@ export function SetPasswordForm({
|
|||||||
<form className="w-full">
|
<form className="w-full">
|
||||||
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
|
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
|
||||||
{codeRequired && (
|
{codeRequired && (
|
||||||
<div className="flex flex-row items-end">
|
<Alert type={AlertType.INFO}>
|
||||||
<div className="flex-1">
|
<div className="flex flex-row">
|
||||||
<TextInput
|
<span className="flex-1 mr-auto text-left">
|
||||||
type="text"
|
{t("set.noCodeReceived")}
|
||||||
required
|
</span>
|
||||||
{...register("code", {
|
<button
|
||||||
required: "This field is required",
|
aria-label="Resend OTP Code"
|
||||||
})}
|
disabled={loading}
|
||||||
label="Code"
|
type="button"
|
||||||
autoComplete="one-time-code"
|
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"
|
||||||
error={errors.code?.message as string}
|
onClick={() => {
|
||||||
/>
|
resendCode();
|
||||||
</div>
|
}}
|
||||||
|
data-testid="resend-button"
|
||||||
<div className="ml-4 mb-1">
|
>
|
||||||
<Button variant={ButtonVariants.Secondary}>
|
|
||||||
{t("set.resend")}
|
{t("set.resend")}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{codeRequired && (
|
||||||
|
<div>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
{...register("code", {
|
||||||
|
required: "This field is required",
|
||||||
|
})}
|
||||||
|
label="Code"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
error={errors.code?.message as string}
|
||||||
|
data-testid="code-text-input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="">
|
<div>
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -196,9 +237,10 @@ export function SetPasswordForm({
|
|||||||
})}
|
})}
|
||||||
label="New Password"
|
label="New Password"
|
||||||
error={errors.password?.message as string}
|
error={errors.password?.message as string}
|
||||||
|
data-testid="password-text-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div>
|
||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
@@ -208,6 +250,7 @@ export function SetPasswordForm({
|
|||||||
})}
|
})}
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
error={errors.confirmPassword?.message as string}
|
error={errors.confirmPassword?.message as string}
|
||||||
|
data-testid="password-confirm-text-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -7,8 +7,9 @@ import {
|
|||||||
IdentityProviderType,
|
IdentityProviderType,
|
||||||
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useCallback, useState } from "react";
|
||||||
import { Alert } from "./alert";
|
import { Alert } from "./alert";
|
||||||
|
import { SignInWithIdentityProviderProps } from "./idps/base-button";
|
||||||
import { SignInWithApple } from "./idps/sign-in-with-apple";
|
import { SignInWithApple } from "./idps/sign-in-with-apple";
|
||||||
import { SignInWithAzureAd } from "./idps/sign-in-with-azure-ad";
|
import { SignInWithAzureAd } from "./idps/sign-in-with-azure-ad";
|
||||||
import { SignInWithGeneric } from "./idps/sign-in-with-generic";
|
import { SignInWithGeneric } from "./idps/sign-in-with-generic";
|
||||||
@@ -29,184 +30,79 @@ export function SignInWithIdp({
|
|||||||
authRequestId,
|
authRequestId,
|
||||||
organization,
|
organization,
|
||||||
linkOnly,
|
linkOnly,
|
||||||
}: SignInWithIDPProps) {
|
}: Readonly<SignInWithIDPProps>) {
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function startFlow(idpId: string, provider: string) {
|
const startFlow = useCallback(
|
||||||
setLoading(true);
|
async (idpId: string, provider: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (linkOnly) params.set("link", "true");
|
||||||
|
if (authRequestId) params.set("authRequestId", authRequestId);
|
||||||
|
if (organization) params.set("organization", organization);
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
try {
|
||||||
|
const response = await startIDPFlow({
|
||||||
|
idpId,
|
||||||
|
successUrl: `/idp/${provider}/success?` + params.toString(),
|
||||||
|
failureUrl: `/idp/${provider}/failure?` + params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
if (linkOnly) {
|
if (response && "error" in response && response?.error) {
|
||||||
params.set("link", "true");
|
setError(response.error);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authRequestId) {
|
if (response && "redirect" in response && response?.redirect) {
|
||||||
params.set("authRequestId", authRequestId);
|
return router.push(response.redirect);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
if (organization) {
|
|
||||||
params.set("organization", organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await startIDPFlow({
|
|
||||||
idpId,
|
|
||||||
successUrl: `/idp/${provider}/success?` + new URLSearchParams(params),
|
|
||||||
failureUrl: `/idp/${provider}/failure?` + new URLSearchParams(params),
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setError("Could not start IDP flow");
|
setError("Could not start IDP flow");
|
||||||
return;
|
} finally {
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
[authRequestId, organization, linkOnly, router],
|
||||||
|
);
|
||||||
|
|
||||||
if (response && "error" in response && response?.error) {
|
const renderIDPButton = (idp: IdentityProvider) => {
|
||||||
setError(response.error);
|
const { id, name, type } = idp;
|
||||||
return;
|
const onClick = () => startFlow(id, idpTypeToSlug(type));
|
||||||
}
|
/* - TODO: Implement after https://github.com/zitadel/zitadel/issues/8981 */
|
||||||
|
|
||||||
if (response && "redirect" in response && response?.redirect) {
|
// .filter((idp) =>
|
||||||
return router.push(response.redirect);
|
// linkOnly ? idp.config?.options.isLinkingAllowed : true,
|
||||||
}
|
// )
|
||||||
}
|
const components: Partial<
|
||||||
|
Record<
|
||||||
|
IdentityProviderType,
|
||||||
|
(props: SignInWithIdentityProviderProps) => ReactNode
|
||||||
|
>
|
||||||
|
> = {
|
||||||
|
[IdentityProviderType.APPLE]: SignInWithApple,
|
||||||
|
[IdentityProviderType.OAUTH]: SignInWithGeneric,
|
||||||
|
[IdentityProviderType.OIDC]: SignInWithGeneric,
|
||||||
|
[IdentityProviderType.GITHUB]: SignInWithGithub,
|
||||||
|
[IdentityProviderType.GITHUB_ES]: SignInWithGithub,
|
||||||
|
[IdentityProviderType.AZURE_AD]: SignInWithAzureAd,
|
||||||
|
[IdentityProviderType.GOOGLE]: (props) => (
|
||||||
|
<SignInWithGoogle {...props} e2e="google" />
|
||||||
|
),
|
||||||
|
[IdentityProviderType.GITLAB]: SignInWithGitlab,
|
||||||
|
[IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Component = components[type];
|
||||||
|
return Component ? (
|
||||||
|
<Component key={id} name={name} onClick={onClick} />
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full space-y-2 text-sm">
|
<div className="flex flex-col w-full space-y-2 text-sm">
|
||||||
{identityProviders &&
|
{identityProviders?.map(renderIDPButton)}
|
||||||
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 && (
|
{error && (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<Alert>{error}</Alert>
|
<Alert>{error}</Alert>
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert } from "@/components/alert";
|
import { Alert, AlertType } from "@/components/alert";
|
||||||
import { resendVerification, sendVerification } from "@/lib/server/email";
|
import { resendVerification, sendVerification } from "@/lib/server/email";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BackButton } from "./back-button";
|
||||||
import { Button, ButtonVariants } from "./button";
|
import { Button, ButtonVariants } from "./button";
|
||||||
import { TextInput } from "./input";
|
import { TextInput } from "./input";
|
||||||
import { Spinner } from "./spinner";
|
import { Spinner } from "./spinner";
|
||||||
@@ -96,7 +97,26 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className="w-full">
|
<form className="w-full">
|
||||||
<div className="">
|
<Alert type={AlertType.INFO}>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<span className="flex-1 mr-auto text-left">
|
||||||
|
{t("verify.noCodeReceived")}
|
||||||
|
</span>
|
||||||
|
<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={() => {
|
||||||
|
resendCode();
|
||||||
|
}}
|
||||||
|
data-testid="resend-button"
|
||||||
|
>
|
||||||
|
{t("verify.resendCode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
<div className="mt-4">
|
||||||
<TextInput
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
@@ -112,13 +132,7 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-row items-center">
|
<div className="mt-8 flex w-full flex-row items-center">
|
||||||
<Button
|
<BackButton />
|
||||||
type="button"
|
|
||||||
onClick={() => resendCode()}
|
|
||||||
variant={ButtonVariants.Secondary}
|
|
||||||
>
|
|
||||||
{t("verify.resendCode")}
|
|
||||||
</Button>
|
|
||||||
<span className="flex-grow"></span>
|
<span className="flex-grow"></span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@@ -1,12 +1,28 @@
|
|||||||
import { LANGUAGE_COOKIE_NAME } from "@/lib/i18n";
|
import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n";
|
||||||
import deepmerge from "deepmerge";
|
import deepmerge from "deepmerge";
|
||||||
import { getRequestConfig } from "next-intl/server";
|
import { getRequestConfig } from "next-intl/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
export default getRequestConfig(async () => {
|
export default getRequestConfig(async () => {
|
||||||
const fallback = "en";
|
const fallback = "en";
|
||||||
const cookiesList = await cookies();
|
const cookiesList = await cookies();
|
||||||
const locale: string = cookiesList.get(LANGUAGE_COOKIE_NAME)?.value ?? "en";
|
|
||||||
|
let locale: string = fallback;
|
||||||
|
|
||||||
|
const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME);
|
||||||
|
if (languageHeader) {
|
||||||
|
const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code
|
||||||
|
if (LANGS.map((l) => l.code).includes(headerLocale)) {
|
||||||
|
locale = headerLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME);
|
||||||
|
if (languageCookie && languageCookie.value) {
|
||||||
|
if (LANGS.map((l) => l.code).includes(languageCookie.value)) {
|
||||||
|
locale = languageCookie.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userMessages = (await import(`../../locales/${locale}.json`)).default;
|
const userMessages = (await import(`../../locales/${locale}.json`)).default;
|
||||||
const fallbackMessages = (await import(`../../locales/${fallback}.json`))
|
const fallbackMessages = (await import(`../../locales/${fallback}.json`))
|
||||||
|
@@ -20,6 +20,11 @@ export const LANGS: Lang[] = [
|
|||||||
name: "Español",
|
name: "Español",
|
||||||
code: "es",
|
code: "es",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "简体中文",
|
||||||
|
code: "zh",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE";
|
export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE";
|
||||||
|
export const LANGUAGE_HEADER_NAME = "accept-language";
|
||||||
|
@@ -1,9 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import {
|
import { createUserServiceClient } from "@zitadel/client/v2";
|
||||||
createSessionServiceClient,
|
|
||||||
createUserServiceClient,
|
|
||||||
} from "@zitadel/client/v2";
|
|
||||||
import { createServerTransport } from "@zitadel/node";
|
import { createServerTransport } from "@zitadel/node";
|
||||||
import { getSessionCookieById } from "./cookies";
|
import { getSessionCookieById } from "./cookies";
|
||||||
import { getSession } from "./zitadel";
|
import { getSession } from "./zitadel";
|
||||||
@@ -13,12 +10,6 @@ const transport = (token: string) =>
|
|||||||
baseUrl: process.env.ZITADEL_API_URL!,
|
baseUrl: process.env.ZITADEL_API_URL!,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionService = (sessionId: string) => {
|
|
||||||
return getSessionCookieById({ sessionId }).then((session) => {
|
|
||||||
return createSessionServiceClient(transport(session.token));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const myUserService = (sessionToken: string) => {
|
const myUserService = (sessionToken: string) => {
|
||||||
return createUserServiceClient(transport(sessionToken));
|
return createUserServiceClient(transport(sessionToken));
|
||||||
};
|
};
|
||||||
@@ -41,7 +32,7 @@ export async function setMyPassword({
|
|||||||
return { error: "Could not load session" };
|
return { error: "Could not load session" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = await myUserService(sessionCookie.token);
|
const service = await myUserService(`${sessionCookie.token}`);
|
||||||
|
|
||||||
if (!session?.factors?.user?.id) {
|
if (!session?.factors?.user?.id) {
|
||||||
return { error: "No user id found in session" };
|
return { error: "No user id found in session" };
|
||||||
@@ -56,6 +47,7 @@ export async function setMyPassword({
|
|||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
if (error.code === 7) {
|
if (error.code === 7) {
|
||||||
return { error: "Session is not valid." };
|
return { error: "Session is not valid." };
|
||||||
}
|
}
|
||||||
|
@@ -84,8 +84,8 @@ type resendVerifyEmailCommand = {
|
|||||||
|
|
||||||
export async function resendVerification(command: resendVerifyEmailCommand) {
|
export async function resendVerification(command: resendVerifyEmailCommand) {
|
||||||
return command.isInvite
|
return command.isInvite
|
||||||
? resendEmailCode(command.userId)
|
? resendInviteCode(command.userId)
|
||||||
: resendInviteCode(command.userId);
|
: resendEmailCode(command.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendVerificationRedirectWithoutCheck(command: {
|
export async function sendVerificationRedirectWithoutCheck(command: {
|
||||||
|
@@ -6,23 +6,30 @@ import {
|
|||||||
} from "@/lib/server/cookie";
|
} from "@/lib/server/cookie";
|
||||||
import {
|
import {
|
||||||
getLoginSettings,
|
getLoginSettings,
|
||||||
|
getSession,
|
||||||
getUserByID,
|
getUserByID,
|
||||||
listAuthenticationMethodTypes,
|
listAuthenticationMethodTypes,
|
||||||
listUsers,
|
listUsers,
|
||||||
passwordReset,
|
passwordReset,
|
||||||
setPassword,
|
setPassword,
|
||||||
|
setUserPassword,
|
||||||
} from "@/lib/zitadel";
|
} from "@/lib/zitadel";
|
||||||
import { create } from "@zitadel/client";
|
import { create } from "@zitadel/client";
|
||||||
|
import { createUserServiceClient } from "@zitadel/client/v2";
|
||||||
|
import { createServerTransport } from "@zitadel/node";
|
||||||
import {
|
import {
|
||||||
Checks,
|
Checks,
|
||||||
ChecksSchema,
|
ChecksSchema,
|
||||||
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||||
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
import {
|
||||||
|
AuthenticationMethodType,
|
||||||
|
SetPasswordRequestSchema,
|
||||||
|
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getNextUrl } from "../client";
|
import { getNextUrl } from "../client";
|
||||||
import { getSessionCookieByLoginName } from "../cookies";
|
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
|
||||||
|
|
||||||
type ResetPasswordCommand = {
|
type ResetPasswordCommand = {
|
||||||
loginName: string;
|
loginName: string;
|
||||||
@@ -142,18 +149,10 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
|||||||
return { error: "Could not verify password!" };
|
return { error: "Could not verify password!" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableSecondFactors = authMethods?.filter(
|
|
||||||
(m: AuthenticationMethodType) =>
|
|
||||||
m !== AuthenticationMethodType.PASSWORD &&
|
|
||||||
m !== AuthenticationMethodType.PASSKEY,
|
|
||||||
);
|
|
||||||
|
|
||||||
const humanUser = user.type.case === "human" ? user.type.value : undefined;
|
const humanUser = user.type.case === "human" ? user.type.value : undefined;
|
||||||
|
|
||||||
if (
|
// check if the user has to change password first
|
||||||
availableSecondFactors?.length == 0 &&
|
if (humanUser?.passwordChangeRequired) {
|
||||||
humanUser?.passwordChangeRequired
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
loginName: session.factors?.user?.loginName,
|
loginName: session.factors?.user?.loginName,
|
||||||
});
|
});
|
||||||
@@ -169,7 +168,13 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
|||||||
return { redirect: "/password/change?" + params };
|
return { redirect: "/password/change?" + params };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableSecondFactors?.length == 1) {
|
const availableMultiFactors = authMethods?.filter(
|
||||||
|
(m: AuthenticationMethodType) =>
|
||||||
|
m !== AuthenticationMethodType.PASSWORD &&
|
||||||
|
m !== AuthenticationMethodType.PASSKEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableMultiFactors?.length == 1) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
loginName: session.factors?.user.loginName,
|
loginName: session.factors?.user.loginName,
|
||||||
});
|
});
|
||||||
@@ -185,7 +190,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const factor = availableSecondFactors[0];
|
const factor = availableMultiFactors[0];
|
||||||
// if passwordless is other method, but user selected password as alternative, perform a login
|
// if passwordless is other method, but user selected password as alternative, perform a login
|
||||||
if (factor === AuthenticationMethodType.TOTP) {
|
if (factor === AuthenticationMethodType.TOTP) {
|
||||||
return { redirect: `/otp/time-based?` + params };
|
return { redirect: `/otp/time-based?` + params };
|
||||||
@@ -196,7 +201,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
|||||||
} else if (factor === AuthenticationMethodType.U2F) {
|
} else if (factor === AuthenticationMethodType.U2F) {
|
||||||
return { redirect: `/u2f?` + params };
|
return { redirect: `/u2f?` + params };
|
||||||
}
|
}
|
||||||
} else if (availableSecondFactors?.length >= 1) {
|
} else if (availableMultiFactors?.length >= 1) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
loginName: session.factors.user.loginName,
|
loginName: session.factors.user.loginName,
|
||||||
});
|
});
|
||||||
@@ -219,7 +224,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
|
|||||||
return { error: "Initial User not supported" };
|
return { error: "Initial User not supported" };
|
||||||
} else if (
|
} else if (
|
||||||
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
|
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
|
||||||
!availableSecondFactors.length
|
!availableMultiFactors.length
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
loginName: session.factors.user.loginName,
|
loginName: session.factors.user.loginName,
|
||||||
@@ -302,5 +307,99 @@ export async function changePassword(command: {
|
|||||||
}
|
}
|
||||||
const userId = user.userId;
|
const userId = user.userId;
|
||||||
|
|
||||||
return setPassword(userId, command.password, user, command.code);
|
return setUserPassword(userId, command.password, user, command.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckSessionAndSetPasswordCommand = {
|
||||||
|
sessionId: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function checkSessionAndSetPassword({
|
||||||
|
sessionId,
|
||||||
|
password,
|
||||||
|
}: CheckSessionAndSetPasswordCommand) {
|
||||||
|
const sessionCookie = await getSessionCookieById({ sessionId });
|
||||||
|
|
||||||
|
const { session } = await getSession({
|
||||||
|
sessionId: sessionCookie.id,
|
||||||
|
sessionToken: sessionCookie.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || !session.factors?.user?.id) {
|
||||||
|
return { error: "Could not load session" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = create(SetPasswordRequestSchema, {
|
||||||
|
userId: session.factors.user.id,
|
||||||
|
newPassword: {
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if the user has no password set in order to set a password
|
||||||
|
const authmethods = await listAuthenticationMethodTypes(
|
||||||
|
session.factors.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authmethods) {
|
||||||
|
return { error: "Could not load auth methods" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredAuthMethodsForForceMFA = [
|
||||||
|
AuthenticationMethodType.OTP_EMAIL,
|
||||||
|
AuthenticationMethodType.OTP_SMS,
|
||||||
|
AuthenticationMethodType.TOTP,
|
||||||
|
AuthenticationMethodType.U2F,
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every(
|
||||||
|
(method) => !authmethods.authMethodTypes.includes(method),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginSettings = await getLoginSettings(
|
||||||
|
session.factors.user.organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const forceMfa = !!(
|
||||||
|
loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly
|
||||||
|
);
|
||||||
|
|
||||||
|
// if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user
|
||||||
|
if (forceMfa && hasNoMFAMethods) {
|
||||||
|
return setPassword(payload).catch((error) => {
|
||||||
|
// throw error if failed precondition (ex. User is not yet initialized)
|
||||||
|
if (error.code === 9 && error.message) {
|
||||||
|
return { error: "Failed precondition" };
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const myUserService = (sessionToken: string) => {
|
||||||
|
return createUserServiceClient(
|
||||||
|
createServerTransport(sessionToken, {
|
||||||
|
baseUrl: process.env.ZITADEL_API_URL!,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selfService = await myUserService(`${sessionCookie.token}`);
|
||||||
|
|
||||||
|
return selfService
|
||||||
|
.setPassword(
|
||||||
|
{
|
||||||
|
userId: session.factors.user.id,
|
||||||
|
newPassword: { password, changeRequired: false },
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
if (error.code === 7) {
|
||||||
|
return { error: "Session is not valid." };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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 { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getNextUrl } from "../client";
|
import { getNextUrl } from "../client";
|
||||||
import {
|
import {
|
||||||
getMostRecentSessionCookie,
|
getMostRecentSessionCookie,
|
||||||
@@ -108,7 +107,7 @@ export async function continueWithSession({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
if (url) {
|
if (url) {
|
||||||
return redirect(url);
|
return { redirect: url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -32,23 +32,25 @@ export async function addU2F(command: RegisterU2FCommand) {
|
|||||||
sessionToken: sessionCookie.token,
|
sessionToken: sessionCookie.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const domain = (await headers()).get("host");
|
const host = (await headers()).get("host");
|
||||||
|
|
||||||
if (!domain) {
|
if (!host) {
|
||||||
return { error: "Could not get domain" };
|
return { error: "Could not get domain" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [hostname, port] = host.split(":");
|
||||||
|
|
||||||
|
if (!hostname) {
|
||||||
|
throw new Error("Could not get hostname");
|
||||||
|
}
|
||||||
|
|
||||||
const userId = session?.session?.factors?.user?.id;
|
const userId = session?.session?.factors?.user?.id;
|
||||||
|
|
||||||
if (!session || !userId) {
|
if (!session || !userId) {
|
||||||
return { error: "Could not get session" };
|
return { error: "Could not get session" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return registerU2F(
|
return registerU2F(userId, hostname);
|
||||||
userId,
|
|
||||||
domain,
|
|
||||||
// sessionCookie.token
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyU2F(command: VerifyU2FCommand) {
|
export async function verifyU2F(command: VerifyU2FCommand) {
|
||||||
|
@@ -13,6 +13,7 @@ import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
|||||||
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
|
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
|
||||||
import {
|
import {
|
||||||
RetrieveIdentityProviderIntentRequest,
|
RetrieveIdentityProviderIntentRequest,
|
||||||
|
SetPasswordRequest,
|
||||||
SetPasswordRequestSchema,
|
SetPasswordRequestSchema,
|
||||||
VerifyPasskeyRegistrationRequest,
|
VerifyPasskeyRegistrationRequest,
|
||||||
VerifyU2FRegistrationRequest,
|
VerifyU2FRegistrationRequest,
|
||||||
@@ -445,11 +446,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) {
|
export async function resendEmailCode(userId: string) {
|
||||||
return userService.resendEmailCode(
|
return userService.resendEmailCode(
|
||||||
{
|
{
|
||||||
@@ -541,7 +537,7 @@ export async function passwordReset(
|
|||||||
* @param code optional if the password should be set with a code (reset), no code for initial setup of password
|
* @param code optional if the password should be set with a code (reset), no code for initial setup of password
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function setPassword(
|
export async function setUserPassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
password: string,
|
password: string,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -587,6 +583,10 @@ export async function setPassword(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setPassword(payload: SetPasswordRequest) {
|
||||||
|
return userService.setPassword(payload, {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param server
|
* @param server
|
||||||
@@ -620,17 +620,7 @@ export async function createPasskeyRegistrationLink(
|
|||||||
* @returns the newly set email
|
* @returns the newly set email
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// TODO check for token requirements!
|
export async function registerU2F(userId: string, domain: string) {
|
||||||
export async function registerU2F(
|
|
||||||
userId: string,
|
|
||||||
domain: string,
|
|
||||||
// token: string,
|
|
||||||
) {
|
|
||||||
// const transport = createServerTransport(token, {
|
|
||||||
// baseUrl: process.env.ZITADEL_API_URL!,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const service = createUserServiceClient(transport);
|
|
||||||
return userService.registerU2F({
|
return userService.registerU2F({
|
||||||
userId,
|
userId,
|
||||||
domain,
|
domain,
|
||||||
|
@@ -23,7 +23,9 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"release": "turbo run build --filter=login^... && changeset publish",
|
"release": "turbo run build --filter=login^... && changeset publish",
|
||||||
"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": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -33,6 +35,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@changesets/cli": "^2.27.9",
|
||||||
"@playwright/test": "^1.48.2",
|
"@playwright/test": "^1.48.2",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
|
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -16,6 +16,18 @@ importers:
|
|||||||
'@changesets/cli':
|
'@changesets/cli':
|
||||||
specifier: ^2.27.9
|
specifier: ^2.27.9
|
||||||
version: 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':
|
'@playwright/test':
|
||||||
specifier: ^1.48.2
|
specifier: ^1.48.2
|
||||||
version: 1.48.2
|
version: 1.48.2
|
||||||
@@ -919,6 +931,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
|
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
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':
|
'@floating-ui/core@1.6.8':
|
||||||
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
|
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
|
||||||
|
|
||||||
@@ -1211,6 +1227,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@parcel/watcher-android-arm64@2.5.0':
|
||||||
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
|
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -4376,6 +4401,10 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
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:
|
throttleit@1.0.1:
|
||||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||||
|
|
||||||
@@ -5438,6 +5467,8 @@ snapshots:
|
|||||||
|
|
||||||
'@eslint/js@8.57.1': {}
|
'@eslint/js@8.57.1': {}
|
||||||
|
|
||||||
|
'@faker-js/faker@9.2.0': {}
|
||||||
|
|
||||||
'@floating-ui/core@1.6.8':
|
'@floating-ui/core@1.6.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.8
|
'@floating-ui/utils': 0.2.8
|
||||||
@@ -5717,6 +5748,17 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@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':
|
'@parcel/watcher-android-arm64@2.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -9136,6 +9178,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
thirty-two@1.0.2: {}
|
||||||
|
|
||||||
throttleit@1.0.1: {}
|
throttleit@1.0.1: {}
|
||||||
|
|
||||||
through@2.3.8: {}
|
through@2.3.8: {}
|
||||||
|
Reference in New Issue
Block a user