Death, Love and Keycloak Theme on Vue3 (TS)

Creation own (custom) login pages through the service Keycloak is a separate type of art. Not only do theme templates use a not widely used templating language .ftl (FreeMarker), so the developer still needs to know almost all the environment variables that are needed to work with keycloak.

But when a developer is faced with the task of creating a custom theme using technologies familiar to the team, this “lucky guy” may start tearing his hair out.

This is exactly the task that faced me and the solution was found by a miracle. It is precisely because of this that I am still not an exact copy of Vin Diesel's character from the movie “Ridik”.

Repository with implemented custom theme Here.

Introduction

First of all, it's worth telling what keycloak is? Since I'm a front-end developer, I won't go into the details of how this is implemented. “spaceship” I won't. Keycloak is a service that allows for very flexible management of client access within the product. At first glance, it's a highly-pumped CRM.

Based on this service, we, the development team “Analytical Center of Nizhny Novgorod” (ANO ACG)decided to create a single entry point (SSO) for all company services. Our front is built on Vue. As a team leader, I took on this task.

Unfortunately, I couldn't find anything suitable after 3-4 hours of intensive searching. At the very end and almost in complete despair, I just started looking for repositories on GitHub. The most similar solution was keycloakify, but it is sharpened for React. And here I found repositorycreated in 2022, by a great Portuguese developer. The `README.md` fully describes the method of how to run this project (on keycloak version 16.0.2). After a short dance with a tambourine, I managed to run it. I figured out how this “chimera” and I want to show it to you.

Analysis of source codes

First, let's download repository and let's see how the developer implemented the connection between Vue and FreeMarker.

What immediately catches your eye and then the question arises – where is the folder? publicfile index.html? Maybe in src:

We don't see it here either.

Let's go further and take a look at the file webpack'a

Quite a lot has been written here, so we will only dwell on the important points.

webpack

We can notice two variables – THEME_NAME And entries

const THEME_NAME = "openfinance";
const entries = [
	"login",
	"register",
	"login-reset-password",
	"login-update-profile",
	"login-idp-link-confirm",
	"login-idp-link-email",
];

The first one is responsible for the name of our future theme, the second one contains a list of states that we will get acquainted with later. Next comes the webpack configuration itself.

Let me make it clear right away – I will not describe the fields that are understandable to many: devtool, resolve, mode, watch, module (Documentation).

entry

This field tells webpack that entry points will be files. index.ts in each state that must be stored in src/views (Documentation).

output: {
  path: path.resolve(__dirname, '..', 'themes', THEME_NAME, 'login'),
  filename: 'resources/js/[name].js',
  publicPath: '/'
},

Here we already begin to understand that the assembled components will be located outside the repository.

Plugins

In the plugins section, we will go in order and only the important ones

HTMLWebpackPlugin – generates files .ftl for each state based on index.html (in simple terms – renaming)

CopeWebpackPlugin – the name and example of use speak for themselves.

plugins: [
  ...entries.map(
    entry =>
      new HtmlWebpackPlugin({
        inject: false,
        template: path.resolve(
          __dirname,
          'src',
          'views',
          entry,
          'index.ftl'
        ),
        filename: `${entry}.ftl`,
        minify: false
      })
  ),
  new CopyWebpackPlugin({
    patterns: [
      {
        from: path.resolve(__dirname, 'src', 'static'),
        to: path.resolve(__dirname, '..', 'themes', THEME_NAME, 'login')
      }
    ]
  })
],

This copying is necessary to use a common template for all states. Let's move on to it.

template.ftl (be sure to check out the file at the link)

In this file we already see the syntax of FreeMarker. Having understood this code, I was surprised by the developer's ingenuity.

To implement access to keycloak's environment variables and i18n text, it creates a global script that is interpreted as json and in the future it will be useful in the functional part and templates of our Vue components.

The very last tag inside <body> we see some kind of construction <#nested "scripts">. It can be compared to slots in Vue. Now let's figure out where it is used.

Working with Vue3

Let's go to the folder views/login.

Let's look at the file index.ftl

