Compare commits
95 commits
eee387c0ab
...
9bf20bb2cd
Author | SHA1 | Date | |
---|---|---|---|
9bf20bb2cd | |||
|
5ba1efc858 | ||
|
a2ba3c8ace | ||
|
b1ff9511d1 | ||
|
33cae33ae4 | ||
|
226daa41c7 | ||
|
fec661cf10 | ||
|
d2e19da64e | ||
|
b2930a4799 | ||
|
a9c3e592f3 | ||
|
a8d9a5553f | ||
|
ce454bda47 | ||
|
b44aee7535 | ||
|
65e90d2ff4 | ||
|
36defd5f33 | ||
|
8853a942f9 | ||
|
927f110aef | ||
|
bd1a5a1543 | ||
|
35b425736a | ||
|
1718a3ee94 | ||
|
9851452be1 | ||
|
6482e41cd8 | ||
|
7d70ea7c20 | ||
|
cd86405064 | ||
|
fd97f9c7d7 | ||
|
8b1cdb1fc3 | ||
|
5b6ac5b14b | ||
|
b3dd26a7c3 | ||
|
d6b01015c4 | ||
|
612e2caae1 | ||
|
25e4995eb7 | ||
|
cb38ad10ea | ||
|
440624e398 | ||
|
373656593d | ||
|
e8543e77d2 | ||
|
3315ea718a | ||
|
f229790ba5 | ||
|
822d3fde32 | ||
|
8be4237fd4 | ||
|
8855cf2316 | ||
|
f8df8e1c9a | ||
|
910caf5ff8 | ||
|
1a038af507 | ||
|
07464b11de | ||
|
fb64e3ba5f | ||
|
81950f5d17 | ||
|
2b4fbfe66b | ||
|
e4101b1b61 | ||
|
7681687e0a | ||
|
d80cb010ff | ||
|
af434d6bc1 | ||
|
2e51775acc | ||
|
9a7cfb38ac | ||
|
a7af380f71 | ||
|
e72da1ac2c | ||
|
53cfcdc273 | ||
|
1d8b61b991 | ||
|
d853dcab7d | ||
|
36b01cbea0 | ||
|
ba51140a25 | ||
|
4e540fa2a7 | ||
|
db780ed6c7 | ||
|
9c50813bf4 | ||
|
78aa08941a | ||
|
a79b67cac8 | ||
|
19ef0a608b | ||
|
0d9d2908f1 | ||
|
98a4faf289 | ||
|
c64e0ad583 | ||
|
abb7c414ab | ||
|
7e470e81f8 | ||
|
a76f9096e8 | ||
|
4ad462fbd3 | ||
|
ac25844731 | ||
|
b27a5d05b4 | ||
|
f9f9a313b3 | ||
|
35b109b4eb | ||
|
7368104e43 | ||
|
77231bd68c | ||
|
3d91df42d8 | ||
|
8c2bc39418 | ||
|
b4caeee0c7 | ||
|
eb5afeeabb | ||
|
fd2338c4fc | ||
|
7152a8b0f3 | ||
|
7bbc35cba7 | ||
|
3c727a32f4 | ||
|
617cadb84b | ||
|
6af682a897 | ||
|
87c87face7 | ||
|
97727dbed5 | ||
|
64f97be053 | ||
|
e41ca1f579 | ||
|
3d663802bb | ||
|
de973de800 |
450 changed files with 5608 additions and 3400 deletions
|
@ -7,7 +7,7 @@ inputs:
|
|||
release-branches:
|
||||
description: 'List of all related release branches (in JSON format)'
|
||||
required: false
|
||||
default: '["refs/heads/release/22.0","refs/heads/release/24.0"]'
|
||||
default: '["refs/heads/release/22.0","refs/heads/release/24.0","refs/heads/release/26.0"]'
|
||||
keep-days:
|
||||
description: 'For how many days to store the particular artifact.'
|
||||
required: false
|
||||
|
|
6
.github/actions/build-keycloak/action.yml
vendored
6
.github/actions/build-keycloak/action.yml
vendored
|
@ -24,9 +24,9 @@ runs:
|
|||
with:
|
||||
create-cache-if-it-doesnt-exist: true
|
||||
|
||||
- id: frontend-plugin-cache
|
||||
name: Frontend Plugin Cache
|
||||
uses: ./.github/actions/frontend-plugin-cache
|
||||
- id: pnpm-store-cache
|
||||
name: PNPM store cache
|
||||
uses: ./.github/actions/pnpm-store-cache
|
||||
|
||||
- id: build-keycloak
|
||||
name: Build Keycloak
|
||||
|
|
3
.github/actions/conditional/action.yml
vendored
3
.github/actions/conditional/action.yml
vendored
|
@ -22,9 +22,6 @@ outputs:
|
|||
ci-webauthn:
|
||||
description: Should "ci.yml" execute (WebAuthn)
|
||||
value: ${{ steps.changes.outputs.ci-webauthn }}
|
||||
ci-test-poc:
|
||||
description: Should "ci.yml" execute (Test PoC)
|
||||
value: ${{ steps.changes.outputs.ci-test-poc }}
|
||||
operator:
|
||||
description: Should "operator-ci.yml" execute
|
||||
value: ${{ steps.changes.outputs.operator }}
|
||||
|
|
2
.github/actions/conditional/conditions
vendored
2
.github/actions/conditional/conditions
vendored
|
@ -60,5 +60,3 @@ js/libs/keycloak-js/ ci ci-quarkus
|
|||
*.tsx codeql-typescript
|
||||
|
||||
testsuite::database-suite ci-store
|
||||
|
||||
test-poc/ ci ci-test-poc
|
20
.github/actions/cypress-cache/action.yml
vendored
Normal file
20
.github/actions/cypress-cache/action.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
name: Cache Cypress
|
||||
description: Caches Cypress binary to speed up the build.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: cache-key
|
||||
name: Cache key based on Cypress version
|
||||
shell: bash
|
||||
run: echo "key=cypress-binary-$(jq -r '.devDependencies.cypress' js/apps/admin-ui/package.json)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Cache Cypress binary
|
||||
with:
|
||||
# See: https://docs.cypress.io/app/references/advanced-installation#Binary-cache
|
||||
path: |
|
||||
~/.cache/Cypress
|
||||
/AppData/Local/Cypress/Cache
|
||||
~/Library/Caches/Cypress
|
||||
key: ${{ runner.os }}-${{ steps.cache-key.outputs.key }}
|
21
.github/actions/frontend-plugin-cache/action.yml
vendored
21
.github/actions/frontend-plugin-cache/action.yml
vendored
|
@ -1,21 +0,0 @@
|
|||
name: Frontend Plugin Cache
|
||||
description: Caches NPM dependencies for the frontend-maven-plugin to speed up builds
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Get PNPM version
|
||||
id: pnpm-version
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version=$(./mvnw help:evaluate -Dexpression=pnpm.version -q -DforceStdout)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Cache PNPM store
|
||||
with:
|
||||
# See: https://pnpm.io/npmrc#store-dir
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
~/AppData/Local/pnpm/store
|
||||
~/Library/pnpm/store
|
||||
key: ${{ runner.os }}-frontend-plugin-pnpm-store-${{ steps.pnpm-version.outputs.version }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
@ -25,9 +25,9 @@ runs:
|
|||
name: Maven cache
|
||||
uses: ./.github/actions/maven-cache
|
||||
|
||||
- id: frontend-plugin-cache
|
||||
name: Frontend Plugin Cache
|
||||
uses: ./.github/actions/frontend-plugin-cache
|
||||
- id: pnpm-store-cache
|
||||
name: PNPM store cache
|
||||
uses: ./.github/actions/pnpm-store-cache
|
||||
|
||||
- id: download-keycloak
|
||||
name: Download Keycloak Maven artifacts
|
||||
|
|
31
.github/actions/pnpm-setup/action.yml
vendored
31
.github/actions/pnpm-setup/action.yml
vendored
|
@ -20,26 +20,19 @@ runs:
|
|||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Get PNPM store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: PNPM store cache
|
||||
uses: ./.github/actions/pnpm-store-cache
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup PNPM cache
|
||||
with:
|
||||
# Also cache Cypress binary.
|
||||
path: |
|
||||
~/.cache/Cypress
|
||||
${{ steps.pnpm-cache.outputs.store-path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Cypress binary cache
|
||||
uses: ./.github/actions/cypress-cache
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Run the store prune after the installation to avoid having caches which grow over time
|
||||
run: |
|
||||
pnpm install --prefer-offline --frozen-lockfile
|
||||
pnpm store prune
|
||||
run: pnpm install --prefer-offline --frozen-lockfile
|
||||
|
||||
# This step is only needed to ensure that the Cypress binary is installed.
|
||||
# If the binary was retrieved from the cache, this step is a no-op.
|
||||
- name: Install Cypress dependencies
|
||||
shell: bash
|
||||
working-directory: js/apps/admin-ui
|
||||
run: pnpm exec cypress install
|
||||
|
|
20
.github/actions/pnpm-store-cache/action.yml
vendored
Normal file
20
.github/actions/pnpm-store-cache/action.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
name: Cache PNPM store
|
||||
description: Caches the PNPM store to speed up the build.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: weekly-cache-key
|
||||
name: Key for weekly rotation of cache
|
||||
shell: bash
|
||||
run: echo "key=pnpm-store-`date -u "+%Y-%U"`" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Cache PNPM store
|
||||
with:
|
||||
# See: https://pnpm.io/npmrc#store-dir
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
~/AppData/Local/pnpm/store
|
||||
~/Library/pnpm/store
|
||||
key: ${{ runner.os }}-${{ steps.weekly-cache-key.outputs.key }}
|
4
.github/actions/unit-test-setup/action.yml
vendored
4
.github/actions/unit-test-setup/action.yml
vendored
|
@ -11,6 +11,6 @@ runs:
|
|||
name: Maven cache
|
||||
uses: ./.github/actions/maven-cache
|
||||
|
||||
- id: frontend-plugin-cache
|
||||
- id: pnpm-store-cache
|
||||
name: Frontend Plugin Cache
|
||||
uses: ./.github/actions/frontend-plugin-cache
|
||||
uses: ./.github/actions/pnpm-store-cache
|
||||
|
|
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
|
@ -33,7 +33,6 @@ jobs:
|
|||
ci-store: ${{ steps.conditional.outputs.ci-store }}
|
||||
ci-sssd: ${{ steps.conditional.outputs.ci-sssd }}
|
||||
ci-webauthn: ${{ steps.conditional.outputs.ci-webauthn }}
|
||||
ci-test-poc: ${{ steps.conditional.outputs.ci-test-poc }}
|
||||
ci-aurora: ${{ steps.auroradb-tests.outputs.run-aurora-tests }}
|
||||
|
||||
steps:
|
||||
|
@ -84,7 +83,7 @@ jobs:
|
|||
run: |
|
||||
SEP=""
|
||||
PROJECTS=""
|
||||
for i in `find -name '*Test.java' -type f | egrep -v './(testsuite|quarkus|docs|test-poc|test-framework)/' | sed 's|/src/test/java/.*||' | sort | uniq | sed 's|./||'`; do
|
||||
for i in `find -name '*Test.java' -type f | egrep -v './(testsuite|quarkus|docs|tests|test-framework)/' | sed 's|/src/test/java/.*||' | sort | uniq | sed 's|./||'`; do
|
||||
PROJECTS="$PROJECTS$SEP$i"
|
||||
SEP=","
|
||||
done
|
||||
|
@ -958,7 +957,7 @@ jobs:
|
|||
job-id: migration-tests-${{ matrix.old-version }}-${{ matrix.database }}
|
||||
|
||||
test-framework:
|
||||
name: Keycloak Test Framework
|
||||
name: Test Framework
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 30
|
||||
|
@ -970,14 +969,12 @@ jobs:
|
|||
uses: ./.github/actions/integration-test-setup
|
||||
|
||||
- name: Run tests
|
||||
run: ./mvnw test -f test-framework/pom.xml
|
||||
run: ./mvnw package -f test-framework/pom.xml
|
||||
|
||||
test-poc:
|
||||
name: Test PoC
|
||||
base-new-integration-tests:
|
||||
name: Base IT (new)
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.conditional.outputs.ci-test-poc == 'true'
|
||||
needs:
|
||||
- conditional
|
||||
- build
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
@ -988,9 +985,7 @@ jobs:
|
|||
uses: ./.github/actions/integration-test-setup
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
KC_TEST_BROWSER: chrome-headless
|
||||
run: ./mvnw clean install -f test-poc/pom.xml
|
||||
run: ./mvnw test -f tests/pom.xml
|
||||
|
||||
check:
|
||||
name: Status Check - Keycloak CI
|
||||
|
@ -1015,7 +1010,8 @@ jobs:
|
|||
- sssd-unit-tests
|
||||
- migration-tests
|
||||
- external-infinispan-tests
|
||||
- test-poc
|
||||
- test-framework
|
||||
- base-new-integration-tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
2
.github/workflows/js-ci.yml
vendored
2
.github/workflows/js-ci.yml
vendored
|
@ -240,7 +240,7 @@ jobs:
|
|||
- name: Start Keycloak server
|
||||
run: |
|
||||
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
|
||||
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz,transient-users &> ~/server.log &
|
||||
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v1,transient-users &> ~/server.log &
|
||||
env:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
|
|
|
@ -53,7 +53,9 @@ public class Profile {
|
|||
|
||||
ACCOUNT_V3("Account Console version 3", Type.DEFAULT, 3, Feature.ACCOUNT_API),
|
||||
|
||||
ADMIN_FINE_GRAINED_AUTHZ("Fine-Grained Admin Permissions", Type.PREVIEW),
|
||||
ADMIN_FINE_GRAINED_AUTHZ("Fine-Grained Admin Permissions", Type.PREVIEW, 1),
|
||||
|
||||
ADMIN_FINE_GRAINED_AUTHZ_V2("Fine-Grained Admin Permissions version 2", Type.EXPERIMENTAL, 2, Feature.AUTHORIZATION),
|
||||
|
||||
ADMIN_API("Admin API", Type.DEFAULT),
|
||||
|
||||
|
@ -123,6 +125,8 @@ public class Profile {
|
|||
PASSKEYS("Passkeys", Type.PREVIEW),
|
||||
|
||||
CACHE_EMBEDDED_REMOTE_STORE("Support for remote-store in embedded Infinispan caches", Type.EXPERIMENTAL),
|
||||
|
||||
USER_EVENT_METRICS("Collect metrics based on user events", Type.PREVIEW),
|
||||
;
|
||||
|
||||
private final Type type;
|
||||
|
@ -337,13 +341,13 @@ public class Profile {
|
|||
*/
|
||||
private static Map<String, TreeSet<Feature>> getOrderedFeatures() {
|
||||
if (FEATURES == null) {
|
||||
// "natural" ordering low to high between two features
|
||||
Comparator<Feature> comparator = Comparator.comparing(Feature::getType).thenComparingInt(Feature::getVersion);
|
||||
// "natural" ordering low to high between two features (type has precedence and then reversed version is used)
|
||||
Comparator<Feature> comparator = Comparator.comparing(Feature::getType).thenComparing(Comparator.comparingInt(Feature::getVersion).reversed());
|
||||
// aggregate the features by unversioned key
|
||||
HashMap<String, TreeSet<Feature>> features = new HashMap<>();
|
||||
Stream.of(Feature.values()).forEach(f -> features.compute(f.getUnversionedKey(), (k, v) -> {
|
||||
if (v == null) {
|
||||
v = new TreeSet<>(comparator.reversed()); // we want the highest priority first
|
||||
v = new TreeSet<>(comparator);
|
||||
}
|
||||
v.add(f);
|
||||
return v;
|
||||
|
|
|
@ -35,4 +35,6 @@ public interface ServiceAccountConstants {
|
|||
String CLIENT_HOST = "clientHost";
|
||||
String CLIENT_ADDRESS = "clientAddress";
|
||||
|
||||
String SERVICE_ACCOUNT_SCOPE = "service_account";
|
||||
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ public class ProfileTest {
|
|||
|
||||
private static final Profile.Feature DEFAULT_FEATURE = Profile.Feature.AUTHORIZATION;
|
||||
private static final Profile.Feature DISABLED_BY_DEFAULT_FEATURE = Profile.Feature.DOCKER;
|
||||
private static final Profile.Feature PREVIEW_FEATURE = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ;
|
||||
private static final Profile.Feature PREVIEW_FEATURE = Profile.Feature.TOKEN_EXCHANGE;
|
||||
private static final Profile.Feature EXPERIMENTAL_FEATURE = Profile.Feature.DYNAMIC_SCOPES;
|
||||
private static Profile.Feature DEPRECATED_FEATURE = Profile.Feature.LOGIN_V1;
|
||||
|
||||
|
|
|
@ -218,6 +218,8 @@ public class RealmRepresentation {
|
|||
protected Boolean organizationsEnabled;
|
||||
private List<OrganizationRepresentation> organizations;
|
||||
|
||||
protected Boolean verifiableCredentialsEnabled;
|
||||
|
||||
@Deprecated
|
||||
protected Boolean social;
|
||||
@Deprecated
|
||||
|
@ -1440,6 +1442,14 @@ public class RealmRepresentation {
|
|||
this.organizationsEnabled = organizationsEnabled;
|
||||
}
|
||||
|
||||
public Boolean isVerifiableCredentialsEnabled() {
|
||||
return verifiableCredentialsEnabled;
|
||||
}
|
||||
|
||||
public void setVerifiableCredentialsEnabled(Boolean verifiableCredentialsEnabled) {
|
||||
this.verifiableCredentialsEnabled = verifiableCredentialsEnabled;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Map<String, String> getAttributesOrEmpty() {
|
||||
return (Map<String, String>) (attributes == null ? Collections.emptyMap() : attributes);
|
||||
|
|
|
@ -8,3 +8,13 @@ If you are using a custom theme that extends any of the `keycloak` themes and ar
|
|||
----
|
||||
darkMode=false
|
||||
----
|
||||
|
||||
Alternatively, you can disable dark mode support for the built-in Keycloak themes on a per-realm basis by turning off the "Dark mode" setting under the "Theme" tab in the realm settings.
|
||||
|
||||
= LDAP users are created as enabled by default when using Microsoft Active Directory
|
||||
|
||||
If you are using Microsoft AD and creating users through the administrative interfaces, the user will created as enabled by default.
|
||||
|
||||
In previous versions, it was only possible to update the user status after setting a (non-temporary) password to the user.
|
||||
This behavior was not consistent with other built-in user storages as well as not consistent with others LDAP vendors supported
|
||||
by the LDAP provider.
|
||||
|
|
BIN
docs/documentation/server_admin/images/brute-force-mixed.png
Normal file
BIN
docs/documentation/server_admin/images/brute-force-mixed.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 319 KiB |
Binary file not shown.
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
After Width: | Height: | Size: 297 KiB |
Binary file not shown.
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 210 KiB |
|
@ -67,6 +67,7 @@ include::topics/threat.adoc[]
|
|||
include::topics/threat/host.adoc[]
|
||||
include::topics/threat/admin.adoc[]
|
||||
include::topics/threat/brute-force.adoc[]
|
||||
include::topics/threat/password.adoc[]
|
||||
include::topics/threat/read-only-attributes.adoc[]
|
||||
include::topics/threat/validate-user-attributes.adoc[]
|
||||
include::topics/threat/clickjacking.adoc[]
|
||||
|
|
|
@ -2,68 +2,110 @@
|
|||
[[password-guess-brute-force-attacks]]
|
||||
=== Brute force attacks
|
||||
|
||||
A brute force attack attempts to guess a user's password by trying to log in multiple times. {project_name} has brute force detection capabilities and can temporarily disable a user account if the number of login failures exceeds a specified threshold.
|
||||
A brute force attack attempts to guess a user's password by trying to log in multiple times. {project_name} has brute force detection capabilities and can permanently or temporarily disable a user account if the number of login failures exceeds a specified threshold.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
{project_name} disables brute force detection by default. Enable this feature to protect against brute force attacks.
|
||||
When a user is locked and attempts to log in, {project_name} displays the default `Invalid username or password` error message. This message is the same error message as the message displayed for an invalid username or invalid password to ensure the attacker is unaware the account is disabled.
|
||||
====
|
||||
|
||||
.Procedure
|
||||
[WARNING]
|
||||
====
|
||||
Brute force detection is disabled by default. Enable this feature to protect against brute force attacks.
|
||||
====
|
||||
|
||||
To enable this protection:
|
||||
|
||||
. Click *Realm Settings* in the menu
|
||||
. Click the *Security Defenses* tab.
|
||||
. Click the *Brute Force Detection* tab.
|
||||
. Choose the *Brute Force Mode* which best fit to your requirements.
|
||||
+
|
||||
.Brute force detection
|
||||
image:images/brute-force.png[]
|
||||
|
||||
{project_name} can deploy permanent lockout and temporary lockout actions when it detects an attack. Permanent lockout disables a user account until an administrator re-enables it. Temporary lockout disables a user account for a specific period of time.
|
||||
The time period that the account is disabled increases as the attack continues and subsequent failures reach multiples of `Max Login Failures`.
|
||||
==== Lockout permanently
|
||||
{project_name} disables a user account (blocking log in attemps) until an administrator re-enables it.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
When a user is temporarily locked and attempts to log in, {project_name} displays the default `Invalid username or password` error message. This message is the same error message as the message displayed for an invalid username or invalid password to ensure the attacker is unaware the account is disabled.
|
||||
====
|
||||
.Lockout permanently
|
||||
image:images/brute-force-permanently.png[]
|
||||
|
||||
*Common Parameters*
|
||||
*Permanent Lockout Parameters*
|
||||
|
||||
|===
|
||||
|Name |Description |Default
|
||||
|
||||
|Max Login Failures
|
||||
|The maximum number of login failures.
|
||||
|30 failures.
|
||||
|30 failures
|
||||
|
||||
|Quick Login Check Milliseconds
|
||||
|The minimum time between login attempts.
|
||||
|1000 milliseconds.
|
||||
|1000 milliseconds
|
||||
|
||||
|Minimum Quick Login Wait
|
||||
|The minimum time the user is disabled when login attempts are quicker than _Quick Login Check Milliseconds_.
|
||||
|1 minute.
|
||||
|1 minute
|
||||
|
||||
|===
|
||||
|
||||
*Permanent Lockout Flow*
|
||||
|
||||
====
|
||||
. On successful login
|
||||
.. Reset `count`
|
||||
. On failed login
|
||||
.. Increment `count`
|
||||
.. If `count` is greater than or equals to `Max login failures`
|
||||
... locks the user
|
||||
.. Else if the time between this failure and the last failure is less than _Quick Login Check Milliseconds_
|
||||
... Locks the user for the time specified at _Minimum Quick Login Wait_
|
||||
====
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
Enabling an user account resets the `count`.
|
||||
====
|
||||
|
||||
==== Lockout temporarily
|
||||
{project_name} disables a user account for a specific period of time. The time period that the account is disabled increases as the attack continues.
|
||||
|
||||
.Lockout temporarily
|
||||
image:images/brute-force-temporarily.png[]
|
||||
|
||||
*Temporary Lockout Parameters*
|
||||
|
||||
|===
|
||||
|Name |Description |Default
|
||||
|
||||
|Max Login Failures
|
||||
|The maximum number of login failures.
|
||||
|30 failures
|
||||
|
||||
|Strategy to increase wait time
|
||||
|Strategy to increase the time a user will be temporarily disabled when the user's login attempts exceed _Max Login Failures_
|
||||
|Multiple
|
||||
|
||||
|Wait Increment
|
||||
|The time added to the time a user is temporarily disabled when the user's login attempts exceed _Max Login Failures_.
|
||||
|1 minute.
|
||||
|1 minute
|
||||
|
||||
|Max Wait
|
||||
|The maximum time a user is temporarily disabled.
|
||||
|15 minutes.
|
||||
|15 minutes
|
||||
|
||||
|Failure Reset Time
|
||||
|The time when the failure count resets. The timer runs from the last failed login. Make sure this number is always greater than `Max wait`; otherwise the effective
|
||||
wait time will never reach the value you have set to `Max wait`.
|
||||
|12 hours.
|
||||
|12 hours
|
||||
|
||||
|Quick Login Check Milliseconds
|
||||
|The minimum time between login attempts.
|
||||
|1000 milliseconds
|
||||
|
||||
|Minimum Quick Login Wait
|
||||
|The minimum time the user is disabled when login attempts are quicker than _Quick Login Check Milliseconds_.
|
||||
|1 minute
|
||||
|
||||
|===
|
||||
|
||||
|
@ -76,10 +118,15 @@ wait time will never reach the value you have set to `Max wait`.
|
|||
... Reset `count`
|
||||
.. Increment `count`
|
||||
.. Calculate `wait` according the brute force strategy defined (see below Strategies to set Wait Time).
|
||||
.. If `wait` equals is less than 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_, set `wait` to _Minimum Quick Login Wait_.
|
||||
.. If `wait` is less than or equals to 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_
|
||||
... set `wait` to _Minimum Quick Login Wait_
|
||||
.. if `wait` is greater than 0
|
||||
... Temporarily disable the user for the smallest of `wait` and _Max Wait_ seconds
|
||||
... Increment the temporary lockout counter
|
||||
|
||||
====
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
`count` does not increment when a temporarily disabled account commits a login failure.
|
||||
====
|
||||
|
||||
|
@ -104,11 +151,11 @@ By multiples strategy, wait time is incremented when the number (or count) of fa
|
|||
|**10** |**30** | 5 | **60**
|
||||
|===
|
||||
|
||||
At the fifth failed attempt of the `Effective Wait Time`, the account is disabled for `30` seconds. After reaching the next multiple of `Max Login Failures`, in this case `10`, the time increases from `30` to `60` seconds.
|
||||
At the fifth failed attempt, the account is disabled for `30` seconds. After reaching the next multiple of `Max Login Failures`, in this case `10`, the time increases from `30` to `60` seconds.
|
||||
|
||||
The By multiple strategy uses the following formula to calculate wait time: _Wait Increment_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number.
|
||||
The By multiple strategy uses the following formula to calculate wait time: _Wait Increment in Seconds_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number.
|
||||
|
||||
For linear strategy, wait time is incremented when the number (or count) of failures equals or is greater than `Max Login Failure`. For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment` to`30` seconds, the effective time that an account is disabled after several failed authentication attempts will be:
|
||||
For linear strategy, wait time is incremented when the `count` (or number) of failures is greater than or equals to `Max Login Failure`. For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment` to`30` seconds, the effective time that an account is disabled after several failed authentication attempts will be:
|
||||
|
||||
[cols="1,1,1,1"]
|
||||
|===
|
||||
|
@ -125,33 +172,88 @@ For linear strategy, wait time is incremented when the number (or count) of fail
|
|||
|**10** |**30** | 5 | **180**
|
||||
|===
|
||||
|
||||
At the fifth failed attempt for the `Effective Wait Time`, the account is disabled for `30` seconds. Each new failed attempt increases wait time.
|
||||
At the fifth failed attempt, the account is disabled for `30` seconds. Each new failure increases wait time according value specified at `wait increment`.
|
||||
|
||||
The linear strategy uses the following formula to calculate wait time: _Wait Increment_ * (1 + `count` - _Max Login Failures_).
|
||||
The linear strategy uses the following formula to calculate wait time: _Wait Increment in Seconds_ * (1 + `count` - _Max Login Failures_).
|
||||
|
||||
*Permanent Lockout Parameters*
|
||||
==== Lockout permanently after temporary lockout
|
||||
Mixed mode. Locks user temporarily for specified number of times and then locks user permanently.
|
||||
|
||||
.Lockout permanently after temporary lockout
|
||||
image:images/brute-force-mixed.png[]
|
||||
|
||||
*Permanent lockout after temporary lockouts Parameters*
|
||||
|
||||
|===
|
||||
|Name |Description |Default
|
||||
|
||||
|Max temporary Lockouts
|
||||
|Max Login Failures
|
||||
|The maximum number of login failures.
|
||||
|30 failures
|
||||
|
||||
|Maximum temporary Lockouts
|
||||
|The maximum number of temporary lockouts permitted before permanent lockout occurs.
|
||||
|0
|
||||
|1
|
||||
|
||||
|Strategy to increase wait time
|
||||
|Strategy to increase the time a user will be temporarily disabled when the user's login attempts exceed _Max Login Failures_
|
||||
|Multiple
|
||||
|
||||
|Wait Increment
|
||||
|The time added to the time a user is temporarily disabled when the user's login attempts exceed _Max Login Failures_.
|
||||
|1 minute
|
||||
|
||||
|Max Wait
|
||||
|The maximum time a user is temporarily disabled.
|
||||
|15 minutes
|
||||
|
||||
|Failure Reset Time
|
||||
|The time when the failure count resets. The timer runs from the last failed login. Make sure this number is always greater than `Max wait`; otherwise the effective
|
||||
wait time will never reach the value you have set to `Max wait`.
|
||||
|12 hours
|
||||
|
||||
|Quick Login Check Milliseconds
|
||||
|The minimum time between login attempts.
|
||||
|1000 milliseconds
|
||||
|
||||
|Minimum Quick Login Wait
|
||||
|The minimum time the user is disabled when login attempts are quicker than _Quick Login Check Milliseconds_.
|
||||
|1 minute
|
||||
|
||||
|===
|
||||
|
||||
*Permanent Lockout Flow*
|
||||
*Permanent lockout after temporary lockouts Algorithm*
|
||||
====
|
||||
. Follow temporary lockout flow
|
||||
. If temporary lockout counter exceeds Max temporary lockouts
|
||||
.. Permanently disable user
|
||||
. On successful login
|
||||
.. Reset `count`
|
||||
.. Reset `temporary lockout` counter
|
||||
. On failed login
|
||||
.. If the time between this failure and the last failure is greater than _Failure Reset Time_
|
||||
... Reset `count`
|
||||
... Reset `temporary lockout` counter
|
||||
.. Increment `count`
|
||||
.. Calculate `wait` according the brute force strategy defined (see below Strategies to set Wait Time).
|
||||
.. If `wait` is less than or equals to 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_
|
||||
... set `wait` to _Minimum Quick Login Wait_
|
||||
... set `quick login failure` to `true``
|
||||
.. if `wait` and `Maximum temporary Lockouts` is greater than 0
|
||||
... set `wait` to the smallest of `wait` and _Max Wait_ seconds
|
||||
.. if `quick login failure` is `false`
|
||||
... Increment `temporary lockout` counter
|
||||
.. If `temporary lockout` counter exceeds `Maximum temporary lockouts`
|
||||
... Permanently locks the user
|
||||
.. Else
|
||||
... Temporarily blocks the user according `wait` value
|
||||
|
||||
When {project_name} disables a user, the user cannot log in until an administrator enables the user. Enabling an account resets the `count`.
|
||||
====
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
`count` does not increment when a temporarily disabled account commits a login failure.
|
||||
====
|
||||
|
||||
==== Downside of {project_name} brute force detection
|
||||
|
||||
The downside of {project_name} brute force detection is that the server becomes vulnerable to denial of service attacks. When implementing a denial of service attack, an attacker can attempt to log in by guessing passwords for any accounts it knows and eventually causing {project_name} to disable the accounts.
|
||||
|
||||
Consider using intrusion prevention software (IPS). {project_name} logs every login failure and client IP address failure. You can point the IPS to the {project_name} server's log file, and the IPS can modify firewalls to block connections from these IP addresses.
|
||||
|
||||
==== Password policies
|
||||
|
||||
Ensure you have a complex password policy to force users to choose complex passwords. See the <<_password-policies, Password Policies>> chapter for more information. Prevent password guessing by setting up the {project_name} server to use one-time-passwords.
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
=== Password policies
|
||||
|
||||
Ensure you have a complex password policy to force users to choose complex passwords. See the <<_password-policies, Password Policies>> chapter for more information. Prevent password guessing by setting up the {project_name} server to use one-time-passwords.
|
|
@ -22,7 +22,7 @@ These APIs are no longer needed as initialization is done automatically on deman
|
|||
= Virtual Threads enabled for Infinispan and JGroups thread pools
|
||||
|
||||
Starting from this release, {project_name} automatically enables the virtual thread pool support in both the embedded Infinispan and JGroups when running on OpenJDK 21.
|
||||
This removes the need to configure the thread pool and reduces overall memory footprint.
|
||||
This removes the need to configure the JGroups thread pool, the need to align the JGroups thread pool with the HTTP worker thread pool, and reduces the overall memory footprint.
|
||||
To disable the virtual threads, add one of the Java system properties combinations to your deployment:
|
||||
|
||||
* `-Dorg.infinispan.threads.virtual=false`: disables virtual thread in both Infinispan and JGroups.
|
||||
|
@ -41,6 +41,11 @@ To enable the previous behavior, choose the transport stack `udp`.
|
|||
|
||||
The {project_name} Operator will continue to configure `kubernetes` as a transport stack.
|
||||
|
||||
= Deprecated transport stacks for distributed caches
|
||||
|
||||
The `azure`, `ec2` and `google` transport stacks have been deprecated. Users should use the TCP based `jdbc-ping`
|
||||
stack as a direct replacement.
|
||||
|
||||
= Defining dependencies between provider factories
|
||||
|
||||
When developing extensions for {project_name}, developers can now specify dependencies between provider factories classes by implementing the method `dependsOn()` in the `ProviderFactory` interface.
|
||||
|
@ -49,3 +54,15 @@ See the Javadoc for a detailed description.
|
|||
= Removal of robots.txt file
|
||||
|
||||
The `robots.txt` file, previously included by default, is now removed. The default `robots.txt` file blocked all crawling, which prevented the `noindex`/`nofollow` directives from being followed. The desired default behaviour is for {project_name} pages to not show up in search engine results and this is accomplished by the existing `X-Robots-Tag` header, which is set to `none` by default. The value of this header can be overidden per-realm if a different behaviour is needed.
|
||||
|
||||
= Offline access removes the associated online session if the `offline_scope` is requested in the initial exchange
|
||||
|
||||
Any offline session in {project_name} is created from another online session. When the `offline_access` scope is requested, the current online session is used to create the associated offline session for the client. Therefore any `offline_access` request finished, until now, with two sessions, one online and one offline.
|
||||
|
||||
Starting with this version, {project_name} removes the initial online session if the `offline_scope` is directly requested as the first interaction for the session. The client retrieves the offline token after the code to token exchange that is associated to the offline session, but the previous online session is removed. If the online session has been used before the `offline_scope` request, by the same or another client, the online session remains active as today. Although the new behavior makes sense because the client application is just asking for an offline token, it can affect some scenarios that rely on having the online session still active after the initial `offline_scope` token request.
|
||||
|
||||
= New client scope `service_account` for `client_credentials` grant mappers
|
||||
|
||||
{project_name} introduces a new client scope at the realm level called `service_account` which is in charge of adding the specific claims for `client_credentials` grant (`client_id`, `clientHost` and `clientAddress`) via protocol mappers. This scope will be automatically assigned to and unassigned from the client when the `serviceAccountsEnabled` option is set or unset in the client configuration.
|
||||
|
||||
Previously, the three mappers (`Client Id`, `Client Host` and `Client IP Address`) where added directly to the dedicated scope when the client was configured to enable service accounts, and they were never removed.
|
|
@ -12,6 +12,15 @@ For a configuration where this is applied, visit <@links.ha id="deploy-keycloak-
|
|||
|
||||
== Concepts
|
||||
|
||||
=== JGroups communications
|
||||
|
||||
// remove this paragraph once OpenJDK 17 is no longer supported on the server side.
|
||||
// https://github.com/keycloak/keycloak/issues/31101
|
||||
|
||||
JGroups communications, which is used in single-site setups for the communication between {project_name} nodes, benefits from the use of virtual threads which are available in OpenJDK 21.
|
||||
This reduces the memory usage and removes the need to configure thread pool sizes.
|
||||
Therefore, the use of OpenJDK 21 is recommended.
|
||||
|
||||
=== Quarkus executor pool
|
||||
|
||||
{project_name} requests, as well as blocking probes, are handled by an executor pool. Depending on the available CPU cores, it has a maximum size of 50 or more threads.
|
||||
|
@ -31,32 +40,6 @@ If you increase the number of database connections and the number of threads too
|
|||
The number of database connections is configured via the link:{links_server_all-config_url}?q=db-pool[`Database` settings `db-pool-initial-size`, `db-pool-min-size` and `db-pool-max-size`] respectively.
|
||||
Low numbers ensure fast response times for all clients, even if there is an occasionally failing request when there is a load spike.
|
||||
|
||||
=== JGroups connection pool
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
* This currently applies to single-site setups only.
|
||||
In a multi-site setup with an external {jdgserver_name} this is not a restriction.
|
||||
* This currently applies if virtual threads are disabled.
|
||||
Since {project_name} 26.1, virtual threads are enabled in both embedded Infinispan and JGroups if running on OpenJDK 21 or higher.
|
||||
====
|
||||
|
||||
|
||||
The combined number of executor threads in all {project_name} nodes in the cluster should not exceed too much the number of threads available in JGroups thread pool to avoid the warning `thread pool is full (max=<value>, active=<value>)`.
|
||||
|
||||
The warning includes a thread dump when the Java system property `-Djgroups.thread_dumps_enabled=true` is set.
|
||||
It may incur in a penalty in performance collecting those thread dumps.
|
||||
|
||||
--
|
||||
include::partials/threads/executor-jgroups-thread-calculation.adoc[]
|
||||
--
|
||||
|
||||
Use metrics to monitor the total JGroup threads in the pool and for the threads active in the pool.
|
||||
When using TCP as the JGroups transport protocol, the metrics `vendor_jgroups_tcp_get_thread_pool_size` and `vendor_jgroups_tcp_get_thread_pool_size_active` are available for monitoring. When using UDP, the metrics `vendor_jgroups_udp_get_thread_pool_size` and `vendor_jgroups_udp_get_thread_pool_size_active` are available.
|
||||
This is useful to monitor that limiting the Quarkus thread pool size keeps the number of active JGroup threads below the maximum JGroup thread pool size.
|
||||
|
||||
WARNING: The metrics are not available when virtual threads are enabled in JGroups.
|
||||
|
||||
[#load-shedding]
|
||||
=== Load Shedding
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ Use it together with the other building blocks outlined in the <@links.ha id="bb
|
|||
* Understanding of a <@links.operator id="basic-deployment" /> of {project_name} with the {project_name} Operator.
|
||||
* Aurora AWS database deployed using the <@links.ha id="deploy-aurora-multi-az" /> {section}.
|
||||
* {jdgserver_name} server deployed using the <@links.ha id="deploy-infinispan-kubernetes-crossdc" /> {section}.
|
||||
* Running {project_name} with OpenJDK 21, which is the default for the containers distributed for {project_name}, as this enabled virtual threads for the JGroups communication.
|
||||
|
||||
== Procedure
|
||||
|
||||
|
@ -46,8 +47,6 @@ See the <@links.ha id="concepts-database-connections" /> {section} for details.
|
|||
<5> To be able to analyze the system under load, enable the metrics endpoint.
|
||||
The disadvantage of the setting is that the metrics will be available at the external {project_name} endpoint, so you must add a filter so that the endpoint is not available from the outside.
|
||||
Use a reverse proxy in front of {project_name} to filter out those URLs.
|
||||
<6> You might consider limiting the number of {project_name} threads further because multiple concurrent threads will lead to throttling by Kubernetes once the requested CPU limit is reached.
|
||||
See the <@links.ha id="concepts-threads" /> {section} for details.
|
||||
|
||||
== Verifying the deployment
|
||||
|
||||
|
@ -70,7 +69,11 @@ spec:
|
|||
additionalOptions:
|
||||
include::examples/generated/keycloak.yaml[tag=keycloak-queue-size]
|
||||
----
|
||||
|
||||
All exceeding requests are served with an HTTP 503.
|
||||
|
||||
You might consider limiting the value for `http-pool-max-threads` further because multiple concurrent threads will lead to throttling by Kubernetes once the requested CPU limit is reached.
|
||||
|
||||
See the <@links.ha id="concepts-threads" /> {section} about load shedding for details.
|
||||
|
||||
== Optional: Disable sticky sessions
|
||||
|
|
|
@ -464,16 +464,16 @@ spec:
|
|||
- name: http-metrics-slos
|
||||
value: '5,10,25,50,250,500'
|
||||
# tag::keycloak[]
|
||||
# end::keycloak[]
|
||||
# tag::keycloak-queue-size[]
|
||||
- name: http-max-queued-requests
|
||||
value: "1000"
|
||||
# end::keycloak-queue-size[]
|
||||
# tag::keycloak[]
|
||||
- name: log-console-output
|
||||
value: json
|
||||
- name: metrics-enabled # <5>
|
||||
value: 'true'
|
||||
- name: http-pool-max-threads # <6>
|
||||
value: "200"
|
||||
# tag::keycloak-ispn[]
|
||||
- name: cache-remote-host # <1>
|
||||
value: "infinispan.keycloak.svc"
|
||||
|
|
|
@ -453,16 +453,16 @@ spec:
|
|||
- name: http-metrics-slos
|
||||
value: '5,10,25,50,250,500'
|
||||
# tag::keycloak[]
|
||||
# end::keycloak[]
|
||||
# tag::keycloak-queue-size[]
|
||||
- name: http-max-queued-requests
|
||||
value: "1000"
|
||||
# end::keycloak-queue-size[]
|
||||
# tag::keycloak[]
|
||||
- name: log-console-output
|
||||
value: json
|
||||
- name: metrics-enabled # <5>
|
||||
value: 'true'
|
||||
- name: http-pool-max-threads # <6>
|
||||
value: "66"
|
||||
# end::keycloak[]
|
||||
# This block is just for documentation purposes as we need both versions of Infinispan config, with and without numbers to corresponding options
|
||||
# tag::keycloak[]
|
||||
|
@ -510,6 +510,7 @@ spec:
|
|||
- name: JAVA_OPTS_APPEND # <5>
|
||||
value: ""
|
||||
ports:
|
||||
# end::keycloak[]
|
||||
# readinessProbe:
|
||||
# exec:
|
||||
# command:
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
The number of JGroup threads is `200` by default.
|
||||
While it can be configured using the property Java system property `jgroups.thread_pool.max_threads`, we advise keeping it at this value.
|
||||
As shown in experiments, the total number of Quarkus worker threads in the cluster should not exceed the number of threads in the JGroup thread pool of `200` in each node to avoid requests being dropped in the JGroups communication.
|
||||
Given a {project_name} cluster with four nodes, each node should then have around 50 Quarkus worker threads.
|
||||
Use the {project_name} configuration option `http-pool-max-threads` to configure the maximum number of Quarkus worker threads.
|
|
@ -12,15 +12,16 @@ An admin can do this through the admin console (or admin REST endpoints), but cl
|
|||
The Client Registration Service provides built-in support for {project_name} Client Representations, OpenID Connect Client Meta Data and SAML Entity Descriptors.
|
||||
The Client Registration Service endpoint is `/realms/<realm>/clients-registrations/<provider>`.
|
||||
|
||||
The built-in supported `providers` are:
|
||||
The built-in supported `providers` are:
|
||||
|
||||
* default - {project_name} Client Representation (JSON)
|
||||
* install - {project_name} Adapter Configuration (JSON)
|
||||
* openid-connect - OpenID Connect Client Metadata Description (JSON)
|
||||
* saml2-entity-descriptor - SAML Entity Descriptor (XML)
|
||||
|
||||
The following sections will describe how to use the different providers.
|
||||
The following sections will describe how to use the different providers.
|
||||
|
||||
[#_authentication]
|
||||
== Authentication
|
||||
|
||||
To invoke the Client Registration Services you usually need a token. The token can be a bearer token, an initial access token or a registration access token.
|
||||
|
@ -40,7 +41,7 @@ If you are using a bearer token to create clients it's recommend to use a token
|
|||
=== Initial Access Token
|
||||
|
||||
The recommended approach to registering new clients is by using initial access tokens.
|
||||
An initial access token can only be used to create clients and has a configurable expiration as well as a configurable limit on how many clients can be created.
|
||||
An initial access token can only be used to create clients and has a configurable expiration as well as a configurable limit on how many clients can be created.
|
||||
|
||||
An initial access token can be created through the admin console.
|
||||
To create a new initial access token first select the realm in the admin console, then click on `Client` in the menu on the left, followed by
|
||||
|
@ -53,12 +54,12 @@ many clients can be created using the token. After you click on `Save` the token
|
|||
It is important that you copy/paste this token now as you won't be able to retrieve it later. If you forget to copy/paste it, then delete the token and create another one.
|
||||
|
||||
The token value is used as a standard bearer token when invoking the Client Registration Services, by adding it to the Authorization header in the request.
|
||||
For example:
|
||||
For example:
|
||||
|
||||
[source]
|
||||
----
|
||||
Authorization: bearer eyJhbGciOiJSUz...
|
||||
----
|
||||
----
|
||||
|
||||
[[_registration_access_token]]
|
||||
=== Registration Access Token
|
||||
|
@ -82,16 +83,16 @@ console, including for example configuring protocol mappers.
|
|||
To create a client create a Client Representation (JSON) then perform an HTTP POST request to `/realms/<realm>/clients-registrations/default`.
|
||||
|
||||
It will return a Client Representation that also includes the registration access token.
|
||||
You should save the registration access token somewhere if you want to retrieve the config, update or delete the client later.
|
||||
You should save the registration access token somewhere if you want to retrieve the config, update or delete the client later.
|
||||
|
||||
To retrieve the Client Representation perform an HTTP GET request to `/realms/<realm>/clients-registrations/default/<client id>`.
|
||||
|
||||
It will also return a new registration access token.
|
||||
It will also return a new registration access token.
|
||||
|
||||
To update the Client Representation perform an HTTP PUT request with the updated Client Representation to:
|
||||
`/realms/<realm>/clients-registrations/default/<client id>`.
|
||||
|
||||
It will also return a new registration access token.
|
||||
It will also return a new registration access token.
|
||||
|
||||
To delete the Client Representation perform an HTTP DELETE request to:
|
||||
`/realms/<realm>/clients-registrations/default/<client id>`
|
||||
|
@ -100,12 +101,12 @@ To delete the Client Representation perform an HTTP DELETE request to:
|
|||
|
||||
The `installation` client registration provider can be used to retrieve the adapter configuration for a client.
|
||||
In addition to token authentication you can also authenticate with client credentials using HTTP basic authentication.
|
||||
To do this include the following header in the request:
|
||||
To do this include the following header in the request:
|
||||
|
||||
[source]
|
||||
----
|
||||
Authorization: basic BASE64(client-id + ':' + client-secret)
|
||||
----
|
||||
----
|
||||
|
||||
To retrieve the Adapter Configuration then perform an HTTP GET request to `/realms/<realm>/clients-registrations/install/<client id>`.
|
||||
|
||||
|
@ -146,7 +147,7 @@ curl -X POST \
|
|||
== Example using Java Client Registration API
|
||||
|
||||
The Client Registration Java API makes it easy to use the Client Registration Service using Java.
|
||||
To use include the dependency `org.keycloak:keycloak-client-registration-api:>VERSION<` from Maven.
|
||||
To use include the dependency `org.keycloak:keycloak-client-registration-api:>VERSION<` from Maven.
|
||||
|
||||
For full instructions on using the Client Registration refer to the JavaDocs.
|
||||
Below is an example of creating a client. You need to replace `eyJhbGciOiJSUz...` with a proper initial access token or bearer token.
|
||||
|
|
|
@ -7,6 +7,7 @@ priority=30
|
|||
summary="Client-side JavaScript library that can be used to secure web applications.">
|
||||
|
||||
{project_name} comes with a client-side JavaScript library called `keycloak-js` that can be used to secure web applications. The adapter also comes with built-in support for Cordova applications.
|
||||
The adapter uses OpenID Connect protocol under the covers. You can take a look at the <@links.securingapps id="oidc-layers" anchor="_oidc_available_endpoints"/> {section} for the more generic information about OpenID Connect endpoints and capabilities.
|
||||
|
||||
== Installation
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ priority=40
|
|||
summary="Node.js adapter to protect server-side JavaScript apps">
|
||||
|
||||
{project_name} provides a Node.js adapter built on top of https://github.com/senchalabs/connect[Connect] to protect server-side JavaScript apps - the goal was to be flexible enough to integrate with frameworks like https://expressjs.com/[Express.js].
|
||||
The adapter uses OpenID Connect protocol under the covers. You can take a look at the <@links.securingapps id="oidc-layers" anchor="_oidc_available_endpoints"/> {section} for the more generic information about OpenID Connect endpoints and capabilities.
|
||||
|
||||
ifeval::[{project_community}==true]
|
||||
The library can be downloaded directly from https://www.npmjs.com/package/keycloak-connect[ {project_name} organization] and the source is available at
|
||||
|
|
|
@ -6,7 +6,7 @@ title="Secure applications and services with OpenID Connect"
|
|||
priority=20
|
||||
summary="Using OpenID Connect with Keycloak to secure applications and services">
|
||||
|
||||
<#include "partials/oidc/available-endpoints.adoc" />
|
||||
include::partials/oidc/available-endpoints.adoc[]
|
||||
|
||||
include::partials/oidc/supported-grant-types.adoc[]
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
|
||||
[#_oidc_available_endpoints]
|
||||
== Available Endpoints
|
||||
|
||||
As a fully-compliant OpenID Connect Provider implementation, {project_name} exposes a set of endpoints that applications
|
||||
|
|
|
@ -14,7 +14,8 @@ The current distributed cache implementation is built on top of https://infinisp
|
|||
== Enable distributed caching
|
||||
When you start {project_name} in production mode, by using the `start` command, caching is enabled and all {project_name} nodes in your network are discovered.
|
||||
|
||||
By default, caches are using a UDP transport stack so that nodes are discovered using IP multicast transport based on UDP. For most production environments, there are better discovery alternatives to UDP available. {project_name} allows you to either choose from a set of pre-defined default transport stacks, or to define your own custom stack, as you will see later in this {section}.
|
||||
By default, caches use the `jdbc-ping-udp` stack which is based upon a UDP transport and uses the configured database to track nodes joining the cluster.
|
||||
{project_name} allows you to either choose from a set of pre-defined default transport stacks, or to define your own custom stack, as you will see later in this {section}.
|
||||
|
||||
To explicitly enable distributed infinispan caching, enter this command:
|
||||
|
||||
|
@ -246,6 +247,10 @@ The following table shows transport stacks that are available using the `--cache
|
|||
|===
|
||||
|
||||
=== Additional transport stacks
|
||||
|
||||
IMPORTANT: The following stacks are deprecated. We recommend that you utilise the `jdbc-ping` stack in such environments
|
||||
as it does not require additional configuration or dependencies.
|
||||
|
||||
The following table shows transport stacks that are supported by {project_name}, but need some extra steps to work.
|
||||
Note that _none_ of these stacks are Kubernetes / OpenShift stacks, so no need exists to enable the `google` stack if you want to run {project_name} on top of the Google Kubernetes engine.
|
||||
In that case, use the `kubernetes` stack.
|
||||
|
|
|
@ -76,6 +76,9 @@ The table below summarizes the available metrics groups:
|
|||
|Cache
|
||||
|A set of metrics from Infinispan caches. See <@links.server id="caching"/> for more details.
|
||||
|
||||
|Keycloak
|
||||
|A set of metrics from Keycloak events. See <@links.server id="event-metrics"/> for more details.
|
||||
|
||||
|===
|
||||
|
||||
</@tmpl.guide>
|
||||
|
|
60
docs/guides/server/event-metrics.adoc
Normal file
60
docs/guides/server/event-metrics.adoc
Normal file
|
@ -0,0 +1,60 @@
|
|||
<#import "/templates/guide.adoc" as tmpl>
|
||||
<#import "/templates/kc.adoc" as kc>
|
||||
<#import "/templates/options.adoc" as opts>
|
||||
<#import "/templates/links.adoc" as links>
|
||||
|
||||
<@tmpl.guide
|
||||
title="Enabling {project_name} Event Metrics"
|
||||
summary="Learn how to enable and use {project_name} Event Metrics"
|
||||
preview="true"
|
||||
includedOptions="metrics-enabled event-metrics-user-*">
|
||||
|
||||
Event metrics can provide admins an overview of the different activities in a {project_name} instance.
|
||||
For now, only metrics for user events are captured.
|
||||
For example, you can monitor the number of logins, login failures, or token refreshes performed.
|
||||
|
||||
The metrics are exposed using the standard metrics endpoint, and you can use it in your own metrics collection system to create dashboards and alerts.
|
||||
|
||||
The metrics are reported as counters per {project_name} instance.
|
||||
The counters are reset on the restart of the instance.
|
||||
If you have multiple instances running in a cluster, you will need to collect the metrics from all instances and aggregate them to get per a cluster view.
|
||||
|
||||
== Enable event metrics
|
||||
|
||||
To start collecting metrics, enable the feature `user-event-metrics`, enable metrics, and enable the metrics for user events.
|
||||
|
||||
The following shows the required startup parameters:
|
||||
|
||||
<@kc.start parameters="--features=user-event-metrics --metrics-enabled=true --event-metrics-user-enabled=true ..."/>
|
||||
|
||||
By default, there is a separate metric for each realm.
|
||||
To break down the metric by client and identity provider, you can add those metrics dimension using the configuration option `event-metrics-user-tags`.
|
||||
This can be useful on installations with a small number of clients and IDPs.
|
||||
This is not recommended for installations with a large number of clients or IDPs as it will increase the memory usage of {project_name} and as it will increase the load on your monitoring system.
|
||||
|
||||
The following shows how to configure {project_name} to break down the metrics by all three metrics dimensions:
|
||||
|
||||
<@kc.start parameters="... --event-metrics-user-tags=realm,idp,clientId ..."/>
|
||||
|
||||
You can limit the events for which {project_name} will expose metrics.
|
||||
|
||||
The following example limits the events collected to `LOGIN` and `LOGOUT` events:
|
||||
|
||||
<@kc.start parameters="... --event-metrics-user-events=login,logout ..."/>
|
||||
|
||||
All error events will be collected with the primary event type and will have the `error` tag filled with the error code.
|
||||
|
||||
The snippet below is an example of a response provided by the metric endpoint:
|
||||
|
||||
[source]
|
||||
----
|
||||
# HELP keycloak_user_events_total Keycloak user events
|
||||
# TYPE keycloak_user_events_total counter
|
||||
keycloak_user_events_total{client_id="security-admin-console",error="",event="code_to_token",idp="",realm="master",} 1.0
|
||||
keycloak_user_events_total{client_id="security-admin-console",error="",event="login",idp="",realm="master",} 1.0
|
||||
keycloak_user_events_total{client_id="security-admin-console",error="",event="logout",idp="",realm="master",} 1.0
|
||||
keycloak_user_events_total{client_id="security-admin-console",error="invalid_user_credentials",event="login",idp="",realm="master",} 1.0
|
||||
----
|
||||
|
||||
|
||||
</@tmpl.guide>
|
|
@ -16,7 +16,7 @@ The default count of users per file and per transaction is fifty.
|
|||
Increasing this to a larger number leads to an exponentially increasing execution time.
|
||||
====
|
||||
|
||||
All {project_name} nodes need to be stopped prior to using `kc.[sh|bat] import | export` commands. This ensures that the resulting operations will have no consistency issues with concurrent requests.
|
||||
All {project_name} nodes need to be stopped prior to using `kc.[sh|bat] import | export` commands. This ensures that the resulting operations will have no consistency issues with concurrent requests.
|
||||
It also ensures that running an import or export command from the same machine as a server instance will not result in port or other conflicts.
|
||||
|
||||
== Providing options for database connection parameters
|
||||
|
@ -31,7 +31,7 @@ As default, {project_name} will re-build automatically for the `export` and `imp
|
|||
If you have built an optimized version of {project_name} with the `build` command as outlined in <@links.server id="configuration"/>, use the command line option `--optimized` to have {project_name} skip the build check for a faster startup time.
|
||||
When doing this, remove the build time options from the command line and keep only the runtime options.
|
||||
|
||||
NOTE: if you do not use `--optimized` keep in mind that an `import` or `export` command will implicitly create or update an optimized image for you - if you are running the command from the same machine as a server instance, this may impact the next start of your server.
|
||||
NOTE: if you do not use `--optimized` keep in mind that an `import` or `export` command will implicitly create or update an optimized image for you - if you are running the command from the same machine as a server instance, this may impact the next start of your server.
|
||||
|
||||
== Exporting a Realm to a Directory
|
||||
|
||||
|
@ -130,8 +130,6 @@ realms and potentially lose state between server restarts.
|
|||
|
||||
To re-create realms you should explicitly run the `import` command prior to starting the server.
|
||||
|
||||
Importing the `master` realm is not supported because as it is a very sensitive operation.
|
||||
|
||||
== Importing and Exporting by using the Admin Console
|
||||
|
||||
You can also import and export a realm using the Admin Console. This functionality is
|
||||
|
@ -148,7 +146,7 @@ To export a realm using the Admin Console, perform these steps:
|
|||
. Click *Realm settings* in the menu.
|
||||
. Point to the *Action* menu in the top right corner of the realm settings screen, and select *Partial export*.
|
||||
+
|
||||
A list of resources appears along with the realm configuration.
|
||||
A list of resources appears along with the realm configuration.
|
||||
. Select the resources you want to export.
|
||||
. Click *Export*.
|
||||
|
||||
|
@ -162,7 +160,7 @@ In a similar way, you can import a previously exported realm. Perform these step
|
|||
|
||||
. Click *Realm settings* in the menu.
|
||||
. Point to the *Action* menu in the top right corner of the realm settings screen, and select *Partial import*.
|
||||
+
|
||||
+
|
||||
A prompt appears where you can select the file you want to import. Based on this file, you see the resources you can import along with the realm settings.
|
||||
. Click *Import*.
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ fips
|
|||
management-interface
|
||||
health
|
||||
configuration-metrics
|
||||
event-metrics
|
||||
tracing
|
||||
importExport
|
||||
vault
|
||||
|
|
|
@ -42,7 +42,6 @@ import org.keycloak.models.UserModel;
|
|||
import org.keycloak.models.utils.reflection.Property;
|
||||
import org.keycloak.models.utils.reflection.PropertyCriteria;
|
||||
import org.keycloak.models.utils.reflection.PropertyQueries;
|
||||
import org.keycloak.storage.ldap.LDAPConfig;
|
||||
import org.keycloak.storage.ldap.idm.model.LDAPDn;
|
||||
import org.keycloak.storage.ldap.idm.model.LDAPObject;
|
||||
import org.keycloak.storage.ldap.idm.query.Condition;
|
||||
|
@ -373,7 +372,7 @@ public class LDAPUtils {
|
|||
* Map key are the attributes names in lower case
|
||||
*/
|
||||
public static Map<String, Property<Object>> getUserModelProperties(){
|
||||
|
||||
|
||||
Map<String, Property<Object>> userModelProps = PropertyQueries.createQuery(UserModel.class)
|
||||
.addCriteria(new PropertyCriteria() {
|
||||
|
||||
|
|
|
@ -569,6 +569,11 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
|
|||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!isGroupInGroupPath(realm, kcGroup)) {
|
||||
// group being inspected is not managed by this mapper - return empty collection
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// TODO: with ranged search in AD we can improve the search using the specific range (not done for the moment)
|
||||
LDAPObject ldapGroup = loadLDAPGroupByName(kcGroup.getName());
|
||||
if (ldapGroup == null) {
|
||||
|
@ -703,18 +708,18 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
|
|||
@Override
|
||||
public Stream<GroupModel> getGroupsStream() {
|
||||
Stream<GroupModel> ldapGroupMappings = getLDAPGroupMappingsConverted();
|
||||
if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) {
|
||||
if (config.isTopLevelGroupsPath() && config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) {
|
||||
// Use just group mappings from LDAP
|
||||
return ldapGroupMappings;
|
||||
} else {
|
||||
// Merge mappings from both DB and LDAP
|
||||
// Merge mappings from both DB and LDAP (including groups assigned from other group mappers)
|
||||
return Stream.concat(ldapGroupMappings, super.getGroupsStream());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void joinGroup(GroupModel group) {
|
||||
if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY) {
|
||||
if (config.getMode() == LDAPGroupMapperMode.LDAP_ONLY && isGroupInGroupPath(realm, group)) {
|
||||
// We need to create new role mappings in LDAP
|
||||
cachedLDAPGroupMappings = null;
|
||||
addGroupMappingInLDAP(realm, group, ldapUser);
|
||||
|
@ -725,6 +730,11 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
|
|||
|
||||
@Override
|
||||
public void leaveGroup(GroupModel group) {
|
||||
// if user is leaving group not managed by this mapper, let the call proceed to the next mapper or to the DB.
|
||||
if (!isGroupInGroupPath(realm, group)) {
|
||||
super.leaveGroup(group);
|
||||
}
|
||||
|
||||
try (LDAPQuery ldapQuery = createGroupQuery(true)) {
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
Condition roleNameCondition = conditionsBuilder.equal(config.getGroupNameLdapAttribute(), group.getName());
|
||||
|
@ -756,7 +766,7 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
|
|||
|
||||
@Override
|
||||
public boolean isMemberOf(GroupModel group) {
|
||||
return RoleUtils.isDirectMember(getGroupsStream(),group);
|
||||
return isGroupInGroupPath(realm, group) && RoleUtils.isDirectMember(getGroupsStream(),group);
|
||||
}
|
||||
|
||||
protected Stream<GroupModel> getLDAPGroupMappingsConverted() {
|
||||
|
@ -795,6 +805,23 @@ public class GroupLDAPStorageMapper extends AbstractLDAPStorageMapper implements
|
|||
return config.isTopLevelGroupsPath() ? null : KeycloakModelUtils.findGroupByPath(session, realm, config.getGroupsPath());
|
||||
}
|
||||
|
||||
protected boolean isGroupInGroupPath(RealmModel realm, GroupModel group) {
|
||||
if (config.isTopLevelGroupsPath()) {
|
||||
return true; // any group is in the path of the top level path.
|
||||
}
|
||||
GroupModel groupPathGroup = KeycloakModelUtils.findGroupByPath(session, realm, config.getGroupsPath());
|
||||
if (groupPathGroup != null) {
|
||||
while(!groupPathGroup.getId().equals(group.getId())) {
|
||||
group = group.getParent();
|
||||
if (group == null) {
|
||||
return false; // we checked every ancestor group, and none matches the group path group.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new KC group from given LDAP group name in given KC parent group or the groups path.
|
||||
*/
|
||||
|
|
|
@ -248,7 +248,7 @@ public class MSADUserAccountControlStorageMapper extends AbstractLDAPStorageMapp
|
|||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
if (ldapProvider.getEditMode() == UserStorageProvider.EditMode.WRITABLE && getPwdLastSet() > 0) {
|
||||
if (UserStorageProvider.EditMode.WRITABLE.equals(ldapProvider.getEditMode())) {
|
||||
MSADUserAccountControlStorageMapper.logger.debugf("Going to propagate enabled=%s for ldapUser '%s' to MSAD", enabled, ldapUser.getDn().toString());
|
||||
|
||||
UserAccountControl control = getUserAccountControl(ldapUser);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<base href="${resourceUrl}/">
|
||||
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light${(properties.darkMode)?boolean?then(' dark', '')}">
|
||||
<meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
|
||||
<meta name="description" content="${properties.description!'The Account Console is a web-based interface for managing your account.'}">
|
||||
<title>${properties.title!'Account Management'}</title>
|
||||
<style>
|
||||
|
@ -58,7 +58,7 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<#if properties.darkMode?boolean>
|
||||
<#if darkMode>
|
||||
<script type="module" async blocking="render">
|
||||
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
|
|
@ -75,7 +75,6 @@ linkedAccounts=Linked accounts
|
|||
personalInfoDescription=Manage your basic information
|
||||
removeAccess=Remove access
|
||||
signingInDescription=Configure ways to sign in.
|
||||
somethingWentWrongDescription=Sorry, an unexpected error has occurred.
|
||||
personalInfo=Personal info
|
||||
removeCred=Remove {{name}}
|
||||
signOutAllDevices=Sign out all devices
|
||||
|
@ -98,10 +97,11 @@ permissionRequest=Permission requests - {{name}}
|
|||
add=Add
|
||||
error-invalid-value='{{0}}' has invalid value.
|
||||
somethingWentWrong=Something went wrong
|
||||
somethingWentWrongDescription=Sorry, an unexpected error has occurred.
|
||||
tryAgain=Try again
|
||||
rolesScope=If there is no role scope mapping defined, each user is permitted to use this client scope. If there are role scope mappings defined, the user must be a member of at least one of the roles.
|
||||
unShareError=Could not un-share the resource due to\: {{error}}
|
||||
ipAddress=IP address
|
||||
tryAgain=Try again
|
||||
resourceName=Resource name
|
||||
unlinkedEmpty=No unlinked providers
|
||||
done=Done
|
||||
|
@ -213,4 +213,5 @@ emptyUserOrganizationsInstructions=You have not joined any organizations yet.
|
|||
searchOrganization=Search for organization
|
||||
organizationList=List of organizations
|
||||
domains=Domains
|
||||
refresh=Refresh
|
||||
refresh=Refresh
|
||||
termsAndConditionsDeclined=You need to accept the Terms and Conditions to continue
|
|
@ -28,7 +28,7 @@
|
|||
"@patternfly/patternfly": "^5.4.1",
|
||||
"@patternfly/react-core": "^5.4.8",
|
||||
"@patternfly/react-icons": "^5.4.2",
|
||||
"@patternfly/react-table": "^5.4.8",
|
||||
"@patternfly/react-table": "^5.4.9",
|
||||
"i18next": "^23.16.4",
|
||||
"i18next-http-backend": "^2.6.2",
|
||||
"keycloak-js": "workspace:*",
|
||||
|
@ -41,12 +41,12 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@keycloak/keycloak-admin-client": "workspace:*",
|
||||
"@playwright/test": "^1.48.1",
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"lightningcss": "^1.27.0",
|
||||
"lightningcss": "^1.28.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-checker": "^0.8.0",
|
||||
"vite-plugin-dts": "^4.3.0"
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
export { PersonalInfo } from "./personal-info/PersonalInfo";
|
||||
export { ErrorPage } from "./root/ErrorPage";
|
||||
export { Header } from "./root/Header";
|
||||
export { PageNav } from "./root/PageNav";
|
||||
export { DeviceActivity } from "./account-security/DeviceActivity";
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
Page,
|
||||
Text,
|
||||
TextContent,
|
||||
TextVariants,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
|
||||
|
||||
type ErrorPageProps = {
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export const ErrorPage = (props: ErrorPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const error = useRouteError() ?? props.error;
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
function onRetry() {
|
||||
location.href = location.origin + location.pathname;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
title={t("somethingWentWrong")}
|
||||
titleIconVariant="danger"
|
||||
showClose={false}
|
||||
isOpen
|
||||
actions={[
|
||||
<Button key="tryAgain" variant="primary" onClick={onRetry}>
|
||||
{t("tryAgain")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<TextContent>
|
||||
<Text>{t("somethingWentWrongDescription")}</Text>
|
||||
{errorMessage && (
|
||||
<Text component={TextVariants.small}>{errorMessage}</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
</Modal>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string | null {
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return error.statusText;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
import { lazy } from "react";
|
||||
import type { IndexRouteObject, RouteObject } from "react-router-dom";
|
||||
|
||||
import { environment } from "./environment";
|
||||
import { Organizations } from "./organizations/Organizations";
|
||||
import { ErrorPage } from "./root/ErrorPage";
|
||||
import { Root } from "./root/Root";
|
||||
import { ErrorPage } from "@keycloak/keycloak-ui-shared";
|
||||
|
||||
const DeviceActivity = lazy(() => import("./account-security/DeviceActivity"));
|
||||
const LinkedAccounts = lazy(() => import("./account-security/LinkedAccounts"));
|
||||
|
@ -85,7 +84,7 @@ export const RootRoute: RouteObject = {
|
|||
PersonalInfoRoute,
|
||||
ResourcesRoute,
|
||||
ContentRoute,
|
||||
Oid4VciRoute,
|
||||
...(environment.features.isOid4VciEnabled ? [Oid4VciRoute] : []),
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -119,7 +119,7 @@ describe("Group test", () => {
|
|||
.assertNoSearchResultsMessageExist(true);
|
||||
});
|
||||
|
||||
it("Duplicate group", () => {
|
||||
it.skip("Duplicate group from item bar", () => {
|
||||
groupPage
|
||||
.duplicateGroupItem(groupNames[0], true)
|
||||
.assertNotificationGroupDuplicated();
|
||||
|
|
|
@ -74,7 +74,7 @@ export default class RoleMappingTab {
|
|||
selectRow(name: string, modal = false) {
|
||||
cy.get(modal ? ".pf-v5-c-modal-box " : "" + this.#namesColumn)
|
||||
.contains(name)
|
||||
.parent()
|
||||
.parents("tr")
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
});
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class AssociatedRolesPage {
|
|||
|
||||
cy.get(this.#addRoleTable)
|
||||
.contains(roleName)
|
||||
.parent()
|
||||
.parents("tr")
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
});
|
||||
|
@ -49,7 +49,7 @@ export default class AssociatedRolesPage {
|
|||
|
||||
cy.get(this.#addRoleTable)
|
||||
.contains(roleName)
|
||||
.parent()
|
||||
.parents("tr")
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
});
|
||||
|
@ -67,7 +67,7 @@ export default class AssociatedRolesPage {
|
|||
|
||||
cy.get(this.#addRoleTable)
|
||||
.contains(roleName)
|
||||
.parent()
|
||||
.parents("tr")
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
});
|
||||
|
|
|
@ -24,13 +24,13 @@ export default class RealmSettingsPage extends CommonPage {
|
|||
userProfileTab = "rs-user-profile-tab";
|
||||
tokensTab = "rs-tokens-tab";
|
||||
selectLoginTheme = "#kc-login-theme";
|
||||
loginThemeList = "[data-testid='select-login-theme']";
|
||||
loginThemeList = "[data-testid='select-loginTheme']";
|
||||
selectAccountTheme = "#kc-account-theme";
|
||||
accountThemeList = "[data-testid='select-account-theme']";
|
||||
accountThemeList = "[data-testid='select-accountTheme']";
|
||||
selectAdminTheme = "#kc-admin-ui-theme";
|
||||
adminThemeList = "[data-testid='select-admin-theme']";
|
||||
adminThemeList = "[data-testid='select-adminTheme']";
|
||||
selectEmailTheme = "#kc-email-theme";
|
||||
emailThemeList = "[data-testid='select-email-theme']";
|
||||
emailThemeList = "[data-testid='select-emailTheme']";
|
||||
ssoSessionIdleSelectMenu = "#kc-sso-session-idle-select-menu";
|
||||
ssoSessionIdleSelectMenuList = "#kc-sso-session-idle-select-menu ul";
|
||||
ssoSessionMaxSelectMenu = "#kc-sso-session-max-select-menu";
|
||||
|
|
|
@ -40,7 +40,7 @@ export default class UserRegistration {
|
|||
selectRow(name: string) {
|
||||
cy.get(this.#namesColumn)
|
||||
.contains(name)
|
||||
.parent()
|
||||
.parents("tr")
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
});
|
||||
|
|
|
@ -591,7 +591,6 @@ hour=時
|
|||
connectionTimeoutHelp=LDAP接続タイムアウト(ミリ秒単位)
|
||||
defaultSigAlgHelp=このレルムでトークンの署名に使用されるデフォルトのアルゴリズム
|
||||
save-admin-eventsHelp=有効の場合は、管理イベントがデータベースに保存され、管理コンソールで使用可能になります。
|
||||
policyGroups=どのユーザーがこのポリシーで許可されるか指定してください。
|
||||
forwardParametersHelp=最初のアプリケーションへのリクエストから取得し、外部IDPの認可エンドポイントへ転送されるOpenID Connect/OAuth標準以外のクエリー・パラメーター。複数のパラメーターをカンマ(,)で区切って入力できます。
|
||||
on=オン
|
||||
webAuthnPolicyRpId=リライング・パーティー・エンティティーID
|
||||
|
@ -804,3 +803,4 @@ resourceNameHelp=このリソースの一意な名前。この名前は、リソ
|
|||
duplicateEmailsAllowed=メールの重複
|
||||
policyClientHelp=このポリシーで許可されるクライアントを指定します。
|
||||
clientAuthenticatorTypeHelp=Keycloakサーバーに対してこのクライアントの認証に使用するクライアント認証方式を設定します。
|
||||
policyGroupsHelp=どのユーザーがこのポリシーで許可されるか指定してください。
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<base href="${resourceUrl}/">
|
||||
<link rel="icon" type="${properties.favIconType!'image/svg+xml'}" href="${resourceUrl}${properties.favIcon!'/favicon.svg'}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light${(properties.darkMode)?boolean?then(' dark', '')}">
|
||||
<meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
|
||||
<meta name="description" content="${properties.description!'The Keycloak Administration Console is a web-based interface for managing Keycloak.'}">
|
||||
<title>${properties.title!'Keycloak Administration Console'}</title>
|
||||
<style>
|
||||
|
@ -15,6 +15,8 @@
|
|||
|
||||
body, #app {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
@ -58,7 +60,7 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<#if properties.darkMode?boolean>
|
||||
<#if darkMode>
|
||||
<script type="module" async blocking="render">
|
||||
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
|
|
@ -231,7 +231,7 @@ eventTypes.USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR.name=User disabled by tempor
|
|||
deleteUser=Delete user
|
||||
addedNodeSuccess=Node successfully added
|
||||
eventTypes.INTROSPECT_TOKEN_ERROR.description=Introspect token error
|
||||
webAuthnPolicyUserVerificationRequirementHelp=Communicates to an authenticator to confirm actually verifying a user.
|
||||
webAuthnPolicyUserVerificationRequirementHelp=Communicates to an authenticator whether to require to verify a user.
|
||||
syncModes.import=Import
|
||||
realmSaveError=Realm could not be updated\: {{error}}
|
||||
authDataDescription=Represents a token carrying authorization data as a result of the processing of an authorization request. This representation is basically what Keycloak issues to clients asking for permission. Check the `authorization` claim for the permissions that where granted based on the current authorization request.
|
||||
|
@ -418,7 +418,7 @@ x509CertificateHelp=X509 Certificate encoded in PEM format
|
|||
samlEndpointsLabel=SAML 2.0 Service Provider Metadata
|
||||
passCurrentLocaleHelp=Pass the current locale to the identity provider as a ui_locales parameter.
|
||||
lessThan=Must be less than {{value}}
|
||||
webAuthnPolicyRequireResidentKeyHelp=It tells an authenticator create a public key credential as Discoverable Credential or not.
|
||||
webAuthnPolicyRequireResidentKeyHelp=It tells an authenticator whether to create a public key credential as a Discoverable Credential.
|
||||
logoutServiceRedirectBindingURL=Logout Service Redirect Binding URL
|
||||
createIdentityProviderSuccess=Identity provider successfully created
|
||||
emptyMappersInstructions=If you want to add mappers, please click the button below to add some predefined mappers or to configure a new mapper.
|
||||
|
@ -689,7 +689,7 @@ clientPolicySearch=Search client policy
|
|||
refreshTokens=Refresh tokens
|
||||
eventTypes.UPDATE_EMAIL_ERROR.description=Update email error
|
||||
credentials=Credentials
|
||||
webAuthnPolicyCreateTimeoutHelp=Timeout value for creating user's public key credential in seconds. if set to 0, this timeout option is not adapted.
|
||||
webAuthnPolicyCreateTimeoutHelp=The timeout value for creating the user's public key credential in seconds. If set to 0, this timeout option is not adapted.
|
||||
policyType.hotp=Counter based
|
||||
claimFilterValue=Essential claim value
|
||||
eventTypes.REGISTER_ERROR.name=Register error
|
||||
|
@ -1250,7 +1250,7 @@ realmRoles=Realm roles
|
|||
fineGrainOpenIdConnectConfigurationHelp=This section is used to configure advanced settings of this client related to OpenID Connect protocol.
|
||||
searchForUserDescription=This realm may have a federated provider. Viewing all users may cause the system to slow down, but it can be done by searching for "*". Please search for a user above.
|
||||
expirationHelp=Sets the expiration for events. Expired events are periodically deleted from the database.
|
||||
webAuthnPolicySignatureAlgorithmsHelp=What signature algorithms should be used for Authentication Assertion.
|
||||
webAuthnPolicySignatureAlgorithmsHelp=The signature algorithms that should be used for the Authentication Assertion.
|
||||
setToNowError=Error\! Failed to set notBefore to current date and time: {{error}}
|
||||
eventTypes.UNREGISTER_NODE_ERROR.description=Unregister node error
|
||||
clientScopeTypes.optional=Optional
|
||||
|
@ -1272,7 +1272,7 @@ revoke=Revoke
|
|||
admin=Admin
|
||||
syncUsersError=Could not sync users\: '{{error}}'
|
||||
generatedAccessTokenHelp=See the example access token, which will be generated and sent to the client when selected user is authenticated. You can see claims and roles that the token will contain based on the effective protocol mappers and role scope mappings and also based on the claims/roles assigned to user himself
|
||||
webAuthnPolicyAcceptableAaguidsHelp=The list of AAGUID of which an authenticator can be registered.
|
||||
webAuthnPolicyAcceptableAaguidsHelp=The list of allowed AAGUIDs of which an authenticator can be registered. An AAGUID is a 128-bit identifier indicating the authenticator's type (e.g., make and model).
|
||||
keyPasswordHelp=Password for the private key
|
||||
frontchannelLogout=Front channel logout
|
||||
clientUpdaterTrustedHostsTooltip=List of Hosts, which are trusted. In case that client registration/update request comes from the host/domain specified in this configuration, condition evaluates to true. You can use hostnames or IP addresses. If you use star at the beginning (for example '*.example.com' ) then whole domain example.com will be trusted.
|
||||
|
@ -1721,7 +1721,7 @@ mappedGroupAttributes=Mapped group attributes
|
|||
localization=Localization
|
||||
importConfig=Import config from file
|
||||
replyToDisplayNameHelp=A user-friendly name for the 'Reply-To' address (optional).
|
||||
webAuthnPolicyRpIdHelp=This is ID as WebAuthn Relying Party. It must be origin's effective domain.
|
||||
webAuthnPolicyRpIdHelp=The WebAuthn Relying Party ID (RpID). It must be the origin's effective domain, e.g. 'company.com' or 'auth.company.com'.
|
||||
signingKeysConfigExplain=If you enable the "Client signature required" below, you must configure the signing keys by generating or importing keys, and the client will sign their saml requests and responses. The signature will be validated.
|
||||
newClientProfile=Create client profile
|
||||
consoleDisplayConnectionUrlHelp=Connection URL to your LDAP server
|
||||
|
@ -2853,7 +2853,7 @@ credentialData=Data
|
|||
clientRolesConditionTooltip=Client roles, which will be checked during this condition evaluation. Condition evaluates to true if client has at least one client role with the name as the client roles specified in the configuration.
|
||||
invalidateSecret=Invalidate
|
||||
emptyPermissionInstructions=If you want to create a permission, please click the button below to create a resource-based or scope-based permission.
|
||||
webAuthnPolicyAvoidSameAuthenticatorRegisterHelp=Avoid registering the authenticator that has already been registered.
|
||||
webAuthnPolicyAvoidSameAuthenticatorRegisterHelp=Avoid registering an authenticator that has already been registered.
|
||||
memberofLdapAttribute=Member-of LDAP attribute
|
||||
supportedLocales=Supported locales
|
||||
showPasswordDataValue=Value
|
||||
|
@ -2936,7 +2936,7 @@ clientSecretHelp=The client secret registered with the identity provider. This f
|
|||
offlineSessionMax=Offline Session Max
|
||||
generatedUserInfoHelp=See the example User Info, which will be provided by the User Info Endpoint
|
||||
dynamicScopeFormat=Dynamic scope format
|
||||
webAuthnPolicyExtraOriginsHelp=The list of extra origin for non-web application.
|
||||
webAuthnPolicyExtraOriginsHelp=The list of extra origins for non-web applications.
|
||||
updatePermissionSuccess=Successfully updated the permission
|
||||
idpLinkSuccess=Identity provider has been linked
|
||||
removeAnnotationText=Remove annotation
|
||||
|
@ -3165,6 +3165,8 @@ logo=Logo
|
|||
avatarImage=Avatar image
|
||||
organizationsEnabled=Organizations
|
||||
organizationsEnabledHelp=If enabled, allows managing organizations. Otherwise, existing organizations are still kept but you will not be able to manage them anymore or authenticate their members.
|
||||
verifiableCredentialsEnabled=Verifiable Credentials
|
||||
verifiableCredentialsEnabledHelp=If enabled, allows managing verifiable credentials in this realm.
|
||||
organizations=Organizations
|
||||
organizationDetails=Organization details
|
||||
organizationsList=Organizations
|
||||
|
@ -3273,7 +3275,24 @@ groupDuplicated=Group duplicated
|
|||
duplicateAGroup=Duplicate group
|
||||
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
|
||||
duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups.
|
||||
darkModeEnabled=Dark mode
|
||||
darkModeEnabledHelp=If enabled the dark variant of the theme will be applied based on user preference through an operating system setting (e.g. light or dark mode) or a user agent setting, if disabled only the light variant will be used. This setting only applies to themes that support dark and light variants, on themes that do not support this feature it will have no effect.
|
||||
showMemberships=Show memberships
|
||||
showMembershipsTitle={{username}} Group Memberships
|
||||
noGroupMembershipsText=This user is not a member of any groups.
|
||||
noGroupMemberships=No memberships
|
||||
termsAndConditionsDeclined=You need to accept the Terms and Conditions to continue
|
||||
somethingWentWrong=Something went wrong
|
||||
somethingWentWrongDescription=Sorry, an unexpected error has occurred.
|
||||
tryAgain=Try again
|
||||
errorSavingTranslations=Error saving translations\: '{{error}}'
|
||||
clearCachesTitle=Clear Caches
|
||||
realmCache=Realm Cache
|
||||
userCache=User Cache
|
||||
keysCache=Keys Cache
|
||||
clearButtonTitle=Clear
|
||||
clearRealmCacheHelp=This will clear entries for all realms.
|
||||
clearUserCacheHelp=This will clear entries for all realms.
|
||||
clearKeysCacheHelp=Clears all entries from the cache of external public keys. These are keys of external clients or identity providers. This will clear all entries for all realms.
|
||||
clearCacheSuccess=Cache cleared successfully
|
||||
clearCacheError=Could not clear cache\: {{error}}
|
||||
|
|
|
@ -74,11 +74,11 @@
|
|||
"@keycloak/keycloak-admin-client": "workspace:*",
|
||||
"@keycloak/keycloak-ui-shared": "workspace:*",
|
||||
"@patternfly/patternfly": "^5.4.1",
|
||||
"@patternfly/react-code-editor": "^5.4.10",
|
||||
"@patternfly/react-code-editor": "^5.4.11",
|
||||
"@patternfly/react-core": "^5.4.8",
|
||||
"@patternfly/react-icons": "^5.4.2",
|
||||
"@patternfly/react-styles": "^5.4.1",
|
||||
"@patternfly/react-table": "^5.4.8",
|
||||
"@patternfly/react-table": "^5.4.9",
|
||||
"admin-ui": "file:",
|
||||
"dagre": "^0.8.5",
|
||||
"file-saver": "^2.0.5",
|
||||
|
@ -101,7 +101,7 @@
|
|||
"@4tw/cypress-drag-drop": "^2.2.5",
|
||||
"@testing-library/cypress": "^10.0.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
|
@ -110,12 +110,12 @@
|
|||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||
"cypress": "^13.15.1",
|
||||
"cypress": "^13.15.2",
|
||||
"cypress-axe": "^1.5.0",
|
||||
"cypress-split": "^1.24.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"ldap-server-mock": "^6.0.1",
|
||||
"lightningcss": "^1.27.0",
|
||||
"lightningcss": "^1.28.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"uuid": "^11.0.2",
|
||||
"vite": "^5.4.10",
|
||||
|
|
|
@ -23,6 +23,9 @@ import { HelpHeader } from "./components/help-enabler/HelpHeader";
|
|||
import { useRealm } from "./context/realm-context/RealmContext";
|
||||
import { useWhoAmI } from "./context/whoami/WhoAmI";
|
||||
import { toDashboard } from "./dashboard/routes/Dashboard";
|
||||
import useToggle from "./utils/useToggle";
|
||||
import { PageHeaderClearCachesModal } from "./PageHeaderClearCachesModal";
|
||||
import { useAccess } from "./context/access/Access";
|
||||
|
||||
const ManageAccountDropdownItem = () => {
|
||||
const { keycloak } = useEnvironment();
|
||||
|
@ -67,6 +70,20 @@ const ServerInfoDropdownItem = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ClearCachesDropdownItem = () => {
|
||||
const { t } = useTranslation();
|
||||
const [open, toggleModal] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownItem key="clear caches" onClick={() => toggleModal()}>
|
||||
{t("clearCachesTitle")}
|
||||
</DropdownItem>
|
||||
{open && <PageHeaderClearCachesModal onClose={() => toggleModal()} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const HelpDropdownItem = () => {
|
||||
const { t } = useTranslation();
|
||||
const { enabled, toggleHelp } = useHelp();
|
||||
|
@ -81,23 +98,34 @@ const HelpDropdownItem = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const kebabDropdownItems = [
|
||||
const kebabDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [
|
||||
<ManageAccountDropdownItem key="kebab Manage Account" />,
|
||||
<ServerInfoDropdownItem key="kebab Server Info" />,
|
||||
...(isMasterRealm && isManager
|
||||
? [<ClearCachesDropdownItem key="Clear Caches" />]
|
||||
: []),
|
||||
<HelpDropdownItem key="kebab Help" />,
|
||||
<Divider component="li" key="kebab sign out separator" />,
|
||||
<SignOutDropdownItem key="kebab Sign out" />,
|
||||
];
|
||||
|
||||
const userDropdownItems = [
|
||||
const userDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [
|
||||
<ManageAccountDropdownItem key="Manage Account" />,
|
||||
<ServerInfoDropdownItem key="Server info" />,
|
||||
...(isMasterRealm && isManager
|
||||
? [<ClearCachesDropdownItem key="Clear Caches" />]
|
||||
: []),
|
||||
<Divider component="li" key="sign out separator" />,
|
||||
<SignOutDropdownItem key="Sign out" />,
|
||||
];
|
||||
|
||||
const KebabDropdown = () => {
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { realm } = useRealm();
|
||||
const { hasAccess } = useAccess();
|
||||
|
||||
const isMasterRealm = realm === "master";
|
||||
const isManager = hasAccess("manage-realm");
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
|
@ -116,7 +144,9 @@ const KebabDropdown = () => {
|
|||
)}
|
||||
isOpen={isDropdownOpen}
|
||||
>
|
||||
<DropdownList>{kebabDropdownItems}</DropdownList>
|
||||
<DropdownList>
|
||||
{kebabDropdownItems(isMasterRealm, isManager)}
|
||||
</DropdownList>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
@ -124,6 +154,11 @@ const KebabDropdown = () => {
|
|||
const UserDropdown = () => {
|
||||
const { whoAmI } = useWhoAmI();
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { realm } = useRealm();
|
||||
const { hasAccess } = useAccess();
|
||||
|
||||
const isMasterRealm = realm === "master";
|
||||
const isManager = hasAccess("manage-realm");
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
|
@ -140,7 +175,7 @@ const UserDropdown = () => {
|
|||
</MenuToggle>
|
||||
)}
|
||||
>
|
||||
<DropdownList>{userDropdownItems}</DropdownList>
|
||||
<DropdownList>{userDropdownItems(isMasterRealm, isManager)}</DropdownList>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
101
js/apps/admin-ui/src/PageHeaderClearCachesModal.tsx
Normal file
101
js/apps/admin-ui/src/PageHeaderClearCachesModal.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
AlertVariant,
|
||||
Button,
|
||||
Flex,
|
||||
FlexItem,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
} from "@patternfly/react-core";
|
||||
import { useRealm } from "./context/realm-context/RealmContext";
|
||||
import { useAdminClient } from "./admin-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HelpItem, useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||
|
||||
export type ClearCachesModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
export const PageHeaderClearCachesModal = ({
|
||||
onClose,
|
||||
}: ClearCachesModalProps) => {
|
||||
const { realm: realmName } = useRealm();
|
||||
const { t } = useTranslation();
|
||||
const { adminClient } = useAdminClient();
|
||||
const { addError, addAlert } = useAlerts();
|
||||
|
||||
const clearCache =
|
||||
(clearCacheFn: typeof adminClient.cache.clearRealmCache) =>
|
||||
async (realm: string) => {
|
||||
try {
|
||||
await clearCacheFn({ realm });
|
||||
addAlert(t("clearCacheSuccess"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("clearCacheError", error);
|
||||
}
|
||||
};
|
||||
const clearRealmCache = clearCache(adminClient.cache.clearRealmCache);
|
||||
const clearUserCache = clearCache(adminClient.cache.clearUserCache);
|
||||
const clearKeysCache = clearCache(adminClient.cache.clearKeysCache);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("clearCachesTitle")}
|
||||
variant={ModalVariant.small}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<List isPlain isBordered>
|
||||
<ListItem>
|
||||
<Flex justifyContent={{ default: "justifyContentSpaceBetween" }}>
|
||||
<FlexItem>
|
||||
{t("realmCache")}{" "}
|
||||
<HelpItem
|
||||
helpText={t("clearRealmCacheHelp")}
|
||||
fieldLabelId="clearRealmCacheHelp"
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<Button onClick={() => clearRealmCache(realmName)}>
|
||||
{t("clearButtonTitle")}
|
||||
</Button>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Flex justifyContent={{ default: "justifyContentSpaceBetween" }}>
|
||||
<FlexItem>
|
||||
{t("userCache")}{" "}
|
||||
<HelpItem
|
||||
helpText={t("clearUserCacheHelp")}
|
||||
fieldLabelId="clearUserCacheHelp"
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<Button onClick={() => clearUserCache(realmName)}>
|
||||
{t("clearButtonTitle")}
|
||||
</Button>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Flex justifyContent={{ default: "justifyContentSpaceBetween" }}>
|
||||
<FlexItem>
|
||||
{t("keysCache")}{" "}
|
||||
<HelpItem
|
||||
helpText={t("clearKeysCacheHelp")}
|
||||
fieldLabelId="clearKeysCacheHelp"
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<Button onClick={() => clearKeysCache(realmName)}>
|
||||
{t("clearButtonTitle")}
|
||||
</Button>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -66,6 +66,7 @@ const USER_VERIFY = [
|
|||
type WeauthnSelectProps = {
|
||||
name: string;
|
||||
label: string;
|
||||
labelIcon?: string;
|
||||
options: readonly string[];
|
||||
labelPrefix?: string;
|
||||
isMultiSelect?: boolean;
|
||||
|
@ -74,6 +75,7 @@ type WeauthnSelectProps = {
|
|||
const WebauthnSelect = ({
|
||||
name,
|
||||
label,
|
||||
labelIcon,
|
||||
options,
|
||||
labelPrefix,
|
||||
isMultiSelect = false,
|
||||
|
@ -82,7 +84,8 @@ const WebauthnSelect = ({
|
|||
return (
|
||||
<SelectControl
|
||||
name={name}
|
||||
label={t(label)}
|
||||
label={label}
|
||||
labelIcon={labelIcon}
|
||||
variant={isMultiSelect ? "typeaheadMulti" : "single"}
|
||||
controller={{ defaultValue: options[0] }}
|
||||
options={options.map((option) => ({
|
||||
|
@ -165,7 +168,8 @@ export const WebauthnPolicy = ({
|
|||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}SignatureAlgorithms`}
|
||||
label="webAuthnPolicySignatureAlgorithms"
|
||||
label={t("webAuthnPolicySignatureAlgorithms")}
|
||||
labelIcon={t("webAuthnPolicySignatureAlgorithmsHelp")}
|
||||
options={SIGNATURE_ALGORITHMS}
|
||||
isMultiSelect
|
||||
/>
|
||||
|
@ -176,32 +180,36 @@ export const WebauthnPolicy = ({
|
|||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}AttestationConveyancePreference`}
|
||||
label="webAuthnPolicyAttestationConveyancePreference"
|
||||
label={t("webAuthnPolicyAttestationConveyancePreference")}
|
||||
labelIcon={t("webAuthnPolicyAttestationConveyancePreferenceHelp")}
|
||||
options={ATTESTATION_PREFERENCE}
|
||||
labelPrefix="attestationPreference"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}AuthenticatorAttachment`}
|
||||
label="webAuthnPolicyAuthenticatorAttachment"
|
||||
label={t("webAuthnPolicyAuthenticatorAttachment")}
|
||||
labelIcon={t("webAuthnPolicyAuthenticatorAttachmentHelp")}
|
||||
options={AUTHENTICATOR_ATTACHMENT}
|
||||
labelPrefix="authenticatorAttachment"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}RequireResidentKey`}
|
||||
label="webAuthnPolicyRequireResidentKey"
|
||||
label={t("webAuthnPolicyRequireResidentKey")}
|
||||
labelIcon={t("webAuthnPolicyRequireResidentKeyHelp")}
|
||||
options={RESIDENT_KEY_OPTIONS}
|
||||
labelPrefix="residentKey"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}UserVerificationRequirement`}
|
||||
label="webAuthnPolicyUserVerificationRequirement"
|
||||
label={t("webAuthnPolicyUserVerificationRequirement")}
|
||||
labelIcon={t("webAuthnPolicyUserVerificationRequirementHelp")}
|
||||
options={USER_VERIFY}
|
||||
labelPrefix="userVerify"
|
||||
/>
|
||||
<TimeSelectorControl
|
||||
name={`${namePrefix}CreateTimeout`}
|
||||
label={t("webAuthnPolicyCreateTimeout")}
|
||||
labelIcon={t("otpPolicyPeriodHelp")}
|
||||
labelIcon={t("webAuthnPolicyCreateTimeoutHelp")}
|
||||
units={["second", "minute", "hour"]}
|
||||
controller={{
|
||||
defaultValue: 0,
|
||||
|
|
|
@ -185,6 +185,7 @@ export default function EditClientScope() {
|
|||
realm,
|
||||
id: clientScope!.id!,
|
||||
mapperId: mapper.id!,
|
||||
viewMode: "new",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
|
@ -256,7 +257,12 @@ export default function EditClientScope() {
|
|||
onAdd={addMappers}
|
||||
onDelete={onDelete}
|
||||
detailLink={(id) =>
|
||||
toMapper({ realm, id: clientScope.id!, mapperId: id! })
|
||||
toMapper({
|
||||
realm,
|
||||
id: clientScope.id!,
|
||||
mapperId: id!,
|
||||
viewMode: "edit",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
|
|
|
@ -34,7 +34,7 @@ export default function MappingDetails() {
|
|||
const { t } = useTranslation();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const { id, mapperId } = useParams<MapperParams>();
|
||||
const { id, mapperId, viewMode } = useParams<MapperParams>();
|
||||
const form = useForm();
|
||||
const { setValue, handleSubmit } = form;
|
||||
const [mapping, setMapping] = useState<ProtocolMapperTypeRepresentation>();
|
||||
|
@ -46,8 +46,7 @@ export default function MappingDetails() {
|
|||
const navigate = useNavigate();
|
||||
const { realm } = useRealm();
|
||||
const serverInfo = useServerInfo();
|
||||
const isGuid = /^[{]?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}[}]?$/;
|
||||
const isUpdating = !!isGuid.exec(mapperId);
|
||||
const isUpdating = viewMode === "edit";
|
||||
|
||||
const isOnClientScope = !!useMatch(MapperRoute.path);
|
||||
const toDetails = () =>
|
||||
|
|
|
@ -7,12 +7,13 @@ export type MapperParams = {
|
|||
realm: string;
|
||||
id: string;
|
||||
mapperId: string;
|
||||
viewMode: "edit" | "new";
|
||||
};
|
||||
|
||||
const MappingDetails = lazy(() => import("../details/MappingDetails"));
|
||||
|
||||
export const MapperRoute: AppRouteObject = {
|
||||
path: "/:realm/client-scopes/:id/mappers/:mapperId",
|
||||
path: "/:realm/client-scopes/:id/mappers/:mapperId/:viewMode",
|
||||
element: <MappingDetails />,
|
||||
breadcrumb: (t) => t("mappingDetails"),
|
||||
handle: {
|
||||
|
|
|
@ -7,6 +7,7 @@ export type MapperParams = {
|
|||
realm: string;
|
||||
id: string;
|
||||
mapperId: string;
|
||||
viewMode: "edit" | "new";
|
||||
};
|
||||
|
||||
const MappingDetails = lazy(
|
||||
|
@ -14,7 +15,7 @@ const MappingDetails = lazy(
|
|||
);
|
||||
|
||||
export const MapperRoute: AppRouteObject = {
|
||||
path: "/:realm/clients/:id/clientScopes/dedicated/mappers/:mapperId",
|
||||
path: "/:realm/clients/:id/clientScopes/dedicated/mappers/:mapperId/:viewMode",
|
||||
element: <MappingDetails />,
|
||||
breadcrumb: (t) => t("mappingDetails"),
|
||||
handle: {
|
||||
|
|
|
@ -60,6 +60,7 @@ export default function DedicatedScopes() {
|
|||
realm,
|
||||
id: client.id!,
|
||||
mapperId: mapper.id!,
|
||||
viewMode: "new",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
|
@ -122,7 +123,7 @@ export default function DedicatedScopes() {
|
|||
onAdd={addMappers}
|
||||
onDelete={onDeleteMapper}
|
||||
detailLink={(mapperId) =>
|
||||
toMapper({ realm, id: client.id!, mapperId })
|
||||
toMapper({ realm, id: client.id!, mapperId, viewMode: "edit" })
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useHelp } from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
Divider,
|
||||
Dropdown,
|
||||
|
@ -7,14 +8,12 @@ import {
|
|||
Split,
|
||||
SplitItem,
|
||||
Switch,
|
||||
TextContent,
|
||||
} from "@patternfly/react-core";
|
||||
import { ExternalLinkAltIcon, HelpIcon } from "@patternfly/react-icons";
|
||||
import { HelpIcon } from "@patternfly/react-icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import helpUrls from "../../help-urls";
|
||||
import { useHelp } from "@keycloak/keycloak-ui-shared";
|
||||
import { FormattedLink } from "../external-link/FormattedLink";
|
||||
|
||||
import "./help-header.css";
|
||||
|
||||
|
@ -24,21 +23,14 @@ export const HelpHeader = () => {
|
|||
const { t } = useTranslation();
|
||||
|
||||
const dropdownItems = [
|
||||
<DropdownItem
|
||||
key="link"
|
||||
id="link"
|
||||
href={helpUrls.documentationUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<Split>
|
||||
<SplitItem isFilled>{t("documentation")}</SplitItem>
|
||||
<SplitItem>
|
||||
<ExternalLinkAltIcon />
|
||||
</SplitItem>
|
||||
</Split>
|
||||
<DropdownItem key="link" id="link">
|
||||
<FormattedLink
|
||||
href={helpUrls.documentationUrl}
|
||||
title={t("documentation")}
|
||||
/>
|
||||
</DropdownItem>,
|
||||
<Divider key="divide" />,
|
||||
<DropdownItem key="enable" id="enable">
|
||||
<DropdownItem key="enable" id="enable" description={t("helpToggleInfo")}>
|
||||
<Split>
|
||||
<SplitItem isFilled>{t("enableHelpMode")}</SplitItem>
|
||||
<SplitItem>
|
||||
|
@ -52,9 +44,6 @@ export const HelpHeader = () => {
|
|||
/>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
<TextContent className="keycloak_help-header-description">
|
||||
{t("helpToggleInfo")}
|
||||
</TextContent>
|
||||
</DropdownItem>,
|
||||
];
|
||||
return (
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
|
||||
import {
|
||||
KeycloakDataTable,
|
||||
ListEmptyState,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
|
@ -9,14 +14,13 @@ import {
|
|||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { FilterIcon } from "@patternfly/react-icons";
|
||||
import { cellWidth, TableText } from "@patternfly/react-table";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAdminClient } from "../../admin-client";
|
||||
import { useAccess } from "../../context/access/Access";
|
||||
import { translationFormatter } from "../../utils/translationFormatter";
|
||||
import useLocaleSort from "../../utils/useLocaleSort";
|
||||
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
|
||||
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
|
||||
import { ResourcesKey, Row, ServiceRole } from "./RoleMapping";
|
||||
import { getAvailableRoles } from "./queries";
|
||||
import { getAvailableClientRoles } from "./resource";
|
||||
|
@ -33,6 +37,15 @@ type AddRoleMappingModalProps = {
|
|||
|
||||
type FilterType = "roles" | "clients";
|
||||
|
||||
const RoleDescription = ({ role }: { role: RoleRepresentation }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TableText wrapModifier="truncate">
|
||||
{translationFormatter(t)(role.description) as string}
|
||||
</TableText>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddRoleMappingModal = ({
|
||||
id,
|
||||
name,
|
||||
|
@ -184,11 +197,12 @@ export const AddRoleMappingModal = ({
|
|||
{
|
||||
name: "name",
|
||||
cellRenderer: ServiceRole,
|
||||
transforms: [cellWidth(20)],
|
||||
},
|
||||
{
|
||||
name: "role.description",
|
||||
displayKey: "description",
|
||||
cellFormatters: [translationFormatter(t)],
|
||||
cellRenderer: RoleDescription,
|
||||
},
|
||||
]}
|
||||
emptyState={
|
||||
|
|
|
@ -79,6 +79,10 @@ export class WhoAmI {
|
|||
public isTemporary(): boolean {
|
||||
return this.#me?.temporary ?? false;
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return !this.#me;
|
||||
}
|
||||
}
|
||||
|
||||
type WhoAmIProps = {
|
||||
|
|
|
@ -92,9 +92,9 @@ const Fields = ({ readOnly }: DiscoverySettingsProps) => {
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<TextControl
|
||||
<TextAreaControl
|
||||
name="config.publicKeySignatureVerifier"
|
||||
label="validatingPublicKey"
|
||||
label={t("validatingPublicKey")}
|
||||
/>
|
||||
<TextControl
|
||||
name="config.publicKeySignatureVerifierKeyId"
|
||||
|
|
|
@ -227,6 +227,13 @@ function RealmSettingsGeneralTabForm({
|
|||
labelIcon={t("organizationsEnabledHelp")}
|
||||
/>
|
||||
)}
|
||||
{isOpenid4vciEnabled && (
|
||||
<DefaultSwitchControl
|
||||
name="verifiableCredentialsEnabled"
|
||||
label={t("verifiableCredentialsEnabled")}
|
||||
labelIcon={t("verifiableCredentialsEnabledHelp")}
|
||||
/>
|
||||
)}
|
||||
<SelectControl
|
||||
name="unmanagedAttributePolicy"
|
||||
label={t("unmanagedAttributes")}
|
||||
|
@ -266,7 +273,7 @@ function RealmSettingsGeneralTabForm({
|
|||
title={t("samlIdentityProviderMetadata")}
|
||||
/>
|
||||
</StackItem>
|
||||
{isOpenid4vciEnabled && (
|
||||
{isOpenid4vciEnabled && realm.verifiableCredentialsEnabled && (
|
||||
<StackItem>
|
||||
<FormattedLink
|
||||
href={`${addTrailingSlash(
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type {
|
||||
UserProfileAttribute,
|
||||
UserProfileConfig,
|
||||
|
@ -61,6 +62,7 @@ type UserProfileAttributeFormFields = Omit<
|
|||
annotations: IndexedAnnotations[];
|
||||
hasSelector: boolean;
|
||||
hasRequiredScopes: boolean;
|
||||
translations?: TranslationForm[];
|
||||
};
|
||||
|
||||
type Attribute = {
|
||||
|
@ -172,7 +174,8 @@ export default function NewAttributeSettings() {
|
|||
|
||||
useFetch(
|
||||
async () => {
|
||||
const translationsToSave: any[] = [];
|
||||
const translationsToSave: Translations[] = [];
|
||||
|
||||
await Promise.all(
|
||||
combinedLocales.map(async (selectedLocale) => {
|
||||
try {
|
||||
|
@ -183,55 +186,50 @@ export default function NewAttributeSettings() {
|
|||
});
|
||||
|
||||
const formData = form.getValues();
|
||||
const formattedKey = formData.displayName?.substring(
|
||||
2,
|
||||
formData.displayName.length - 1,
|
||||
);
|
||||
const filteredTranslations: Array<{
|
||||
locale: string;
|
||||
value: string;
|
||||
}> = [];
|
||||
const allTranslations = Object.entries(translations).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
const formattedKey =
|
||||
formData.displayName?.substring(
|
||||
2,
|
||||
formData.displayName.length - 1,
|
||||
) || "";
|
||||
|
||||
const filteredTranslations: TranslationForm[] = Object.entries(
|
||||
translations,
|
||||
)
|
||||
.filter(([key]) => key === formattedKey)
|
||||
.map(([_, value]) => ({
|
||||
locale: selectedLocale,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
allTranslations.forEach((translation) => {
|
||||
if (translation.key === formattedKey) {
|
||||
filteredTranslations.push({
|
||||
locale: selectedLocale,
|
||||
value: translation.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const translationToSave: any = {
|
||||
key: formattedKey,
|
||||
translations: filteredTranslations,
|
||||
};
|
||||
|
||||
translationsToSave.push(translationToSave);
|
||||
if (filteredTranslations.length > 0) {
|
||||
translationsToSave.push({
|
||||
key: formattedKey,
|
||||
translations: filteredTranslations,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching translations for ${selectedLocale}:`,
|
||||
error,
|
||||
);
|
||||
addError("errorSavingTranslations", error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return translationsToSave;
|
||||
},
|
||||
(translationsToSaveData) => {
|
||||
setTranslationsData(() => ({
|
||||
key: translationsToSaveData[0].key,
|
||||
translations: translationsToSaveData.flatMap(
|
||||
(translationData) => translationData.translations,
|
||||
),
|
||||
}));
|
||||
(translationsToSave) => {
|
||||
if (translationsToSave && translationsToSave.length > 0) {
|
||||
const allTranslations = translationsToSave.flatMap(
|
||||
(translation) => translation.translations,
|
||||
);
|
||||
|
||||
setTranslationsData({
|
||||
key: translationsToSave[0].key,
|
||||
translations: allTranslations,
|
||||
});
|
||||
|
||||
form.setValue("translations", allTranslations);
|
||||
}
|
||||
},
|
||||
[combinedLocales],
|
||||
[combinedLocales, realmName, form],
|
||||
);
|
||||
|
||||
useFetch(
|
||||
|
@ -282,8 +280,9 @@ export default function NewAttributeSettings() {
|
|||
|
||||
const saveTranslations = async () => {
|
||||
try {
|
||||
const nonEmptyTranslations = translationsData.translations.map(
|
||||
async (translation) => {
|
||||
const nonEmptyTranslations = translationsData.translations
|
||||
.filter((translation) => translation.value.trim() !== "")
|
||||
.map(async (translation) => {
|
||||
try {
|
||||
await adminClient.realms.addLocalization(
|
||||
{
|
||||
|
@ -293,11 +292,11 @@ export default function NewAttributeSettings() {
|
|||
},
|
||||
translation.value,
|
||||
);
|
||||
} catch {
|
||||
console.error(`Error saving translation for ${translation.locale}`);
|
||||
} catch (error) {
|
||||
addError(t("errorSavingTranslations"), error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(nonEmptyTranslations);
|
||||
} catch (error) {
|
||||
console.error(`Error saving translations: ${error}`);
|
||||
|
@ -377,7 +376,7 @@ export default function NewAttributeSettings() {
|
|||
(translation) => translation.value.trim() !== "",
|
||||
);
|
||||
|
||||
if (!hasNonEmptyTranslations && !formFields.displayName) {
|
||||
if (!hasNonEmptyTranslations) {
|
||||
addError("createAttributeError", t("translationError"));
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,20 +1,11 @@
|
|||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
import {
|
||||
HelpItem,
|
||||
KeycloakSelect,
|
||||
SelectVariant,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
Button,
|
||||
FormGroup,
|
||||
PageSection,
|
||||
SelectOption,
|
||||
} from "@patternfly/react-core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { SelectControl } from "@keycloak/keycloak-ui-shared";
|
||||
import { ActionGroup, Button, PageSection } from "@patternfly/react-core";
|
||||
import { useEffect } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormAccess } from "../components/form/FormAccess";
|
||||
import { DefaultSwitchControl } from "../components/SwitchControl";
|
||||
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
||||
import { convertToFormValues } from "../util";
|
||||
|
||||
|
@ -29,12 +20,8 @@ export const RealmSettingsThemesTab = ({
|
|||
}: RealmSettingsThemesTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loginThemeOpen, setLoginThemeOpen] = useState(false);
|
||||
const [accountThemeOpen, setAccountThemeOpen] = useState(false);
|
||||
const [adminUIThemeOpen, setAdminUIThemeOpen] = useState(false);
|
||||
const [emailThemeOpen, setEmailThemeOpen] = useState(false);
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<RealmRepresentation>();
|
||||
const form = useForm<RealmRepresentation>();
|
||||
const { handleSubmit, setValue } = form;
|
||||
const themeTypes = useServerInfo().themes!;
|
||||
|
||||
const setupForm = () => {
|
||||
|
@ -42,6 +29,11 @@ export const RealmSettingsThemesTab = ({
|
|||
};
|
||||
useEffect(setupForm, []);
|
||||
|
||||
const appendEmptyChoice = (items: { key: string; value: string }[]) => [
|
||||
{ key: "", value: t("choose") },
|
||||
...items,
|
||||
];
|
||||
|
||||
return (
|
||||
<PageSection variant="light">
|
||||
<FormAccess
|
||||
|
@ -50,178 +42,70 @@ export const RealmSettingsThemesTab = ({
|
|||
className="pf-v5-u-mt-lg"
|
||||
onSubmit={handleSubmit(save)}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("loginTheme")}
|
||||
fieldId="kc-login-theme"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("loginThemeHelp")}
|
||||
fieldLabelId="loginTheme"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
<FormProvider {...form}>
|
||||
<DefaultSwitchControl
|
||||
name="attributes.darkMode"
|
||||
labelIcon={t("darkModeEnabledHelp")}
|
||||
label={t("darkModeEnabled")}
|
||||
defaultValue="true"
|
||||
stringify
|
||||
/>
|
||||
<SelectControl
|
||||
id="kc-login-theme"
|
||||
name="loginTheme"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="kc-login-theme"
|
||||
onToggle={() => setLoginThemeOpen(!loginThemeOpen)}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value as string);
|
||||
setLoginThemeOpen(false);
|
||||
}}
|
||||
selections={field.value}
|
||||
variant={SelectVariant.single}
|
||||
isOpen={loginThemeOpen}
|
||||
placeholderText={t("selectATheme")}
|
||||
data-testid="select-login-theme"
|
||||
aria-label={t("selectLoginTheme")}
|
||||
>
|
||||
{themeTypes.login.map((theme, idx) => (
|
||||
<SelectOption
|
||||
selected={theme.name === field.value}
|
||||
key={`login-theme-${idx}`}
|
||||
value={theme.name}
|
||||
>
|
||||
{theme.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
label={t("loginTheme")}
|
||||
labelIcon={t("loginThemeHelp")}
|
||||
controller={{ defaultValue: "" }}
|
||||
options={appendEmptyChoice(
|
||||
themeTypes.login.map((theme) => ({
|
||||
key: theme.name,
|
||||
value: theme.name,
|
||||
})),
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("accountTheme")}
|
||||
fieldId="kc-account-theme"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("accountThemeHelp")}
|
||||
fieldLabelId="accountTheme"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
<SelectControl
|
||||
id="kc-account-theme"
|
||||
name="accountTheme"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="kc-account-theme"
|
||||
onToggle={() => setAccountThemeOpen(!accountThemeOpen)}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value as string);
|
||||
setAccountThemeOpen(false);
|
||||
}}
|
||||
selections={field.value}
|
||||
variant={SelectVariant.single}
|
||||
aria-label={t("selectAccountTheme")}
|
||||
isOpen={accountThemeOpen}
|
||||
placeholderText={t("selectATheme")}
|
||||
data-testid="select-account-theme"
|
||||
>
|
||||
{themeTypes.account
|
||||
.filter((theme) => theme.name !== "base")
|
||||
.map((theme, idx) => (
|
||||
<SelectOption
|
||||
selected={theme.name === field.value}
|
||||
key={`account-theme-${idx}`}
|
||||
value={theme.name}
|
||||
>
|
||||
{t(theme.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
label={t("accountTheme")}
|
||||
labelIcon={t("accountThemeHelp")}
|
||||
placeholderText={t("selectATheme")}
|
||||
controller={{ defaultValue: "" }}
|
||||
options={appendEmptyChoice(
|
||||
themeTypes.account.map((theme) => ({
|
||||
key: theme.name,
|
||||
value: theme.name,
|
||||
})),
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("adminTheme")}
|
||||
fieldId="kc-admin-ui-theme"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("adminThemeHelp")}
|
||||
fieldLabelId="adminTheme"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
<SelectControl
|
||||
id="kc-admin-theme"
|
||||
name="adminTheme"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="kc-admin-ui-theme"
|
||||
onToggle={() => setAdminUIThemeOpen(!adminUIThemeOpen)}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value as string);
|
||||
setAdminUIThemeOpen(false);
|
||||
}}
|
||||
selections={field.value}
|
||||
variant={SelectVariant.single}
|
||||
isOpen={adminUIThemeOpen}
|
||||
placeholderText={t("selectATheme")}
|
||||
data-testid="select-admin-theme"
|
||||
aria-label={t("selectAdminTheme")}
|
||||
>
|
||||
{themeTypes.admin
|
||||
.filter((theme) => theme.name !== "base")
|
||||
.map((theme, idx) => (
|
||||
<SelectOption
|
||||
selected={theme.name === field.value}
|
||||
key={`admin-theme-${idx}`}
|
||||
value={theme.name}
|
||||
>
|
||||
{t(theme.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
label={t("adminTheme")}
|
||||
labelIcon={t("adminThemeHelp")}
|
||||
placeholderText={t("selectATheme")}
|
||||
controller={{ defaultValue: "" }}
|
||||
options={appendEmptyChoice(
|
||||
themeTypes.admin.map((theme) => ({
|
||||
key: theme.name,
|
||||
value: theme.name,
|
||||
})),
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("emailTheme")}
|
||||
fieldId="kc-email-theme"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("emailThemeHelp")}
|
||||
fieldLabelId="emailTheme"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
<SelectControl
|
||||
id="kc-email-theme"
|
||||
name="emailTheme"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="kc-email-theme"
|
||||
onToggle={() => setEmailThemeOpen(!emailThemeOpen)}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value as string);
|
||||
setEmailThemeOpen(false);
|
||||
}}
|
||||
selections={field.value}
|
||||
variant={SelectVariant.single}
|
||||
isOpen={emailThemeOpen}
|
||||
placeholderText={t("selectATheme")}
|
||||
data-testid="select-email-theme"
|
||||
aria-label={t("selectEmailTheme")}
|
||||
>
|
||||
{themeTypes.email.map((theme, idx) => (
|
||||
<SelectOption
|
||||
selected={theme.name === field.value}
|
||||
key={`email-theme-${idx}`}
|
||||
value={theme.name}
|
||||
>
|
||||
{t(theme.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
label={t("emailTheme")}
|
||||
labelIcon={t("emailThemeHelp")}
|
||||
placeholderText={t("selectATheme")}
|
||||
controller={{ defaultValue: "" }}
|
||||
options={appendEmptyChoice(
|
||||
themeTypes.email.map((theme) => ({
|
||||
key: theme.name,
|
||||
value: theme.name,
|
||||
})),
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormProvider>
|
||||
<ActionGroup>
|
||||
<Button variant="primary" type="submit" data-testid="themes-tab-save">
|
||||
{t("save")}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
|
||||
import {
|
||||
HelpItem,
|
||||
|
@ -17,7 +18,6 @@ import {
|
|||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { GlobeRouteIcon } from "@patternfly/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FormProvider,
|
||||
|
@ -27,7 +27,6 @@ import {
|
|||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useAdminClient } from "../../admin-client";
|
||||
import { FormAccess } from "../../components/form/FormAccess";
|
||||
import { KeyValueInput } from "../../components/key-value-form/KeyValueInput";
|
||||
import type { KeyValueType } from "../../components/key-value-form/key-value-convert";
|
||||
|
@ -44,6 +43,8 @@ import {
|
|||
AddTranslationsDialog,
|
||||
TranslationsType,
|
||||
} from "./attribute/AddTranslationsDialog";
|
||||
import { GlobeRouteIcon } from "@patternfly/react-icons";
|
||||
import { useAdminClient } from "../../admin-client";
|
||||
|
||||
function parseAnnotations(input: Record<string, unknown>): KeyValueType[] {
|
||||
return Object.entries(input).reduce((p, [key, value]) => {
|
||||
|
@ -77,11 +78,6 @@ type Translations = {
|
|||
translations: TranslationForm[];
|
||||
};
|
||||
|
||||
type TranslationsSets = {
|
||||
displayHeader: Translations;
|
||||
displayDescription: Translations;
|
||||
};
|
||||
|
||||
const defaultValues: FormFields = {
|
||||
annotations: [],
|
||||
displayDescription: "",
|
||||
|
@ -112,20 +108,21 @@ export default function AttributesGroupForm() {
|
|||
const [addTranslationsModalOpen, toggleModal] = useToggle();
|
||||
const regexPattern = /\$\{([^}]+)\}/;
|
||||
const [type, setType] = useState<TranslationsType>();
|
||||
const [translationsData, setTranslationsData] = useState<TranslationsSets>({
|
||||
|
||||
const [translationsData, setTranslationsData] = useState({
|
||||
displayHeader: {
|
||||
key: "",
|
||||
translations: [],
|
||||
translations: [] as TranslationForm[],
|
||||
},
|
||||
displayDescription: {
|
||||
key: "",
|
||||
translations: [],
|
||||
translations: [] as TranslationForm[],
|
||||
},
|
||||
});
|
||||
|
||||
const matchingGroup = useMemo(
|
||||
() => config?.groups?.find(({ name }) => name === params.name),
|
||||
[config?.groups],
|
||||
[config?.groups, params.name],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -138,120 +135,98 @@ export default function AttributesGroupForm() {
|
|||
: [];
|
||||
|
||||
form.reset({ ...defaultValues, ...matchingGroup, annotations });
|
||||
}, [matchingGroup]);
|
||||
}, [matchingGroup, form]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"displayHeader",
|
||||
matchingGroup
|
||||
? matchingGroup.displayHeader!
|
||||
: generatedAttributesGroupDisplayName,
|
||||
matchingGroup?.displayHeader || generatedAttributesGroupDisplayName || "",
|
||||
);
|
||||
form.setValue(
|
||||
"displayDescription",
|
||||
matchingGroup
|
||||
? matchingGroup.displayDescription!
|
||||
: generatedAttributesGroupDisplayDescription,
|
||||
matchingGroup?.displayDescription ||
|
||||
generatedAttributesGroupDisplayDescription ||
|
||||
"",
|
||||
);
|
||||
}, [
|
||||
generatedAttributesGroupDisplayName,
|
||||
generatedAttributesGroupDisplayDescription,
|
||||
matchingGroup,
|
||||
form,
|
||||
]);
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const translationsToSaveDisplayHeader: Translations[] = [];
|
||||
const translationsToSaveDisplayDescription: Translations[] = [];
|
||||
const formData = form.getValues();
|
||||
|
||||
const translationsResults = await Promise.all(
|
||||
combinedLocales.map(async (selectedLocale) => {
|
||||
await Promise.all(
|
||||
combinedLocales.map(async (locale: string) => {
|
||||
try {
|
||||
const translations =
|
||||
await adminClient.realms.getRealmLocalizationTexts({
|
||||
realm: realmName,
|
||||
selectedLocale,
|
||||
selectedLocale: locale,
|
||||
});
|
||||
|
||||
const formattedDisplayHeaderKey = formData.displayHeader?.substring(
|
||||
2,
|
||||
formData.displayHeader.length - 1,
|
||||
);
|
||||
const formattedDisplayDescriptionKey =
|
||||
formData.displayDescription?.substring(
|
||||
2,
|
||||
formData.displayDescription.length - 1,
|
||||
);
|
||||
|
||||
return {
|
||||
locale: selectedLocale,
|
||||
headerTranslation: translations[formattedDisplayHeaderKey] ?? "",
|
||||
descriptionTranslation:
|
||||
translations[formattedDisplayDescriptionKey] ?? "",
|
||||
const formData = form.getValues();
|
||||
const extractKey = (value: string | undefined) => {
|
||||
const match = value?.match(/\$\{(.*?)\}/);
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const displayHeaderKey = extractKey(formData.displayHeader) || "";
|
||||
const displayDescriptionKey =
|
||||
extractKey(formData.displayDescription) || "";
|
||||
|
||||
const headerTranslation = translations[displayHeaderKey] || "";
|
||||
const descriptionTranslation =
|
||||
translations[displayDescriptionKey] || "";
|
||||
|
||||
if (headerTranslation) {
|
||||
translationsToSaveDisplayHeader.push({
|
||||
key: displayHeaderKey,
|
||||
translations: [{ locale, value: headerTranslation }],
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionTranslation) {
|
||||
translationsToSaveDisplayDescription.push({
|
||||
key: displayDescriptionKey,
|
||||
translations: [{ locale, value: descriptionTranslation }],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching translations for ${selectedLocale}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
console.error(`Error fetching translations for ${locale}:`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
translationsResults.forEach((translationsResult) => {
|
||||
if (translationsResult) {
|
||||
const { locale, headerTranslation, descriptionTranslation } =
|
||||
translationsResult;
|
||||
translationsToSaveDisplayHeader.push({
|
||||
key: formData.displayHeader?.substring(
|
||||
2,
|
||||
formData.displayHeader.length - 1,
|
||||
),
|
||||
translations: [
|
||||
{
|
||||
locale,
|
||||
value: headerTranslation,
|
||||
},
|
||||
],
|
||||
});
|
||||
translationsToSaveDisplayDescription.push({
|
||||
key: formData.displayDescription?.substring(
|
||||
2,
|
||||
formData.displayDescription.length - 1,
|
||||
),
|
||||
translations: [
|
||||
{
|
||||
locale,
|
||||
value: descriptionTranslation,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
translationsToSaveDisplayHeader,
|
||||
translationsToSaveDisplayDescription,
|
||||
};
|
||||
},
|
||||
(data) => {
|
||||
setTranslationsData({
|
||||
const translationsDataNew = {
|
||||
displayHeader: {
|
||||
key: data.translationsToSaveDisplayHeader[0].key,
|
||||
translations: data.translationsToSaveDisplayHeader.flatMap(
|
||||
(translationData) => translationData.translations,
|
||||
key:
|
||||
translationsToSaveDisplayHeader.length > 0
|
||||
? translationsToSaveDisplayHeader[0].key
|
||||
: "",
|
||||
translations: translationsToSaveDisplayHeader.flatMap(
|
||||
(data) => data.translations,
|
||||
),
|
||||
},
|
||||
displayDescription: {
|
||||
key: data.translationsToSaveDisplayDescription[0].key,
|
||||
translations: data.translationsToSaveDisplayDescription.flatMap(
|
||||
(translationData) => translationData.translations,
|
||||
key:
|
||||
translationsToSaveDisplayDescription.length > 0
|
||||
? translationsToSaveDisplayDescription[0].key
|
||||
: "",
|
||||
translations: translationsToSaveDisplayDescription.flatMap(
|
||||
(data) => data.translations,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
setTranslationsData(translationsDataNew);
|
||||
},
|
||||
[combinedLocales],
|
||||
() => {},
|
||||
[combinedLocales, realmName, form],
|
||||
);
|
||||
|
||||
const saveTranslations = async () => {
|
||||
|
@ -278,29 +253,33 @@ export default function AttributesGroupForm() {
|
|||
|
||||
try {
|
||||
if (
|
||||
translationsData.displayHeader &&
|
||||
translationsData &&
|
||||
translationsData.displayHeader.translations.length > 0
|
||||
) {
|
||||
for (const translation of translationsData.displayHeader.translations) {
|
||||
await addLocalization(
|
||||
translationsData.displayHeader.key,
|
||||
translation.locale,
|
||||
translation.value,
|
||||
);
|
||||
if (translation.locale && translation.value) {
|
||||
await addLocalization(
|
||||
translationsData.displayHeader.key,
|
||||
translation.locale,
|
||||
translation.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
translationsData.displayDescription &&
|
||||
translationsData &&
|
||||
translationsData.displayDescription.translations.length > 0
|
||||
) {
|
||||
for (const translation of translationsData.displayDescription
|
||||
.translations) {
|
||||
await addLocalization(
|
||||
translationsData.displayDescription.key,
|
||||
translation.locale,
|
||||
translation.value,
|
||||
);
|
||||
if (translation.locale && translation.value) {
|
||||
await addLocalization(
|
||||
translationsData.displayDescription.key,
|
||||
translation.locale,
|
||||
translation.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -331,7 +310,6 @@ export default function AttributesGroupForm() {
|
|||
translationsData.displayHeader.translations.some(
|
||||
(translation) => translation.value.trim() !== "",
|
||||
);
|
||||
|
||||
const hasNonEmptyDisplayDescriptionTranslations =
|
||||
translationsData.displayDescription.translations.some(
|
||||
(translation) => translation.value.trim() !== "",
|
||||
|
|
|
@ -142,10 +142,13 @@ export const AddTranslationsDialog = ({
|
|||
useEffect(() => {
|
||||
combinedLocales.forEach((locale, rowIndex) => {
|
||||
setValue(`translations.${rowIndex}.locale`, locale);
|
||||
|
||||
const translationExists =
|
||||
translations.translations[rowIndex] !== undefined;
|
||||
setValue(
|
||||
`translations.${rowIndex}.value`,
|
||||
translations.translations.length > 0
|
||||
? translations.translations[rowIndex].value
|
||||
translationExists
|
||||
? translations.translations[rowIndex]?.value
|
||||
: defaultTranslations[locale] || "",
|
||||
);
|
||||
});
|
||||
|
|
|
@ -3,6 +3,8 @@ import { useMatches } from "react-router-dom";
|
|||
|
||||
import { ForbiddenSection } from "../ForbiddenSection";
|
||||
import { useAccess } from "../context/access/Access";
|
||||
import { useWhoAmI } from "../context/whoami/WhoAmI";
|
||||
import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared";
|
||||
|
||||
function hasProp<K extends PropertyKey>(
|
||||
data: object,
|
||||
|
@ -14,6 +16,7 @@ function hasProp<K extends PropertyKey>(
|
|||
export const AuthWall = ({ children }: any) => {
|
||||
const matches = useMatches();
|
||||
const { hasAccess } = useAccess();
|
||||
const { whoAmI } = useWhoAmI();
|
||||
|
||||
const permissionNeeded = matches.flatMap(({ handle }) => {
|
||||
if (
|
||||
|
@ -31,9 +34,13 @@ export const AuthWall = ({ children }: any) => {
|
|||
return [handle.access] as AccessType[];
|
||||
});
|
||||
|
||||
return hasAccess(...permissionNeeded) ? (
|
||||
children
|
||||
) : (
|
||||
<ForbiddenSection permissionNeeded={permissionNeeded} />
|
||||
);
|
||||
if (whoAmI.isEmpty()) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
if (!hasAccess(...permissionNeeded)) {
|
||||
return <ForbiddenSection permissionNeeded={permissionNeeded} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
|
|
@ -35,7 +35,7 @@ Or if you just want to clear the data so you can start fresh without downloading
|
|||
pnpm delete-data
|
||||
```
|
||||
|
||||
If you want to run with a local Quarkus distribution of Keycloak for development purposes, you can do so by running this command instead:
|
||||
If you want to run with a local Quarkus distribution of Keycloak for development purposes, you can do so by running this command instead:
|
||||
|
||||
```sh
|
||||
pnpm start --local
|
||||
|
|
|
@ -45,14 +45,14 @@
|
|||
"url-template": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.3",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mocha": "^10.0.9",
|
||||
"@types/node": "^22.8.2",
|
||||
"@types/node": "^22.9.0",
|
||||
"chai": "^5.1.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mocha": "^10.7.3",
|
||||
"mocha": "^10.8.2",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"author": {
|
||||
|
|
|
@ -83,6 +83,7 @@ export default interface RealmRepresentation {
|
|||
offlineSessionMaxLifespan?: number;
|
||||
offlineSessionMaxLifespanEnabled?: boolean;
|
||||
organizationsEnabled?: boolean;
|
||||
verifiableCredentialsEnabled?: boolean;
|
||||
otpPolicyAlgorithm?: string;
|
||||
otpPolicyDigits?: number;
|
||||
otpPolicyInitialCounter?: number;
|
||||
|
|
|
@ -6,6 +6,14 @@ export class Cache extends Resource<{ realm?: string }> {
|
|||
method: "POST",
|
||||
path: "/clear-user-cache",
|
||||
});
|
||||
public clearKeysCache = this.makeRequest<{}, void>({
|
||||
method: "POST",
|
||||
path: "/clear-keys-cache",
|
||||
});
|
||||
public clearRealmCache = this.makeRequest<{}, void>({
|
||||
method: "POST",
|
||||
path: "/clear-realm-cache",
|
||||
});
|
||||
|
||||
constructor(client: KeycloakAdminClient) {
|
||||
super(client, {
|
||||
|
|
|
@ -15,7 +15,7 @@ describe("Attack Detection", () => {
|
|||
kcAdminClient = new KeycloakAdminClient();
|
||||
await kcAdminClient.auth(credentials);
|
||||
|
||||
const username = faker.internet.userName();
|
||||
const username = faker.internet.username();
|
||||
currentUser = await kcAdminClient.users.create({
|
||||
username,
|
||||
});
|
||||
|
|
|
@ -16,7 +16,7 @@ describe("Authentication management", () => {
|
|||
before(async () => {
|
||||
kcAdminClient = new KeycloakAdminClient();
|
||||
await kcAdminClient.auth(credentials);
|
||||
const realmName = faker.internet.userName().toLowerCase();
|
||||
const realmName = faker.internet.username().toLowerCase();
|
||||
await kcAdminClient.realms.create({
|
||||
id: realmName,
|
||||
realm: realmName,
|
||||
|
|
|
@ -27,7 +27,7 @@ describe("Clients", () => {
|
|||
// create client and also test it
|
||||
// NOTICE: to be clear, clientId stands for the property `clientId` of client
|
||||
// clientUniqueId stands for property `id` of client
|
||||
const clientId = faker.internet.userName();
|
||||
const clientId = faker.internet.username();
|
||||
const createdClient = await kcAdminClient.clients.create({
|
||||
clientId,
|
||||
});
|
||||
|
@ -82,7 +82,7 @@ describe("Clients", () => {
|
|||
|
||||
it("delete single client", async () => {
|
||||
// create another one for delete test
|
||||
const clientId = faker.internet.userName();
|
||||
const clientId = faker.internet.username();
|
||||
const { id } = await kcAdminClient.clients.create({
|
||||
clientId,
|
||||
});
|
||||
|
@ -103,7 +103,7 @@ describe("Clients", () => {
|
|||
*/
|
||||
describe("client roles", () => {
|
||||
before(async () => {
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
// create a client role
|
||||
const { roleName: createdRoleName } =
|
||||
await kcAdminClient.clients.createRole({
|
||||
|
@ -172,7 +172,7 @@ describe("Clients", () => {
|
|||
});
|
||||
|
||||
it("delete a client role", async () => {
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
// create a client role
|
||||
await kcAdminClient.clients.createRole({
|
||||
id: currentClient.id,
|
||||
|
@ -762,7 +762,7 @@ describe("Clients", () => {
|
|||
|
||||
it("get JSON with payload of examples", async () => {
|
||||
const { id: clientUniqueId } = currentClient;
|
||||
const username = faker.internet.userName();
|
||||
const username = faker.internet.username();
|
||||
const user = await kcAdminClient.users.create({
|
||||
username,
|
||||
});
|
||||
|
@ -1019,7 +1019,7 @@ describe("Clients", () => {
|
|||
});
|
||||
|
||||
before("create test user", async () => {
|
||||
const username = faker.internet.userName();
|
||||
const username = faker.internet.username();
|
||||
user = await kcAdminClient.users.create({
|
||||
username,
|
||||
});
|
||||
|
|
|
@ -16,7 +16,7 @@ describe("User federation using component api", () => {
|
|||
await kcAdminClient.auth(credentials);
|
||||
|
||||
// create user fed
|
||||
const name = faker.internet.userName();
|
||||
const name = faker.internet.username();
|
||||
const component = await kcAdminClient.components.create({
|
||||
name,
|
||||
parentId: "master",
|
||||
|
|
|
@ -14,7 +14,7 @@ describe("Realms", () => {
|
|||
kcAdminClient = new KeycloakAdminClient();
|
||||
await kcAdminClient.auth(credentials);
|
||||
|
||||
const realmId = faker.internet.userName();
|
||||
const realmId = faker.internet.username();
|
||||
const realm = await kcAdminClient.realms.create({
|
||||
id: realmId,
|
||||
realm: realmId,
|
||||
|
@ -28,7 +28,7 @@ describe("Realms", () => {
|
|||
});
|
||||
|
||||
it("add a user to another realm", async () => {
|
||||
const username = faker.internet.userName().toLowerCase();
|
||||
const username = faker.internet.username().toLowerCase();
|
||||
const user = await kcAdminClient.users.create({
|
||||
realm: currentRealmId,
|
||||
username,
|
||||
|
|
|
@ -21,7 +21,7 @@ describe("Group user integration", () => {
|
|||
let currentPolicy: PolicyRepresentation;
|
||||
|
||||
before(async () => {
|
||||
const groupName = faker.internet.userName();
|
||||
const groupName = faker.internet.username();
|
||||
kcAdminClient = new KeycloakAdminClient();
|
||||
await kcAdminClient.auth(credentials);
|
||||
// create group
|
||||
|
@ -31,7 +31,7 @@ describe("Group user integration", () => {
|
|||
currentGroup = (await kcAdminClient.groups.findOne({ id: group.id }))!;
|
||||
|
||||
// create user
|
||||
const username = faker.internet.userName();
|
||||
const username = faker.internet.username();
|
||||
const user = await kcAdminClient.users.create({
|
||||
username,
|
||||
email: "test@keycloak.org",
|
||||
|
|
|
@ -100,7 +100,7 @@ describe("Groups", () => {
|
|||
describe("role-mappings", () => {
|
||||
before(async () => {
|
||||
// create new role
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
const { roleName: createdRoleName } = await kcAdminClient.roles.create({
|
||||
name: roleName,
|
||||
});
|
||||
|
@ -189,7 +189,7 @@ describe("Groups", () => {
|
|||
describe("client role-mappings", () => {
|
||||
before(async () => {
|
||||
// create new client
|
||||
const clientId = faker.internet.userName();
|
||||
const clientId = faker.internet.username();
|
||||
await kcAdminClient.clients.create({
|
||||
clientId,
|
||||
});
|
||||
|
@ -199,7 +199,7 @@ describe("Groups", () => {
|
|||
currentClient = clients[0];
|
||||
|
||||
// create new client role
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
await kcAdminClient.clients.createRole({
|
||||
id: currentClient.id,
|
||||
name: roleName,
|
||||
|
@ -265,7 +265,7 @@ describe("Groups", () => {
|
|||
});
|
||||
|
||||
it("del client role-mappings from group", async () => {
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
await kcAdminClient.clients.createRole({
|
||||
id: currentClient.id,
|
||||
name: roleName,
|
||||
|
|
|
@ -15,7 +15,7 @@ describe("Identity providers", () => {
|
|||
await kcAdminClient.auth(credentials);
|
||||
|
||||
// create idp
|
||||
const alias = faker.internet.userName();
|
||||
const alias = faker.internet.username();
|
||||
const idp = await kcAdminClient.identityProviders.create({
|
||||
alias,
|
||||
providerId: "saml",
|
||||
|
|
|
@ -10,8 +10,8 @@ import { credentials } from "./constants.js";
|
|||
const expect = chai.expect;
|
||||
|
||||
const createRealm = async (kcAdminClient: KeycloakAdminClient) => {
|
||||
const realmId = faker.internet.userName().toLowerCase();
|
||||
const realmName = faker.internet.userName().toLowerCase();
|
||||
const realmId = faker.internet.username().toLowerCase();
|
||||
const realmName = faker.internet.username().toLowerCase();
|
||||
const realm = await kcAdminClient.realms.create({
|
||||
id: realmId,
|
||||
realm: realmName,
|
||||
|
@ -48,8 +48,8 @@ describe("Realms", () => {
|
|||
});
|
||||
|
||||
it("create realm", async () => {
|
||||
const realmId = faker.internet.userName().toLowerCase();
|
||||
const realmName = faker.internet.userName().toLowerCase();
|
||||
const realmId = faker.internet.username().toLowerCase();
|
||||
const realmName = faker.internet.username().toLowerCase();
|
||||
const realm = await kcAdminClient.realms.create({
|
||||
id: realmId,
|
||||
realm: realmName,
|
||||
|
|
|
@ -15,7 +15,7 @@ describe("Users federation provider", () => {
|
|||
kcAdminClient = new KeycloakAdminClient();
|
||||
await kcAdminClient.auth(credentials);
|
||||
|
||||
const name = faker.internet.userName();
|
||||
const name = faker.internet.username();
|
||||
currentUserFed = await kcAdminClient.components.create({
|
||||
name,
|
||||
parentId: "master",
|
||||
|
|
|
@ -34,7 +34,7 @@ describe("Users", () => {
|
|||
});
|
||||
|
||||
// initialize user
|
||||
const username = faker.internet.userName();
|
||||
const username = faker.internet.username();
|
||||
const user = await kcAdminClient.users.create({
|
||||
username,
|
||||
email: "test@keycloak.org",
|
||||
|
@ -373,7 +373,7 @@ describe("Users", () => {
|
|||
describe("role-mappings", () => {
|
||||
before(async () => {
|
||||
// create new role
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
await kcAdminClient.roles.create({
|
||||
name: roleName,
|
||||
});
|
||||
|
@ -460,7 +460,7 @@ describe("Users", () => {
|
|||
describe("client role-mappings", () => {
|
||||
before(async () => {
|
||||
// create new client
|
||||
const clientId = faker.internet.userName();
|
||||
const clientId = faker.internet.username();
|
||||
await kcAdminClient.clients.create({
|
||||
clientId,
|
||||
});
|
||||
|
@ -470,7 +470,7 @@ describe("Users", () => {
|
|||
currentClient = clients[0];
|
||||
|
||||
// create new client role
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
await kcAdminClient.clients.createRole({
|
||||
id: currentClient.id,
|
||||
name: roleName,
|
||||
|
@ -536,7 +536,7 @@ describe("Users", () => {
|
|||
});
|
||||
|
||||
it("del client role-mappings from user", async () => {
|
||||
const roleName = faker.internet.userName();
|
||||
const roleName = faker.internet.username();
|
||||
await kcAdminClient.clients.createRole({
|
||||
id: currentClient.id,
|
||||
name: roleName,
|
||||
|
@ -575,7 +575,7 @@ describe("Users", () => {
|
|||
await kcAdminClient.auth(credentials);
|
||||
|
||||
// create client
|
||||
const clientId = faker.internet.userName();
|
||||
const clientId = faker.internet.username();
|
||||
await kcAdminClient.clients.create({
|
||||
clientId,
|
||||
consentRequired: true,
|
||||
|
|
|
@ -1299,30 +1299,33 @@ function Keycloak (config) {
|
|||
return;
|
||||
}
|
||||
|
||||
const logoutUrl = kc.createLogoutUrl(options);
|
||||
const response = await fetch(logoutUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
id_token_hint: kc.idToken,
|
||||
client_id: kc.clientId,
|
||||
post_logout_redirect_uri: adapter.redirectUri(options, false)
|
||||
})
|
||||
});
|
||||
// Create form to send POST request.
|
||||
const form = document.createElement("form");
|
||||
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
form.setAttribute("method", "POST");
|
||||
form.setAttribute("action", kc.createLogoutUrl(options));
|
||||
form.style.display = "none";
|
||||
|
||||
// Add data to form as hidden input fields.
|
||||
const data = {
|
||||
id_token_hint: kc.idToken,
|
||||
client_id: kc.clientId,
|
||||
post_logout_redirect_uri: adapter.redirectUri(options, false)
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const input = document.createElement("input");
|
||||
|
||||
input.setAttribute("type", "hidden");
|
||||
input.setAttribute("name", name);
|
||||
input.setAttribute("value", value);
|
||||
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Logout failed, request returned an error code.");
|
||||
// Append form to page and submit it to perform logout and redirect.
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
|
||||
register: async function(options) {
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
"@patternfly/react-core": "^5.4.8",
|
||||
"@patternfly/react-icons": "^5.4.2",
|
||||
"@patternfly/react-styles": "^5.4.1",
|
||||
"@patternfly/react-table": "^5.4.8",
|
||||
"@patternfly/react-table": "^5.4.9",
|
||||
"i18next": "^23.16.4",
|
||||
"keycloak-js": "workspace:*",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
|
|
@ -5,9 +5,9 @@ import {
|
|||
Page,
|
||||
Text,
|
||||
TextContent,
|
||||
TextVariants,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getNetworkErrorDescription } from "../utils/errors";
|
||||
|
||||
type ErrorPageProps = {
|
||||
error?: unknown;
|
||||
|
@ -16,7 +16,10 @@ type ErrorPageProps = {
|
|||
export const ErrorPage = (props: ErrorPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const error = props.error;
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorMessage =
|
||||
getErrorMessage(error) ||
|
||||
getNetworkErrorDescription(error)?.replace(/\+/g, " ");
|
||||
console.error(error);
|
||||
|
||||
function onRetry() {
|
||||
location.href = location.origin + location.pathname;
|
||||
|
@ -26,7 +29,7 @@ export const ErrorPage = (props: ErrorPageProps) => {
|
|||
<Page>
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
title={t("somethingWentWrong")}
|
||||
title={errorMessage ? "" : t("somethingWentWrong")}
|
||||
titleIconVariant="danger"
|
||||
showClose={false}
|
||||
isOpen
|
||||
|
@ -37,9 +40,10 @@ export const ErrorPage = (props: ErrorPageProps) => {
|
|||
]}
|
||||
>
|
||||
<TextContent>
|
||||
<Text>{t("somethingWentWrongDescription")}</Text>
|
||||
{errorMessage && (
|
||||
<Text component={TextVariants.small}>{errorMessage}</Text>
|
||||
{errorMessage ? (
|
||||
<Text>{t(errorMessage)}</Text>
|
||||
) : (
|
||||
<Text>{t("somethingWentWrongDescription")}</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
</Modal>
|
||||
|
|
|
@ -79,8 +79,14 @@ export const KeycloakProvider = <T extends BaseEnvironment>({
|
|||
calledOnce.current = true;
|
||||
}, [keycloak]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorPage error={error} />;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (error || searchParams.get("error_description")) {
|
||||
return (
|
||||
<ErrorPage
|
||||
error={error ? error : searchParams.get("error_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!init) {
|
||||
|
|
|
@ -94,7 +94,7 @@ export const SingleSelectControl = <
|
|||
}}
|
||||
isOpen={open}
|
||||
>
|
||||
<SelectList>
|
||||
<SelectList data-testid={`select-${name}`}>
|
||||
{options.map((option) => (
|
||||
<SelectOption key={key(option)} value={key(option)}>
|
||||
{isString(option) ? option : option.value}
|
||||
|
|
|
@ -82,7 +82,7 @@ export const SelectComponent = (props: UserProfileFieldProps) => {
|
|||
}}
|
||||
selections={
|
||||
isMultiValue && Array.isArray(field.value)
|
||||
? field.value
|
||||
? field.value.map((option) => fetchLabel(option))
|
||||
: fetchLabel(field.value)
|
||||
}
|
||||
variant={
|
||||
|
|
|
@ -1821,8 +1821,25 @@ public class RealmAdapter implements CachedRealmModel {
|
|||
updated.setOrganizationsEnabled(organizationsEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifiableCredentialsEnabled() {
|
||||
if (isUpdated()) return featureVerifiableCredentialsEnabled(updated.isVerifiableCredentialsEnabled());
|
||||
return featureVerifiableCredentialsEnabled(cached.isVerifiableCredentialsEnabled());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVerifiableCredentialsEnabled(boolean verifiableCredentialsEnabled) {
|
||||
getDelegateForUpdate();
|
||||
updated.setVerifiableCredentialsEnabled(verifiableCredentialsEnabled);
|
||||
}
|
||||
|
||||
private boolean featureAwareIsOrganizationsEnabled(boolean isOrganizationsEnabled) {
|
||||
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) return false;
|
||||
return isOrganizationsEnabled;
|
||||
}
|
||||
|
||||
private boolean featureVerifiableCredentialsEnabled(boolean isVerifiableCredentialsEnabled) {
|
||||
if (!Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) return false;
|
||||
return isVerifiableCredentialsEnabled;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
protected boolean identityFederationEnabled;
|
||||
protected boolean editUsernameAllowed;
|
||||
protected boolean organizationsEnabled;
|
||||
protected boolean verifiableCredentialsEnabled;
|
||||
//--- brute force settings
|
||||
protected boolean bruteForceProtected;
|
||||
protected boolean permanentLockout;
|
||||
|
@ -191,6 +192,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
resetPasswordAllowed = model.isResetPasswordAllowed();
|
||||
editUsernameAllowed = model.isEditUsernameAllowed();
|
||||
organizationsEnabled = model.isOrganizationsEnabled();
|
||||
verifiableCredentialsEnabled = model.isVerifiableCredentialsEnabled();
|
||||
//--- brute force settings
|
||||
bruteForceProtected = model.isBruteForceProtected();
|
||||
permanentLockout = model.isPermanentLockout();
|
||||
|
@ -431,6 +433,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
return organizationsEnabled;
|
||||
}
|
||||
|
||||
public boolean isVerifiableCredentialsEnabled() {
|
||||
return verifiableCredentialsEnabled;
|
||||
}
|
||||
|
||||
public String getDefaultSignatureAlgorithm() {
|
||||
return defaultSignatureAlgorithm;
|
||||
}
|
||||
|
|
|
@ -1199,6 +1199,16 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
|||
setAttribute(RealmAttributes.ORGANIZATIONS_ENABLED, organizationsEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifiableCredentialsEnabled() {
|
||||
return getAttribute(RealmAttributes.VERIFIABLE_CREDENTIALS_ENABLED, Boolean.FALSE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVerifiableCredentialsEnabled(boolean verifiableCredentialsEnabled) {
|
||||
setAttribute(RealmAttributes.VERIFIABLE_CREDENTIALS_ENABLED, verifiableCredentialsEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientModel getMasterAdminClient() {
|
||||
String masterAdminClientId = realm.getMasterAdminClient();
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue