diff --git a/.github/actions/linkcheck/action.yml b/.github/actions/linkcheck/action.yml index befb7c88..05fe7f82 100644 --- a/.github/actions/linkcheck/action.yml +++ b/.github/actions/linkcheck/action.yml @@ -13,23 +13,23 @@ runs: using: composite steps: - - name: Get Node.js and PNPM versions + - name: Get Node.js and npm versions id: tooling-versions shell: bash run: | echo "node=$(cat pom.xml | grep '' | cut -d '>' -f 2 | cut -d '<' -f 1 | cut -c 2-)" >> $GITHUB_OUTPUT - echo "pnpm=$(cat pom.xml | grep '' | cut -d '>' -f 2 | cut -d '<' -f 1 | cut -c 1-)" >> $GITHUB_OUTPUT + echo "npm=$(cat pom.xml | grep '' | cut -d '>' -f 2 | cut -d '<' -f 1 | cut -c 1-)" >> $GITHUB_OUTPUT working-directory: ${{ inputs.directory }} # Downloading Node.js often fails due to network issues, therefore we cache the artifacts downloaded by the frontend plugin. - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 id: cache-binaries - name: Cache Node.js and PNPM binaries + name: Cache Node.js and npm binaries with: path: | ~/.m2/repository/com/github/eirslett/node - ~/.m2/repository/com/github/eirslett/pnpm - key: ${{ runner.os }}-frontend-plugin-artifacts-${{ steps.tooling-versions.outputs.node }}-${{ steps.tooling-versions.outputs.pnpm }} + ~/.m2/repository/com/github/eirslett/npm + key: ${{ runner.os }}-frontend-plugin-artifacts-${{ steps.tooling-versions.outputs.node }}-${{ steps.tooling-versions.outputs.npm }} - name: Build with Maven env: diff --git a/pom.xml b/pom.xml index 23ff0d32..fa354708 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ 1.15.0 v22.18.0 - 10.14.0 + 10.9.2 3.2.0 3.8.1 3.1.2 @@ -111,7 +111,7 @@ ${version.node} - ${version.npm} + ${version.npm} diff --git a/quicktheme/eslint.config.js b/quicktheme/eslint.config.js new file mode 100644 index 00000000..5e6b472f --- /dev/null +++ b/quicktheme/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/quicktheme/index.html b/quicktheme/index.html new file mode 100644 index 00000000..ef3d2a92 --- /dev/null +++ b/quicktheme/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Quick Theme - Keycloak + + +
+ + + + diff --git a/quicktheme/package.json b/quicktheme/package.json new file mode 100644 index 00000000..8c7996dd --- /dev/null +++ b/quicktheme/package.json @@ -0,0 +1,45 @@ +{ + "name": "@edewit/quicktheme", + "version": "0.0.2", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/edewit/standalone-quicktheme.git" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@keycloak/keycloak-admin-ui": "26.6.0", + "@patternfly/patternfly": "^5.4.2", + "@patternfly/react-core": "^5.4.14", + "i18next": "^25.7.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^16.5.1", + "react-router-dom": "^6.30.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/quicktheme/pnpm-workspace.yaml b/quicktheme/pnpm-workspace.yaml new file mode 100644 index 00000000..c5739b74 --- /dev/null +++ b/quicktheme/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoredBuiltDependencies: + - esbuild diff --git a/quicktheme/public/icon.svg b/quicktheme/public/icon.svg new file mode 100644 index 00000000..849ac275 --- /dev/null +++ b/quicktheme/public/icon.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/quicktheme/public/login/keycloak.v2/css/styles.css b/quicktheme/public/login/keycloak.v2/css/styles.css new file mode 100644 index 00000000..60d67b25 --- /dev/null +++ b/quicktheme/public/login/keycloak.v2/css/styles.css @@ -0,0 +1,144 @@ +:root { + --keycloak-logo-url: url('../img/keycloak-logo-text.svg'); + --keycloak-bg-logo-url: url("../img/keycloak-bg-darken.svg"); + --keycloak-logo-height: 63px; + --keycloak-logo-width: 300px; + --keycloak-card-top-color: var(--pf-v5-global--palette--blue-400); +} + +.pf-v5-c-login__container { + grid-template-columns: 34rem; + grid-template-areas: "header" + "main" +} + +.pf-v5-c-login__main-header { + border-top: 4px solid var(--keycloak-card-top-color); +} + +/* Info section - top margin + bottom padding */ +.pf-v5-c-login__main-footer-band:first-child { + margin-block-start: var(--pf-v5-global--spacer--lg); +} + +.pf-v5-c-login__main-footer-band:last-child { + padding-bottom: 0; +} +/* Info section */ + +.login-pf body { + background: var(--keycloak-bg-logo-url) no-repeat center center fixed; + background-size: cover; + height: 100%; +} + +div.kc-logo-text { + background-image: var(--keycloak-logo-url); + height: var(--keycloak-logo-height); + width: var(--keycloak-logo-width); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + margin: 0 auto; +} + +div.kc-logo-text span { + display: none; +} + +.kc-login-tooltip { + position: relative; + display: inline-block; +} + +.kc-login-tooltip .kc-tooltip-text{ + top:-3px; + left:160%; + background-color: black; + visibility: hidden; + color: #fff; + + min-width:130px; + text-align: center; + border-radius: 2px; + box-shadow:0 1px 8px rgba(0,0,0,0.6); + padding: 5px; + + position: absolute; + opacity:0; + transition:opacity 0.5s; +} + +/* Show tooltip */ +.kc-login-tooltip:hover .kc-tooltip-text { + visibility: visible; + opacity:0.7; +} + +/* Arrow for tooltip */ +.kc-login-tooltip .kc-tooltip-text::after { + content: " "; + position: absolute; + top: 15px; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent black transparent transparent; +} + +#kc-recovery-codes-list { + columns: 2; +} + +#certificate_subjectDN { + overflow-wrap: break-word +} + +#kc-verify-email-form { + margin-top: 24px; + margin-bottom: 24px; +} + +#kc-header-wrapper { + font-size: 29px; + text-transform: uppercase; + letter-spacing: 3px; + line-height: 1.2em; + white-space: normal; + color: var(--pf-v5-global--Color--light-100) !important; + text-align: center; +} + +#kc-code pre code { + word-break: break-all; +} + +hr { + margin-top: var(--pf-v5-global--spacer--sm); + margin-bottom: var(--pf-v5-global--spacer--md); +} + +#kc-social-providers svg:not(.google) { + filter: invert(47%) sepia(88%) saturate(7486%) hue-rotate(199deg) brightness(91%) contrast(101%); +} + +#kc-social-providers svg { + height: var(--pf-v5-global--FontSize--xl); +} + +@media (prefers-color-scheme: dark) { + #kc-social-providers svg:not(.google) { + filter: invert(54%) sepia(96%) saturate(2028%) hue-rotate(174deg) brightness(99%) contrast(97%); + } +} + +@media (min-width: 768px) { + div.pf-v5-c-login__main-header { + grid-template-columns: 70% 30%; + } +} + +#kc-form-webauthn { + gap: 0.875rem; +} \ No newline at end of file diff --git a/quicktheme/public/login/keycloak.v2/img/keycloak-bg-darken.svg b/quicktheme/public/login/keycloak.v2/img/keycloak-bg-darken.svg new file mode 100644 index 00000000..54542f38 --- /dev/null +++ b/quicktheme/public/login/keycloak.v2/img/keycloak-bg-darken.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/quicktheme/public/login/keycloak.v2/img/keycloak-bg.png b/quicktheme/public/login/keycloak.v2/img/keycloak-bg.png new file mode 100644 index 00000000..4004db44 Binary files /dev/null and b/quicktheme/public/login/keycloak.v2/img/keycloak-bg.png differ diff --git a/quicktheme/public/login/keycloak.v2/img/keycloak-logo-text.svg b/quicktheme/public/login/keycloak.v2/img/keycloak-logo-text.svg new file mode 100644 index 00000000..7a65390b --- /dev/null +++ b/quicktheme/public/login/keycloak.v2/img/keycloak-logo-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/quicktheme/public/logo.svg b/quicktheme/public/logo.svg new file mode 100644 index 00000000..17edc2c0 --- /dev/null +++ b/quicktheme/public/logo.svg @@ -0,0 +1 @@ +keycloak_deliverables \ No newline at end of file diff --git a/quicktheme/public/theme/login.css b/quicktheme/public/theme/login.css new file mode 100644 index 00000000..d70c6018 --- /dev/null +++ b/quicktheme/public/theme/login.css @@ -0,0 +1,644 @@ +/* Patternfly CSS places a "bg-login.jpg" as the background on this ".login-pf" class. + This clashes with the "keycloak-bg.png' background defined on the body below. + Therefore the Patternfly background must be set to none. */ +.login-pf { + background: none; +} + +.login-pf body { + background: url("../img/keycloak-bg.png") no-repeat center center fixed; + background-size: cover; + height: 100%; +} + +textarea.pf-c-form-control { + height: auto; +} + +.pf-c-alert__title { + font-size: var(--pf-global--FontSize--xs); +} + +p.instruction { + margin: 5px 0; +} + +.pf-c-button.pf-m-control { + border-color: rgba(230, 230, 230, 0.5); +} + +h1#kc-page-title { + margin-top: 10px; +} + +#kc-locale ul { + background-color: var(--pf-global--BackgroundColor--100); + display: none; + top: 20px; + min-width: 100px; + padding: 0; +} + +#kc-locale-dropdown{ + display: inline-block; +} + +#kc-locale-dropdown:hover ul { + display:block; +} + +#kc-locale-dropdown a { + color: var(--pf-global--Color--200); + text-align: right; + font-size: var(--pf-global--FontSize--sm); +} + +#kc-locale-dropdown button { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--pf-global--Color--200); + text-align: right; + font-size: var(--pf-global--FontSize--sm); +} + +button#kc-current-locale-link::after { + content: "\2c5"; + margin-left: var(--pf-global--spacer--xs) +} + +.login-pf .container { + padding-top: 40px; +} + +.login-pf a:hover { + color: #0099d3; +} + +#kc-logo { + width: 100%; +} + +div.kc-logo-text { + background-image: url(../img/keycloak-logo-text.png); + background-repeat: no-repeat; + height: 63px; + width: 300px; + margin: 0 auto; +} + +div.kc-logo-text span { + display: none; +} + +#kc-header { + color: #ededed; + overflow: visible; + white-space: nowrap; +} + +#kc-header-wrapper { + font-size: 29px; + text-transform: uppercase; + letter-spacing: 3px; + line-height: 1.2em; + padding: 62px 10px 20px; + white-space: normal; +} + +#kc-content { + width: 100%; +} + +#kc-attempted-username { + font-size: 20px; + font-family: inherit; + font-weight: normal; + padding-right: 10px; +} + +#kc-username { + text-align: center; + margin-bottom:-10px; +} + +#kc-webauthn-settings-form { + padding-top: 8px; +} + +#kc-form-webauthn .select-auth-box-parent { + pointer-events: none; +} + +#kc-form-webauthn .select-auth-box-desc { + color: var(--pf-global--palette--black-600); +} + +#kc-form-webauthn .select-auth-box-headline { + color: var(--pf-global--Color--300); +} + +#kc-form-webauthn .select-auth-box-icon { + flex: 0 0 3em; +} + +#kc-form-webauthn .select-auth-box-icon-properties { + margin-top: 10px; + font-size: 1.8em; +} + +#kc-form-webauthn .select-auth-box-icon-properties.unknown-transport-class { + margin-top: 3px; +} + +#kc-form-webauthn .pf-l-stack__item { + margin: -1px 0; +} + +#kc-content-wrapper { + margin-top: 20px; +} + +#kc-form-wrapper { + margin-top: 10px; +} + +#kc-info { + margin: 20px -40px -30px; +} + +#kc-info-wrapper { + font-size: 13px; + padding: 15px 35px; + background-color: #F0F0F0; +} + +#kc-form-options span { + display: block; +} + +#kc-form-options .checkbox { + margin-top: 0; + color: #72767b; +} + +#kc-terms-text { + margin-bottom: 20px; +} + +#kc-registration-terms-text { + max-height: 100px; + overflow-y: auto; + overflow-x: hidden; + margin: 5px; +} + +#kc-registration { + margin-bottom: 0; +} + +/* TOTP */ + +.subtitle { + text-align: right; + margin-top: 30px; + color: #909090; +} + +.required { + color: var(--pf-global--danger-color--200); +} + +ol#kc-totp-settings { + margin: 0; + padding-left: 20px; +} + +ul#kc-totp-supported-apps { + margin-bottom: 10px; +} + +#kc-totp-secret-qr-code { + max-width:150px; + max-height:150px; +} + +#kc-totp-secret-key { + background-color: #fff; + color: #333333; + font-size: 16px; + padding: 10px 0; +} + +/* OAuth */ + +#kc-oauth h3 { + margin-top: 0; +} + +#kc-oauth ul { + list-style: none; + padding: 0; + margin: 0; +} + +#kc-oauth ul li { + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 12px; + padding: 10px 0; +} + +#kc-oauth ul li:first-of-type { + border-top: 0; +} + +#kc-oauth .kc-role { + display: inline-block; + width: 50%; +} + +/* Code */ +#kc-code textarea { + width: 100%; + height: 8em; +} + +/* Social */ +.kc-social-links { + margin-top: 20px; +} + +.kc-social-links li { + width: 100%; +} + +.kc-social-provider-logo { + font-size: 23px; + width: 30px; + height: 25px; + float: left; +} + +.kc-social-gray { + color: var(--pf-global--Color--200); +} + +.kc-social-gray h2 { + font-size: 1em; +} + +.kc-social-item { + margin-bottom: var(--pf-global--spacer--sm); + font-size: 15px; + text-align: center; +} + +.kc-social-provider-name { + position: relative; +} + +.kc-social-icon-text { + left: -15px; +} + +.kc-social-grid { + display:grid; + grid-column-gap: 10px; + grid-row-gap: 5px; + grid-column-end: span 6; + --pf-l-grid__item--GridColumnEnd: span 6; +} + +.kc-social-grid .kc-social-icon-text { + left: -10px; +} + +.kc-login-tooltip { + position: relative; + display: inline-block; +} + +.kc-social-section { + text-align: center; +} + +.kc-social-section hr{ + margin-bottom: 10px +} + +.kc-login-tooltip .kc-tooltip-text{ + top:-3px; + left:160%; + background-color: black; + visibility: hidden; + color: #fff; + + min-width:130px; + text-align: center; + border-radius: 2px; + box-shadow:0 1px 8px rgba(0,0,0,0.6); + padding: 5px; + + position: absolute; + opacity:0; + transition:opacity 0.5s; +} + +/* Show tooltip */ +.kc-login-tooltip:hover .kc-tooltip-text { + visibility: visible; + opacity:0.7; +} + +/* Arrow for tooltip */ +.kc-login-tooltip .kc-tooltip-text::after { + content: " "; + position: absolute; + top: 15px; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent black transparent transparent; +} + +@media (min-width: 768px) { + #kc-container-wrapper { + position: absolute; + width: 100%; + } + + .login-pf .container { + padding-right: 80px; + } + + #kc-locale { + position: relative; + text-align: right; + z-index: 9999; + } +} + +@media (max-width: 767px) { + + .login-pf body { + background: white; + } + + #kc-header { + padding-left: 15px; + padding-right: 15px; + float: none; + text-align: left; + } + + #kc-header-wrapper { + font-size: 16px; + font-weight: bold; + padding: 20px 60px 0 0; + color: #72767b; + letter-spacing: 0; + } + + div.kc-logo-text { + margin: 0; + width: 150px; + height: 32px; + background-size: 100%; + } + + #kc-form { + float: none; + } + + #kc-info-wrapper { + border-top: 1px solid rgba(255, 255, 255, 0.1); + background-color: transparent; + } + + .login-pf .container { + padding-top: 15px; + padding-bottom: 15px; + } + + #kc-locale { + position: absolute; + width: 200px; + top: 20px; + right: 20px; + text-align: right; + z-index: 9999; + } +} + +@media (min-height: 646px) { + #kc-container-wrapper { + bottom: 12%; + } +} + +@media (max-height: 645px) { + #kc-container-wrapper { + padding-top: 50px; + top: 20%; + } +} + +.card-pf form.form-actions .btn { + float: right; + margin-left: 10px; +} + +#kc-form-buttons { + margin-top: 20px; +} + +.login-pf-page .login-pf-brand { + margin-top: 20px; + max-width: 360px; + width: 40%; +} + +.select-auth-box-arrow{ + display: flex; + align-items: center; + margin-right: 2rem; +} + +.select-auth-box-icon{ + display: flex; + flex: 0 0 2em; + justify-content: center; + margin-right: 1rem; + margin-left: 3rem; +} + +.select-auth-box-parent{ + border-top: 1px solid var(--pf-global--palette--black-200); + padding-top: 1rem; + padding-bottom: 1rem; + cursor: pointer; + text-align: left; + align-items: unset; + background-color: unset; + border-right: unset; + border-bottom: unset; + border-left: unset; +} + +.select-auth-box-parent:hover{ + background-color: #f7f8f8; +} + +.select-auth-container { + padding-bottom: 0px !important; +} + +.select-auth-box-headline { + font-size: var(--pf-global--FontSize--md); + color: var(--pf-global--primary-color--100); + font-weight: bold; +} + +.select-auth-box-desc { + font-size: var(--pf-global--FontSize--sm); +} + +.select-auth-box-paragraph { + text-align: center; + font-size: var(--pf-global--FontSize--md); + margin-bottom: 5px; +} + +.card-pf { + margin: 0 auto; + box-shadow: var(--pf-global--BoxShadow--lg); + padding: 0 20px; + max-width: 500px; + border-top: 4px solid; + border-color: var(--pf-global--primary-color--100); +} + +/*phone*/ +@media (max-width: 767px) { + .login-pf-page .card-pf { + max-width: none; + margin-left: 0; + margin-right: 0; + padding-top: 0; + border-top: 0; + box-shadow: 0 0; + } + + .kc-social-grid { + grid-column-end: 12; + --pf-l-grid__item--GridColumnEnd: span 12; + } + + .kc-social-grid .kc-social-icon-text { + left: -15px; + } +} + +.login-pf-page .login-pf-signup { + font-size: 15px; + color: #72767b; +} +#kc-content-wrapper .row { + margin-left: 0; + margin-right: 0; +} + +.login-pf-page.login-pf-page-accounts { + margin-left: auto; + margin-right: auto; +} + +.login-pf-page .btn-primary { + margin-top: 0; +} + +.login-pf-page .list-view-pf .list-group-item { + border-bottom: 1px solid #ededed; +} + +.login-pf-page .list-view-pf-description { + width: 100%; +} + +#kc-form-login div.form-group:last-of-type, +#kc-register-form div.form-group:last-of-type, +#kc-update-profile-form div.form-group:last-of-type, +#kc-update-email-form div.form-group:last-of-type{ + margin-bottom: 0px; +} + +.no-bottom-margin { + margin-bottom: 0; +} + +#kc-back { + margin-top: 5px; +} + +.kc-alert-delete-account { + margin-top: 0; + margin-bottom: 30px; +} + +.kc-delete-account-list { + color: #72767b; + list-style: disc; + list-style-position: inside; +} + +.kc-delete-account-cancel { + margin-left: calc(100% - 220px); +} + +/* Recovery codes */ +.kc-recovery-codes-warning { + margin-bottom: 32px; +} +.kc-recovery-codes-warning .pf-c-alert__description p { + font-size: 0.875rem; +} +.kc-recovery-codes-list { + list-style: none; + columns: 2; + margin: 16px 0; + padding: 16px 16px 8px 16px; + border: 1px solid #D2D2D2; +} +.kc-recovery-codes-list li { + margin-bottom: 8px; + font-size: 11px; +} +.kc-recovery-codes-list li span { + color: #6A6E73; + width: 16px; + text-align: right; + display: inline-block; + margin-right: 1px; +} + +.kc-recovery-codes-actions { + margin-bottom: 24px; +} +.kc-recovery-codes-actions button { + padding-left: 0; +} +.kc-recovery-codes-actions button i { + margin-right: 8px; +} + +.kc-recovery-codes-confirmation { + align-items: baseline; + margin-bottom: 16px; +} + +#certificate_subjectDN { + overflow-wrap: break-word +} +/* End Recovery codes */ diff --git a/quicktheme/public/vite.svg b/quicktheme/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/quicktheme/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/quicktheme/src/App.css b/quicktheme/src/App.css new file mode 100644 index 00000000..4512e6b9 --- /dev/null +++ b/quicktheme/src/App.css @@ -0,0 +1,47 @@ +.quick-theme-app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.quick-theme-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--pf-v5-global--BackgroundColor--100, #fff); + border-bottom: 1px solid var(--pf-v5-global--BorderColor--100, #d2d2d2); +} + +.quick-theme-header__logo { + font-size: 1.25rem; + font-weight: 600; + color: var(--pf-v5-global--Color--100, #151515); + text-decoration: none; +} + +.quick-theme-header__nav a { + color: var(--pf-v5-global--link--Color, #06c); + text-decoration: none; +} + +.quick-theme-header__nav a:hover { + text-decoration: underline; +} + +.quick-theme-banner { + padding: 0.75rem 1.5rem; + background: var(--pf-v5-global--warning-color--100, #f0ab00); + color: var(--pf-v5-global--Color--dark-100, #151515); + font-size: 0.875rem; +} + +.quick-theme-banner a { + color: inherit; + font-weight: 600; +} + +.quick-theme-main { + flex: 1; + padding: 1rem 1.5rem 2rem; +} diff --git a/quicktheme/src/App.tsx b/quicktheme/src/App.tsx new file mode 100644 index 00000000..224bbdb3 --- /dev/null +++ b/quicktheme/src/App.tsx @@ -0,0 +1,38 @@ +import { QuickTheme } from "@keycloak/keycloak-admin-ui"; +import { MockAppContexts, mockRealm } from "./mock-providers"; +import "./App.css"; + +function App() { + return ( +
+
+ + Keycloak + + +
+ +
+ Experimental. This online demo does not connect to a + Keycloak server. Download the theme JAR and deploy it to your server as + described in the{" "} + + Quick Theme guide + + . +
+ +
+ + + +
+
+ ); +} + +export default App; diff --git a/quicktheme/src/main.tsx b/quicktheme/src/main.tsx new file mode 100644 index 00000000..ebc71be1 --- /dev/null +++ b/quicktheme/src/main.tsx @@ -0,0 +1,108 @@ +import "@patternfly/patternfly/patternfly.css"; +import "@patternfly/patternfly/patternfly-addons.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { initReactI18next } from "react-i18next"; +import i18n from "i18next"; + +import App from "./App.tsx"; +import { MockProvider } from "./mock-providers.tsx"; + +// Initialize i18n +i18n.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, + defaultNS: "master", + ns: ["master"], + resources: { + en: { + master: { + adminConsolePreview: "Admin console preview", + continue: "Continue", + cancel: "Cancel", + themeColorInfo: + 'Here you can set the patternfly color variables and create a "theme jar" file that you can download and put in your providers folder to apply the theme to your realm.', + themes: "Themes", + themeName: "Theme name", + fileName: "File name", + quickTheme: "Quick Theme", + themeMode: "Theme mode", + lightMode: "Light mode", + darkMode: "Dark mode", + favicon: "Favicon", + logo: "Logo", + backgroundImage: "Background image", + downloadThemeJar: "Download theme jar", + font: "Font", + fileNameDialogTitle: "Save as", + errorColor: "Error color", + successColor: "Success color", + activeColor: "Active color", + primaryColor: "Primary color", + primaryColorHover: "Primary color hover", + secondaryColor: "Secondary color", + linkColor: "Link color", + linkColorHover: "Link color hover", + loginPagePreview: "Login page preview", + logoWidth: "Logo width", + logoHeight: "Logo height", + backgroundColor: "Background color", + backgroundColorAccent: "Background color accent", + backgroundColorNav: "Background color nav", + backgroundColorHeader: "Background color header", + iconColor: "Icon color", + textColor: "Text color", + themeDescription: "Theme description", + themeDescriptionDefault: + "Custom theme created by the Quick theme tool.", + lightTextColor: "Light text color", + inputBackgroundColor: "Input background color", + inputTextColor: "Input text color", + signOut: "Sign out", + navigation: "Navigation", + documentation: "Documentation", + helpToggleInfo: "Help toggle info", + enableHelpMode: "Enable help mode", + enableHelp: "Enable help", + revert: "Revert", + uploadGeneratedThemeJar: "Upload generated theme jar", + save: "Save", + realmSettings: "Realm settings", + loginTheme: "Login theme", + accountTheme: "Account theme", + adminConsoleTheme: "Admin console theme", + emailTheme: "Email theme", + internationalization: "Internationalization", + internationalizationHelp: + "Enable/disable internationalization for your realm", + supportedLocales: "Supported locales", + supportedLocalesHelp: "Locales that are supported in this realm", + defaultLocale: "Default locale", + defaultLocaleHelp: "The default locale for this realm", + spinnerLoading: "Loading...", + unknownUser: "Anonymous", + themePreviewInfo: + "In order to preview the theme colors, the current theme needs to be set to the one you want to preview, so we have automatically switched you to the one you want to preview.", + }, + }, + }, + saveMissing: true, + missingKeyHandler: function (lngs, ns, key) { + console.log("Missing translation:", lngs, ns, key); + }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/quicktheme/src/mock-providers.tsx b/quicktheme/src/mock-providers.tsx new file mode 100644 index 00000000..ac7b8bd1 --- /dev/null +++ b/quicktheme/src/mock-providers.tsx @@ -0,0 +1,123 @@ +import type { PropsWithChildren } from "react"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { + KeycloakProvider, + AccessContext, + WhoAmIContext, + RealmContext, +} from "@keycloak/keycloak-admin-ui"; + +// Mock Keycloak instance that doesn't require authentication +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createMockKeycloak = (): any => ({ + authenticated: true, + token: "mock-token", + tokenParsed: { sub: "mock-user-id" }, + subject: "mock-user-id", + responseMode: "query", + responseType: "code", + flow: "standard", + timeSkew: 0, + idToken: "mock-id-token", + idTokenParsed: { sub: "mock-user-id" }, + init: () => Promise.resolve(true), + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + register: () => Promise.resolve(), + accountManagement: () => Promise.resolve(), + createLoginUrl: () => "", + createLogoutUrl: () => "", + createRegisterUrl: () => "", + createAccountUrl: () => "", + isTokenExpired: () => false, + updateToken: () => Promise.resolve(true), + clearToken: () => {}, + hasRealmRole: () => true, + hasResourceRole: () => true, + loadUserProfile: () => Promise.resolve({}), + loadUserInfo: () => Promise.resolve({}), +}); + +// Base URL for static theme assets (e.g. /quick-theme on keycloak.org) +const resourceBase = import.meta.env.BASE_URL.replace(/\/$/, ""); + +// Mock environment configuration +const mockEnvironment = { + adminBaseUrl: "", + resourceUrl: resourceBase, + // Resolve /resources//login/... to /quick-theme/login/... when hosted under /quick-theme/ + resourceVersion: "../quick-theme", + logo: "", + logoUrl: "", + serverBaseUrl: "", + realm: "master", + clientId: "quicktheme", +}; + +// Mock realm data for standalone mode +export const mockRealm: RealmRepresentation = { + realm: "master", + displayName: "Master Realm", + loginTheme: "keycloak", + accountTheme: "keycloak", + adminTheme: "keycloak.v2", + emailTheme: "keycloak", + internationalizationEnabled: false, + supportedLocales: ["en"], + defaultLocale: "en", +}; + +// Mock WhoAmI data +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockWhoAmI: any = { + realm: "master", + userId: "mock-user-id", + displayName: "Mock User", + locale: "en", + createRealm: true, + realm_access: { + master: ["manage-realm", "view-realm", "manage-users", "view-users"], + }, + temporary: false, +}; + +// Provides all required contexts with mock data +export const MockAppContexts = ({ children }: PropsWithChildren) => { + return ( + {}, + }} + > + {}, + }} + > + true, + hasSomeAccess: () => true, + }} + > + {children} + + + + ); +}; + +// Top-level provider (wraps KeycloakProvider with mock keycloak) +export const MockProvider = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; diff --git a/quicktheme/tsconfig.app.json b/quicktheme/tsconfig.app.json new file mode 100644 index 00000000..a9b5a59c --- /dev/null +++ b/quicktheme/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/quicktheme/tsconfig.json b/quicktheme/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/quicktheme/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/quicktheme/tsconfig.node.json b/quicktheme/tsconfig.node.json new file mode 100644 index 00000000..8a67f62f --- /dev/null +++ b/quicktheme/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/quicktheme/vite.config.ts b/quicktheme/vite.config.ts new file mode 100644 index 00000000..d2102115 --- /dev/null +++ b/quicktheme/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + base: '/quick-theme/', +}) diff --git a/src/main/java/org/keycloak/webbuilder/WebBuilder.java b/src/main/java/org/keycloak/webbuilder/WebBuilder.java index 2f2905f6..c7c5664f 100644 --- a/src/main/java/org/keycloak/webbuilder/WebBuilder.java +++ b/src/main/java/org/keycloak/webbuilder/WebBuilder.java @@ -9,6 +9,7 @@ import org.keycloak.webbuilder.builders.GitHubReleaseNotesBuilder; import org.keycloak.webbuilder.builders.GuideBuilder; import org.keycloak.webbuilder.builders.PageBuilder; +import org.keycloak.webbuilder.builders.QuickThemeBuilder; import org.keycloak.webbuilder.builders.RedirectBuilder; import org.keycloak.webbuilder.builders.ReleaseNotesBuilder; import org.keycloak.webbuilder.builders.ResourcesBuilder; @@ -34,6 +35,7 @@ public class WebBuilder { new DownloadsArchiveBuilder(), new RssFeedBuilder(), new AppBuilder(), + new QuickThemeBuilder(), new RedirectBuilder() }; diff --git a/src/main/java/org/keycloak/webbuilder/builders/QuickThemeBuilder.java b/src/main/java/org/keycloak/webbuilder/builders/QuickThemeBuilder.java new file mode 100644 index 00000000..957fc27d --- /dev/null +++ b/src/main/java/org/keycloak/webbuilder/builders/QuickThemeBuilder.java @@ -0,0 +1,78 @@ +package org.keycloak.webbuilder.builders; + +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class QuickThemeBuilder extends AbstractBuilder { + + @Override + protected void build() throws Exception { + File quickthemeDir = resolveQuickthemeDir(); + File distDir = new File(quickthemeDir, "dist"); + File targetDir = new File(context.getTargetDir(), "quick-theme"); + + if (!distDir.isDirectory() || !new File(distDir, "index.html").isFile()) { + runCommand(quickthemeDir, command("npm", "ci")); + runCommand(quickthemeDir, command("npm", "run", "build")); + } else { + printStep("skipped", "quicktheme build (dist already present)"); + } + + if (!distDir.isDirectory()) { + throw new IllegalStateException("quicktheme dist/ not found after build"); + } + + if (targetDir.exists()) { + FileUtils.deleteDirectory(targetDir); + } + FileUtils.copyDirectory(distDir, targetDir); + printStep("copied", "quick-theme to " + targetDir.getPath()); + } + + private File resolveQuickthemeDir() throws Exception { + File inRepo = new File(context.getWebSrcDir(), "quicktheme"); + if (isQuickthemeProject(inRepo)) { + return inRepo; + } + File sibling = new File(context.getWebSrcDir(), "../quicktheme"); + if (isQuickthemeProject(sibling)) { + return sibling.getCanonicalFile(); + } + throw new IllegalStateException( + "quicktheme project not found. Expected quicktheme/ in the website repo " + + "or ../quicktheme as a sibling directory."); + } + + private boolean isQuickthemeProject(File dir) { + return dir.isDirectory() && new File(dir, "package.json").isFile(); + } + + private String[] command(String... parts) { + return parts; + } + + private void runCommand(File dir, String... cmd) throws Exception { + List command = new ArrayList<>(); + for (String part : cmd) { + command.add(part); + } + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(dir); + pb.inheritIO(); + Process process = pb.start(); + int exit = process.waitFor(); + if (exit != 0) { + throw new RuntimeException( + "Command failed with exit code " + exit + ": " + String.join(" ", command)); + } + printStep("ran", String.join(" ", command) + " in " + dir.getName()); + } + + @Override + protected String getTitle() { + return "Quick Theme"; + } +}