<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
  <#if section = "scripts">
    <script typo="module" src="https://habr.com/ru/articles/841550/${url.resourcesPath}/js/login.js"></script>
  </#if>
</@layout.registrationLayout>

In the 3rd line we see a condition that checks the section for a value "scripts" and when the condition is met, inserts a certain script. Next, in the 4th line, we see this very script, which is a compiled version of the application, separately compiled for each state.

In this architecture we work as follows. Each state is a separate and independent page. This way we understand that keycloak's themes adhere to MPA approach. If you switch to Vue, then the developer, taking into account all of the above, understands that his application will work in SSG MPA mode.

But for fans and adherents of the SPA approach, I will say that there is one loophole. Files index.ts in each state are actually main.ts file that is architecturally accepted in vite And vue-cli as an entry point into applications.

Working with keycloak variables

We have already encountered a large script in tempalte.ftlwhich is where keycloak variables are registered in a format accessible to js.

<script id="environment" type="application/json">
	{
        "urls": {
            "loginResetCredentials": "${url.loginResetCredentialsUrl}",
            "login": "${url.loginUrl}",
            "registration": "${url.registrationUrl}",
            "loginAction": "${url.loginAction}",
            "registrationAction": "${url.registrationAction}",
            "resourcesPath": "${url.resourcesPath}"
        },
        "titles": {
            "loginProfileTitle": "${msg("loginProfileTitle")}",
            "loginAccountTitle": "${msg("loginAccountTitle")}",
            "registerTitle": "${msg("registerTitle")}",
            "emailForgotTitle": "${msg("emailForgotTitle")}",
            "confirmLinkIdpTitle": "${msg("confirmLinkIdpTitle")}",
            "emailLinkIdpTitle": "${msg("emailLinkIdpTitle", idpDisplayName)}"
        },
        "permissions": {
            "usernameEditDisabled": <#if usernameEditDisabled??>true<#else>false</#if>,
            "loginWithEmailAllowed": <#if realm.loginWithEmailAllowed>true<#else>false</#if>,
            "registrationEmailAsUsername": <#if realm.registrationEmailAsUsername>true<#else>false</#if>,
            "rememberMe": <#if realm.rememberMe>true<#else>false</#if>,
            "resetPasswordAllowed": <#if realm.resetPasswordAllowed>true<#else>false</#if>,
            "password": <#if realm.password>true<#else>false</#if>,
            "registrationAllowed": <#if realm.registrationAllowed>true<#else>false</#if>,
            "registrationDisabled": <#if registrationDisabled??>true<#else>false</#if>,
            "passwordRequired": <#if passwordRequired??>true<#else>false</#if>
        },
        "labels": {
            "firstName": "${msg("firstName")}",
            "lastName": "${msg("lastName")}",
            "username": "${msg("username")}",
            "usernameOrEmail": "${msg("usernameOrEmail")}",
            "email": "${msg("email")}",
            "password": "${msg("password")}",
            "passwordConfirm": "${msg("passwordConfirm")}",
            "rememberMe": "${msg("rememberMe")}",
            "doForgotPassword": "${msg("doForgotPassword")}",
            "doLogIn": "${msg("doLogIn")}",
            "doSubmit": "${msg("doSubmit")}",
            "noAccount": "${msg("noAccount")}",
            "doRegister": "${msg("doRegister")}",
            "backToLogin": "${kcSanitize(msg("backToLogin"))?no_esc}",
            "confirmLinkIdpContinue": "${msg("confirmLinkIdpContinue")}",
            "doClickHere": "${msg("doClickHere")}"
        },
        "forms": {
            "loginUsername": "${(login.username!'')}",
            "loginRememberMe": <#if login.rememberMe??>true<#else>false</#if>,
            "selectedCredential": "${(auth.selectedCredential!'')}",
            "registerFirstName": <#if register??>"${(register.formData.firstName!'')}"<#else>""</#if>,
            "registerLastName": <#if register??>"${(register.formData.lastName!'')}"<#else>""</#if>,
            "registerEmail": <#if register??>"${(register.formData.email!'')}"<#else>""</#if>,
            "registerUsername": <#if register??>"${(register.formData.username!'')}"<#else>""</#if>
        },
        "user": {
            "username": <#if user??>"${(user.username!'')}"<#else>""</#if>,
            "email": <#if user??>"${(user.email!'')}"<#else>""</#if>,
            "firstName": <#if user??>"${(user.firstName!'')}"<#else>""</#if>,
            "lastName": <#if user??>"${(user.lastName!'')}"<#else>""</#if>
        },
        "validations": {
            "firstName": <#if messagesPerField.existsError('firstName')>"${kcSanitize(messagesPerField.get('firstName'))?no_esc}"<#else>""</#if>,
            "lastName":  <#if messagesPerField.existsError('lastName')>"${kcSanitize(messagesPerField.get('lastName'))?no_esc}"<#else>""</#if>,
            "email": <#if messagesPerField.existsError('email')>"${kcSanitize(messagesPerField.get('email'))?no_esc}"<#else>""</#if>,
            "usernameOrPassword": <#if messagesPerField.existsError('username','password')>"${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}"<#else>""</#if>,
            "username": <#if messagesPerField.existsError('username')>"${kcSanitize(messagesPerField.get('username'))?no_esc}"<#else>""</#if>,
            "password": <#if messagesPerField.existsError('password')>"${kcSanitize(messagesPerField.get('password'))?no_esc}"<#else>""</#if>,
            "passwordConfirm": <#if messagesPerField.existsError('password-confirm')>"${kcSanitize(messagesPerField.get('password-confirm'))?no_esc}"<#else>""</#if>
        },
        "message": {
            "type": <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>"${message.type}"<#else>""</#if>,
            "sumary": <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>"${kcSanitize(message.summary)?no_esc}"<#else>""</#if>
        },
        "social": [
            <#if realm.password && social.providers??>
            <#list social.providers as p>
                {
                "alias": "${p.alias}",
                "displayName": "${p.displayName!}",
                "loginUrl": "${p.loginUrl}"
                }<#sep>, </#sep>
            </#list>
            </#if>
        ]
	}
