mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 20:47:32 +00:00
Merge branch 'main' into login-eslint
This commit is contained in:
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
|||||||
actions: write
|
actions: write
|
||||||
id-token: write
|
id-token: write
|
||||||
with:
|
with:
|
||||||
ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' }}
|
ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' || fromJSON(github.run_attempt) > 1 }}
|
||||||
node_version: "20"
|
node_version: "20"
|
||||||
|
|
||||||
container:
|
container:
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
with:
|
with:
|
||||||
login_build_image_name: "ghcr.io/zitadel/login-build"
|
login_build_image_name: "ghcr.io/zitadel/zitadel-login-build"
|
||||||
node_version: "20"
|
node_version: "20"
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
@@ -133,5 +133,5 @@ jobs:
|
|||||||
image_name: "ghcr.io/zitadel/zitadel"
|
image_name: "ghcr.io/zitadel/zitadel"
|
||||||
google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel"
|
google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel"
|
||||||
build_image_name_login: ${{ needs.login-container.outputs.login_build_image }}
|
build_image_name_login: ${{ needs.login-container.outputs.login_build_image }}
|
||||||
image_name_login: "ghcr.io/zitadel/login"
|
image_name_login: "ghcr.io/zitadel/zitadel-login"
|
||||||
google_image_name_login: europe-docker.pkg.dev/zitadel-common/zitadel-repo/login
|
google_image_name_login: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel-login"
|
||||||
|
11
.github/workflows/login-container.yml
vendored
11
.github/workflows/login-container.yml
vendored
@@ -22,6 +22,7 @@ env:
|
|||||||
default_labels: |
|
default_labels: |
|
||||||
org.opencontainers.image.documentation=https://zitadel.com/docs
|
org.opencontainers.image.documentation=https://zitadel.com/docs
|
||||||
org.opencontainers.image.vendor=CAOS AG
|
org.opencontainers.image.vendor=CAOS AG
|
||||||
|
org.opencontainers.image.licenses=MIT
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
login-container:
|
login-container:
|
||||||
@@ -29,6 +30,7 @@ jobs:
|
|||||||
runs-on: depot-ubuntu-22.04-8
|
runs-on: depot-ubuntu-22.04-8
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: depot/setup-action@v1
|
- uses: depot/setup-action@v1
|
||||||
@@ -40,6 +42,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ inputs.login_build_image_name }}
|
images: ${{ inputs.login_build_image_name }}
|
||||||
labels: ${{ env.default_labels}}
|
labels: ${{ env.default_labels}}
|
||||||
|
annotations: |
|
||||||
|
manifest:org.opencontainers.image.licenses=MIT
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,prefix=,suffix=,format=long
|
type=sha,prefix=,suffix=,format=long
|
||||||
- name: Login to Docker registry
|
- name: Login to Docker registry
|
||||||
@@ -53,11 +57,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_VERSION: ${{ inputs.node_version }}
|
NODE_VERSION: ${{ inputs.node_version }}
|
||||||
with:
|
with:
|
||||||
workdir: login
|
|
||||||
push: true
|
push: true
|
||||||
|
provenance: true
|
||||||
|
sbom: true
|
||||||
targets: login-standalone
|
targets: login-standalone
|
||||||
set: login-standalone.platforms=[linux/amd64,linux/arm64]
|
set: login-*.context=./login/
|
||||||
project: w47wkxzdtw
|
project: w47wkxzdtw
|
||||||
files: |
|
files: |
|
||||||
|
./login/docker-bake.hcl
|
||||||
|
./login/docker-bake-release.hcl
|
||||||
./docker-bake.hcl
|
./docker-bake.hcl
|
||||||
cwd://${{ steps.login-meta.outputs.bake-file }}
|
cwd://${{ steps.login-meta.outputs.bake-file }}
|
||||||
|
@@ -14,7 +14,7 @@ export GID := $(id -g)
|
|||||||
export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance
|
export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance
|
||||||
|
|
||||||
export DOCKER_METADATA_OUTPUT_VERSION ?= local
|
export DOCKER_METADATA_OUTPUT_VERSION ?= local
|
||||||
export LOGIN_TAG ?= login:${DOCKER_METADATA_OUTPUT_VERSION}
|
export LOGIN_TAG ?= zitadel-login:${DOCKER_METADATA_OUTPUT_VERSION}
|
||||||
export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION}
|
export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION}
|
||||||
export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION}
|
export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION}
|
||||||
export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION}
|
export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION}
|
||||||
|
@@ -16,7 +16,7 @@ services:
|
|||||||
ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik
|
ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik
|
||||||
|
|
||||||
login:
|
login:
|
||||||
image: "${LOGIN_TAG:-login:local}"
|
image: "${LOGIN_TAG:-zitadel-login:local}"
|
||||||
container_name: acceptance-login
|
container_name: acceptance-login
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
@@ -61,7 +61,7 @@ export default async function Page(props: {
|
|||||||
return (
|
return (
|
||||||
<DynamicTheme branding={branding}>
|
<DynamicTheme branding={branding}>
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<h1 data-i18n-key="error.tryagain">
|
<h1>
|
||||||
<Translated i18nKey="title" namespace="loginname" />
|
<Translated i18nKey="title" namespace="loginname" />
|
||||||
</h1>
|
</h1>
|
||||||
<p className="ztdl-p">
|
<p className="ztdl-p">
|
||||||
|
@@ -291,23 +291,25 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const paramsPassword: any = {
|
const paramsPassword = new URLSearchParams({
|
||||||
loginName: session.factors?.user?.loginName,
|
loginName: session.factors?.user?.loginName,
|
||||||
};
|
});
|
||||||
|
|
||||||
// TODO: does this have to be checked in loginSettings.allowDomainDiscovery
|
// TODO: does this have to be checked in loginSettings.allowDomainDiscovery
|
||||||
|
|
||||||
if (command.organization || session.factors?.user?.organizationId) {
|
if (command.organization || session.factors?.user?.organizationId) {
|
||||||
paramsPassword.organization =
|
paramsPassword.append(
|
||||||
command.organization ?? session.factors?.user?.organizationId;
|
"organization",
|
||||||
|
command.organization ?? session.factors?.user?.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.requestId) {
|
if (command.requestId) {
|
||||||
paramsPassword.requestId = command.requestId;
|
paramsPassword.append("requestId", command.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: "/password?" + new URLSearchParams(paramsPassword),
|
redirect: "/password?" + paramsPassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
|
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
|
||||||
@@ -318,36 +320,42 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const paramsPasskey: any = { loginName: command.loginName };
|
const paramsPasskey = new URLSearchParams({
|
||||||
|
loginName: session.factors?.user?.loginName,
|
||||||
|
});
|
||||||
if (command.requestId) {
|
if (command.requestId) {
|
||||||
paramsPasskey.requestId = command.requestId;
|
paramsPasskey.append("requestId", command.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.organization || session.factors?.user?.organizationId) {
|
if (command.organization || session.factors?.user?.organizationId) {
|
||||||
paramsPasskey.organization =
|
paramsPasskey.append(
|
||||||
command.organization ?? session.factors?.user?.organizationId;
|
"organization",
|
||||||
|
command.organization ?? session.factors?.user?.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { redirect: "/passkey?" + new URLSearchParams(paramsPasskey) };
|
return { redirect: "/passkey?" + paramsPasskey };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// prefer passkey in favor of other methods
|
// prefer passkey in favor of other methods
|
||||||
if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) {
|
if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) {
|
||||||
const passkeyParams: any = {
|
const passkeyParams = new URLSearchParams({
|
||||||
loginName: command.loginName,
|
loginName: session.factors?.user?.loginName,
|
||||||
altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option
|
altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option
|
||||||
};
|
});
|
||||||
|
|
||||||
if (command.requestId) {
|
if (command.requestId) {
|
||||||
passkeyParams.requestId = command.requestId;
|
passkeyParams.append("requestId", command.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.organization || session.factors?.user?.organizationId) {
|
if (command.organization || session.factors?.user?.organizationId) {
|
||||||
passkeyParams.organization =
|
passkeyParams.append(
|
||||||
command.organization ?? session.factors?.user?.organizationId;
|
"organization",
|
||||||
|
command.organization ?? session.factors?.user?.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { redirect: "/passkey?" + new URLSearchParams(passkeyParams) };
|
return { redirect: "/passkey?" + passkeyParams };
|
||||||
} else if (
|
} else if (
|
||||||
methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
|
methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
|
||||||
) {
|
) {
|
||||||
@@ -356,19 +364,23 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
|||||||
methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)
|
methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)
|
||||||
) {
|
) {
|
||||||
// user has no passkey setup and login settings allow passkeys
|
// user has no passkey setup and login settings allow passkeys
|
||||||
const paramsPasswordDefault: any = { loginName: command.loginName };
|
const paramsPasswordDefault = new URLSearchParams({
|
||||||
|
loginName: session.factors?.user?.loginName,
|
||||||
|
});
|
||||||
|
|
||||||
if (command.requestId) {
|
if (command.requestId) {
|
||||||
paramsPasswordDefault.requestId = command.requestId;
|
paramsPasswordDefault.append("requestId", command.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.organization || session.factors?.user?.organizationId) {
|
if (command.organization || session.factors?.user?.organizationId) {
|
||||||
paramsPasswordDefault.organization =
|
paramsPasswordDefault.append(
|
||||||
command.organization ?? session.factors?.user?.organizationId;
|
"organization",
|
||||||
|
command.organization ?? session.factors?.user?.organizationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: "/password?" + new URLSearchParams(paramsPasswordDefault),
|
redirect: "/password?" + paramsPasswordDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,7 +13,6 @@ import {
|
|||||||
|
|
||||||
type LoadMostRecentSessionParams = {
|
type LoadMostRecentSessionParams = {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
|
|
||||||
sessionParams: {
|
sessionParams: {
|
||||||
loginName?: string;
|
loginName?: string;
|
||||||
organization?: string;
|
organization?: string;
|
||||||
|
@@ -854,15 +854,15 @@ export async function searchUsers({
|
|||||||
const emailQuery = EmailQuery(searchValue);
|
const emailQuery = EmailQuery(searchValue);
|
||||||
emailAndPhoneQueries.push(emailQuery);
|
emailAndPhoneQueries.push(emailQuery);
|
||||||
} else {
|
} else {
|
||||||
const emailAndPhoneOrQueries: SearchQuery[] = [];
|
const orQuery: SearchQuery[] = [];
|
||||||
|
|
||||||
const emailQuery = EmailQuery(searchValue);
|
const emailQuery = EmailQuery(searchValue);
|
||||||
emailAndPhoneOrQueries.push(emailQuery);
|
orQuery.push(emailQuery);
|
||||||
|
|
||||||
let phoneQuery;
|
let phoneQuery;
|
||||||
if (searchValue.length <= 20) {
|
if (searchValue.length <= 20) {
|
||||||
phoneQuery = PhoneQuery(searchValue);
|
phoneQuery = PhoneQuery(searchValue);
|
||||||
emailAndPhoneOrQueries.push(phoneQuery);
|
orQuery.push(phoneQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
emailAndPhoneQueries.push(
|
emailAndPhoneQueries.push(
|
||||||
@@ -870,7 +870,7 @@ export async function searchUsers({
|
|||||||
query: {
|
query: {
|
||||||
case: "orQuery",
|
case: "orQuery",
|
||||||
value: {
|
value: {
|
||||||
queries: emailAndPhoneOrQueries,
|
queries: orQuery,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -903,7 +903,7 @@ export async function searchUsers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (emailOrPhoneResult.result.length == 1) {
|
if (emailOrPhoneResult.result.length == 1) {
|
||||||
return loginNameResult;
|
return emailOrPhoneResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: "User not found in the system" };
|
return { error: "User not found in the system" };
|
||||||
|
3
login/docker-bake-release.hcl
Normal file
3
login/docker-bake-release.hcl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
target "release" {
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
}
|
@@ -6,12 +6,18 @@ variable "DOCKERFILES_DIR" {
|
|||||||
default = "dockerfiles/"
|
default = "dockerfiles/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The release target is overwritten in docker-bake-release.hcl
|
||||||
|
# It makes sure the image is built for multiple platforms.
|
||||||
|
# By default the platforms property is empty, so images are only built for the current bake runtime platform.
|
||||||
|
target "release" {}
|
||||||
|
|
||||||
# typescript-proto-client is used to generate the client code for the login service.
|
# typescript-proto-client is used to generate the client code for the login service.
|
||||||
# It is not login-prefixed, so it is easily extendable.
|
# It is not login-prefixed, so it is easily extendable.
|
||||||
# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory.
|
# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory.
|
||||||
# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/
|
# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/
|
||||||
# The zitadel repository uses this to generate the client and the mock server from local proto files.
|
# The zitadel repository uses this to generate the client and the mock server from local proto files.
|
||||||
target "typescript-proto-client" {
|
target "typescript-proto-client" {
|
||||||
|
inherits = ["release"]
|
||||||
dockerfile = "${DOCKERFILES_DIR}typescript-proto-client.Dockerfile"
|
dockerfile = "${DOCKERFILES_DIR}typescript-proto-client.Dockerfile"
|
||||||
contexts = {
|
contexts = {
|
||||||
# We directly generate and download the client server-side with buf, so we don't need the proto files
|
# We directly generate and download the client server-side with buf, so we don't need the proto files
|
||||||
@@ -37,6 +43,7 @@ target "login-typescript-proto-client-out" {
|
|||||||
# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/
|
# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/
|
||||||
# The zitadel repository uses this to generate the client and the mock server from local proto files.
|
# The zitadel repository uses this to generate the client and the mock server from local proto files.
|
||||||
target "proto-files" {
|
target "proto-files" {
|
||||||
|
inherits = ["release"]
|
||||||
dockerfile = "${DOCKERFILES_DIR}proto-files.Dockerfile"
|
dockerfile = "${DOCKERFILES_DIR}proto-files.Dockerfile"
|
||||||
contexts = {
|
contexts = {
|
||||||
login-pnpm = "target:login-pnpm"
|
login-pnpm = "target:login-pnpm"
|
||||||
@@ -48,6 +55,7 @@ variable "NODE_VERSION" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "login-pnpm" {
|
target "login-pnpm" {
|
||||||
|
inherits = ["release"]
|
||||||
dockerfile = "${DOCKERFILES_DIR}login-pnpm.Dockerfile"
|
dockerfile = "${DOCKERFILES_DIR}login-pnpm.Dockerfile"
|
||||||
args = {
|
args = {
|
||||||
NODE_VERSION = "${NODE_VERSION}"
|
NODE_VERSION = "${NODE_VERSION}"
|
||||||
@@ -76,6 +84,7 @@ target "login-test-unit" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target "login-client" {
|
target "login-client" {
|
||||||
|
inherits = ["release"]
|
||||||
dockerfile = "${DOCKERFILES_DIR}login-client.Dockerfile"
|
dockerfile = "${DOCKERFILES_DIR}login-client.Dockerfile"
|
||||||
contexts = {
|
contexts = {
|
||||||
login-pnpm = "target:login-pnpm"
|
login-pnpm = "target:login-pnpm"
|
||||||
@@ -93,7 +102,7 @@ target "core-mock" {
|
|||||||
contexts = {
|
contexts = {
|
||||||
protos = "target:proto-files"
|
protos = "target:proto-files"
|
||||||
}
|
}
|
||||||
tags = ["${LOGIN_CORE_MOCK_TAG}"]
|
tags = ["${LOGIN_CORE_MOCK_TAG}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "LOGIN_TEST_INTEGRATION_TAG" {
|
variable "LOGIN_TEST_INTEGRATION_TAG" {
|
||||||
@@ -105,7 +114,7 @@ target "login-test-integration" {
|
|||||||
contexts = {
|
contexts = {
|
||||||
login-pnpm = "target:login-pnpm"
|
login-pnpm = "target:login-pnpm"
|
||||||
}
|
}
|
||||||
tags = ["${LOGIN_TEST_INTEGRATION_TAG}"]
|
tags = ["${LOGIN_TEST_INTEGRATION_TAG}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "LOGIN_TEST_ACCEPTANCE_TAG" {
|
variable "LOGIN_TEST_ACCEPTANCE_TAG" {
|
||||||
@@ -117,28 +126,33 @@ target "login-test-acceptance" {
|
|||||||
contexts = {
|
contexts = {
|
||||||
login-pnpm = "target:login-pnpm"
|
login-pnpm = "target:login-pnpm"
|
||||||
}
|
}
|
||||||
tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"]
|
tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "LOGIN_TAG" {
|
variable "LOGIN_TAG" {
|
||||||
default = "zitadel-login:local"
|
default = "zitadel-login:local"
|
||||||
}
|
}
|
||||||
|
|
||||||
target "docker-metadata-action" {}
|
target "docker-metadata-action" {
|
||||||
|
# In the pipeline, this target is overwritten by the docker metadata action.
|
||||||
|
tags = ["${LOGIN_TAG}"]
|
||||||
|
}
|
||||||
|
|
||||||
# We run integration and acceptance tests against the next standalone server for docker.
|
# We run integration and acceptance tests against the next standalone server for docker.
|
||||||
target "login-standalone" {
|
target "login-standalone" {
|
||||||
inherits = ["docker-metadata-action"]
|
inherits = [
|
||||||
|
"docker-metadata-action",
|
||||||
|
"release",
|
||||||
|
]
|
||||||
dockerfile = "${DOCKERFILES_DIR}login-standalone.Dockerfile"
|
dockerfile = "${DOCKERFILES_DIR}login-standalone.Dockerfile"
|
||||||
contexts = {
|
contexts = {
|
||||||
login-client = "target:login-client"
|
login-client = "target:login-client"
|
||||||
}
|
}
|
||||||
tags = ["${LOGIN_TAG}"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
target "login-standalone-out" {
|
target "login-standalone-out" {
|
||||||
inherits = ["login-standalone"]
|
inherits = ["login-standalone"]
|
||||||
target = "login-standalone-out"
|
target = "login-standalone-out"
|
||||||
output = [
|
output = [
|
||||||
"type=local,dest=${LOGIN_DIR}apps/login/standalone"
|
"type=local,dest=${LOGIN_DIR}apps/login/standalone"
|
||||||
]
|
]
|
||||||
|
Reference in New Issue
Block a user