</script>

It is not for nothing that this object has an id attribute.

First, let's take a look again src/views/login/index.ts.

import '~/scss/index.scss'
import { createApp } from 'vue'
import index from './index.vue'

const environment = document.querySelector('#environment')
if (environment) {
  const app = createApp(index)
  app.provide<Environment>('environment', JSON.parse(String(environment.textContent)))
  app.mount('#app')
}

We see that the object from the above script is taken and thrown into the entire application below (Provide/Inject).
This way we can get this object anywhere in our Vue application.

Let's turn to the folder src/hooks.

index.ts fully imports login.tsso let's turn to it right away

login.ts (be sure to check the file)

Here we already see the use of the variable passed by provide env.

The only exported function returns the fields of this object that we need and also implements some functions.

It is through this function that we will work from Vue with keycloak in the future.

Dry residue

To summarize this part, we understand that:

  • File template.ftl in conjunction with index.ftl states are analogous index.html in the classic approach to Vue.

  • File index.ts is the entry point to a separate Vue application of a separate state within keycloak.

  • If the developer wants, then this Vue application can also be added Routerand state manager (Pinia) and this will not affect the work with keycloak in any way.

  • Each state has its own application. This is how you can think index.vue file for App.vue. These applications are used in states by importing a script, which is a compiled version of the Vue application.

Necessary improvements

From the repository you can see that there are no fonts And pictures No. Let's add them and more.

First, we need to remember how our webpack. We use CopyWebpackPlugin to copy a folder src/static into the state folder itself. JavaScript is taken into the state from the folder resourcesrespectively, you can put fonts, images and some static css styles there too. Let's create a new pattern for copying in webpack.

new CopyWebpackPlugin({
    patterns: [
        {
            from: path.resolve(__dirname, "src", "static"),
            to: path.resolve(__dirname, "..", "themes", THEME_NAME, "login"),
        },
        // Копирование глобальных ресурсов
        {
            from: path.resolve(__dirname, "src", "resources"),
            to: path.resolve(
                __dirname,
                "..",
                "themes",
                THEME_NAME,
                "login",
                "resources",
            ),
        },
        // Копирование ресурсов (изображений), которые будут расположены в папке конкретного стейта
        ...entries
            .filter((entry) => fs.existsSync(`${__dirname}/src/views/${entry}/img`))
            .map((entry) => {
                return {
                    from: path.resolve(__dirname, "src", "views", entry, "img"),
                    to: path.resolve(
                        __dirname,
                        "..",
                        "themes",
                        THEME_NAME,
                        "login",
                        "resources",
                        "img",
                    ),
                };
            }),
    ],
}),

To access these photos and fonts we will need to make some minor modifications to the main template and hooks file.

Let's go back to the file src/hooks/login.ts.

Let's add a function:

const getImage = (url: string) => {
    return env.urls.resourcesPath + "/img" + url;
}

It is with its help that we will obtain images.

Now let's move on to src/static/template.ftl.

Let's add a tag <style> in the head of the file:

<style>
    @font-face {
        font-family: "Roboto-Bold";
        src: url("${url.resourcesPath}/fonts/Roboto-Bold.woff2");
    }
    @font-face {
        font-family: "Roboto-Medium";
        src: url("${url.resourcesPath}/fonts/Roboto-Medium.woff2");
    }
    @font-face {
        font-family: "Roboto-Regular";
        src: url("${url.resourcesPath}/fonts/Roboto-Regular.woff2");
    }
</style>

Registering favicon and default css styles:

<link rel="icon" href="https://habr.com/ru/articles/841550/${url.resourcesPath}/img/Logo.svg">
<link rel="stylesheet" href="${url.resourcesPath}/css/default.css">

Additional improvements

In our company, we have a practice of creating a folder of components that can be used throughout the application without directly importing them. Such components are mainly the atomic components of the project's UI-kit, which is why the folder is called – UI. This folder is located in src/components.

index.ts

import MyButton from "./MyButton.vue";
import MyInput from "./MyInput.vue";
import MyCheckbox from "./MyCheckbox.vue";
import LineWithText from "./LineWithText.vue";

const UIStore = [
    MyButton, MyInput, MyCheckbox, LineWithText, 
];

export default UIStore

Next, a block of such components must be registered in the Vue application.

src/views/login/index.ts

import { Environment } from "@doc-types/environment";

import { createApp } from "vue";
import index from "./index.vue";
// Импорт модуля UI компонент
import UIStore from "@components/UI";

const environment = document.querySelector("#environment") as HTMLElement;

const app = createApp(index);
app.provide<Environment>("environment", JSON.parse(String(environment.textContent)));

// Регистрация компонент на уровне приложения
UIStore.forEach((component) => {
    // @ts-ignore
    app.component(component.__name ?? component.name, component);
});

app.mount("#app");

Defining your aliases

webpack.config.js

resolve: {
    extensions: [".ts", ".tsx", ".js", ".vue", ".json", ".scss"],
    alias: {
        "@components": path.resolve(__dirname, "src/components"),
        "@": path.resolve(__dirname, "src"),
    },
},

tsconfig.json

"paths": {
    "@components/*": ["src/components/*"],
    "@/*": ["src/*"],
},

Global styles

Also, our company always has a set of global styles that we write in index.scss. You need to tell webpack that it needs to import global styles into the styles of each component.

To do this, it is necessary to refine the use of the sass loading module.

{
    test: /\.(scss|css)$/,
    use: [
        "style-loader",
        "css-loader",
        {
            loader: "postcss-loader",
            options: {
                postcssOptions: {
                    plugins: { autoprefixer: {} },
                },
            },
        },
        // Доработка применения модуля "sass-loader"
        {
            loader: "sass-loader",
            options: {
                additionalData: `@import "@/scss/index.scss";`,
            },
        },
    ],
},

Launch of the project

If you've made it to the very end and you're still not “nodding off”, then the last step for our project is to launch it.

Just one command:

docker-compose -f docker-compose.yml up --build -d

Source

The repository with fully implemented functionality is located Here.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *