Initial commit
This commit is contained in:
commit
ff5a958e26
3
.browserslistrc
Normal file
3
.browserslistrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = true
|
1
.eslintcache
Normal file
1
.eslintcache
Normal file
File diff suppressed because one or more lines are too long
40
.eslintrc.json
Normal file
40
.eslintrc.json
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": ["plugin:vue/essential", "standard", "prettier"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["vue", "@typescript-eslint", "prettier"],
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"max-lines": [
|
||||||
|
"error",
|
||||||
|
{ "max": 300, "skipComments": true, "skipBlankLines": true }
|
||||||
|
],
|
||||||
|
"max-lines-per-function": [
|
||||||
|
"error",
|
||||||
|
{ "max": 30, "skipComments": true, "skipBlankLines": true }
|
||||||
|
],
|
||||||
|
"max-len": ["error", { "code": 120 }],
|
||||||
|
"import/extensions": [
|
||||||
|
"error",
|
||||||
|
"ignorePackages",
|
||||||
|
{
|
||||||
|
"ts": "never",
|
||||||
|
"tsx": "never",
|
||||||
|
"vue": "never",
|
||||||
|
"json": "never"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
|
||||||
|
yarn-error.log
|
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"semi": true
|
||||||
|
}
|
52
README.md
Normal file
52
README.md
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# A modern Keycloak login theme
|
||||||
|
|
||||||
|
keycloak-modern-login is a Keycloak login theme that's build using Vue.js 3 and Typescript. It is easily extensible and bypasses the complexity of FreeMarker templates.
|
||||||
|
|
||||||
|
## State of this project
|
||||||
|
|
||||||
|
I wrote this project mainly for my own Keycloak instance. Since I don't have much time for that, only the pages I see in daily use are currently customized. For all other pages, Keycloak's default theme is used instead.
|
||||||
|
|
||||||
|
## Building a deployable JAR file
|
||||||
|
|
||||||
|
The project's development and building process are tested only on Linux. Node.js as well as Yarn must already be installed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Build a deployable JAR file
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
The final JAR (`theme.jar`) will be located inside the `dist` folder.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To be able to test the theme live during development, a Keycloak Docker container is used. The `dist` folder is mounted directly into the container without building a JAR. The theme is not cached, and changes are directly visible.
|
||||||
|
|
||||||
|
The compose file in the root directory starts a preconfigured Keycloak container. A realm named `test` is automatically imported, in which the theme is configured as the default login theme. The admin user in the `master` realm has `admin` as both its username and password.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Run fist dev build to create the folder structure
|
||||||
|
# Once Webpack has successfully compiled the project, you can cancel the process by pressing CTRL + C
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Start the development Keycloak instance
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Start Webpack in dev mode (which watches for changes and rebuilds the project automatically)
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
For testing, the account console of the test realm can be used to get a login window: [http://localhost:8080/realms/test/account](http://localhost:8080/realms/test/account)
|
||||||
|
|
||||||
|
### Adding new pages
|
||||||
|
|
||||||
|
If you want to add new pages, there are some things to be aware of.
|
||||||
|
|
||||||
|
- Create a new subfolder for the page in the `views` folder. The name of the new folder must match the name of the FTL file in the [Keycloak base theme](https://github.com/keycloak/keycloak/tree/main/themes/src/main/resources/theme/base/login).
|
||||||
|
- Copy the three `index.*` files of an existing page into the new folder. The page name also needs to be adjusted in the `index.ftl` file within the attribute `pageId` as well as in the path for the script.
|
||||||
|
- Add the new page in `webpack.config.js` in the upper part to the list `customPages`.
|
15
docker-compose.yaml
Normal file
15
docker-compose.yaml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
version: "3.3"
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:20.0.3
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./dist/theme/modern-login:/opt/keycloak/themes/modern-login
|
||||||
|
- ./realm-export.json:/opt/keycloak/data/import/realm.json
|
||||||
|
environment:
|
||||||
|
- KEYCLOAK_ADMIN=admin
|
||||||
|
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||||
|
command:
|
||||||
|
- start-dev
|
||||||
|
- --import-realm
|
51
package.json
Normal file
51
package.json
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "keycloak-modern-login",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "webpack",
|
||||||
|
"prod": "webpack --mode=production",
|
||||||
|
"zipjar": "cd dist; zip -r ../dist/theme.jar META-INF theme; cd -",
|
||||||
|
"build": "yarn prod && yarn zipjar",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint --cache --report-unused-disable-directives --format codeframe --ext js,ts,vue ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.16.7",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.16.8",
|
||||||
|
"@babel/plugin-transform-typescript": "^7.16.8",
|
||||||
|
"@babel/preset-env": "^7.16.8",
|
||||||
|
"@babel/preset-typescript": "^7.16.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.9.1",
|
||||||
|
"@typescript-eslint/parser": "^5.9.1",
|
||||||
|
"@vue/compiler-sfc": "^3.2.26",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
|
"babel-loader": "^8.2.3",
|
||||||
|
"clean-webpack-plugin": "^4.0.0",
|
||||||
|
"copy-webpack-plugin": "^10.2.4",
|
||||||
|
"css-loader": "^6.5.1",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-config-standard": "^16.0.3",
|
||||||
|
"eslint-import-resolver-typescript": "^2.5.0",
|
||||||
|
"eslint-plugin-import": "^2.25.4",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"eslint-plugin-promise": "^5.2.0",
|
||||||
|
"eslint-plugin-vue": "^8.3.0",
|
||||||
|
"html-webpack-plugin": "^5.5.0",
|
||||||
|
"postcss": "^8.4.21",
|
||||||
|
"postcss-loader": "^7.0.2",
|
||||||
|
"prettier": "^2.5.1",
|
||||||
|
"style-loader": "^3.3.1",
|
||||||
|
"tailwindcss": "^3.2.4",
|
||||||
|
"typescript": "^4.5.4",
|
||||||
|
"vue-loader": "^17.0.0",
|
||||||
|
"webpack": "^5.66.0",
|
||||||
|
"webpack-cli": "^4.9.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"rfc4648": "^1.5.2",
|
||||||
|
"vue": "^3.2.26"
|
||||||
|
}
|
||||||
|
}
|
1577
realm-export.json
Normal file
1577
realm-export.json
Normal file
File diff suppressed because it is too large
Load diff
15
src/components/ErrorBox.vue
Normal file
15
src/components/ErrorBox.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-row justify-center mt-6 w-full bg-red-100 border border-red-400 text-red-700 p-2 rounded">
|
||||||
|
<p class="text-center">
|
||||||
|
<slot></slot>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ErrorBox",
|
||||||
|
});
|
||||||
|
</script>
|
25
src/components/Layout.vue
Normal file
25
src/components/Layout.vue
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
'flex flex-col min-h-screen w-full md:w-1/2 lg:w-1/3 max-w-none md:max-w-2xl bg-gray-50 md:rounded-r-3xl shadow-2xl bg-[url(\'' +
|
||||||
|
context.url.resourcesPath +
|
||||||
|
'/img/background.jpg\')] bg-no-repeat bg-center bg-cover font-sans overflow-hidden'
|
||||||
|
">
|
||||||
|
<div class="flex flex-grow flex-col justify-center w-full px-8 md:px-20">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { KcContextBase } from "~/types/context";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Layout",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
context: (window as any).kcContext as KcContextBase.Common,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
5
src/shims-vue.d.ts
vendored
Normal file
5
src/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "*" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
8
src/static/jar/META-INF/keycloak-themes.json
Normal file
8
src/static/jar/META-INF/keycloak-themes.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"name": "modern-login",
|
||||||
|
"types": ["login"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
237
src/static/login/baselayout.ftl
Normal file
237
src/static/login/baselayout.ftl
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
<#function are_same_path path searchedPath>
|
||||||
|
<#if path?size != searchedPath?size>
|
||||||
|
<#return false>
|
||||||
|
</#if>
|
||||||
|
<#local i=0>
|
||||||
|
<#list path as property>
|
||||||
|
<#local searchedProperty=searchedPath[i]>
|
||||||
|
<#if searchedProperty?is_string && searchedProperty == "*">
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
<#if searchedProperty?is_string && !property?is_string>
|
||||||
|
<#return false>
|
||||||
|
</#if>
|
||||||
|
<#if searchedProperty?is_number && !property?is_number>
|
||||||
|
<#return false>
|
||||||
|
</#if>
|
||||||
|
<#if searchedProperty?string != property?string>
|
||||||
|
<#return false>
|
||||||
|
</#if>
|
||||||
|
<#local i+= 1>
|
||||||
|
</#list>
|
||||||
|
<#return true>
|
||||||
|
</#function>
|
||||||
|
<#function ftl_object_to_js_code_declaring_an_object pageId object path>
|
||||||
|
<#local isHash = "">
|
||||||
|
<#attempt>
|
||||||
|
<#local isHash = object?is_hash || object?is_hash_ex>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Can't evaluate if " + path?join(".") + " is hash">
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#if isHash>
|
||||||
|
|
||||||
|
<#if path?size gt 10>
|
||||||
|
<#return "ABORT: Too many recursive calls">
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local keys = "">
|
||||||
|
|
||||||
|
<#attempt>
|
||||||
|
<#local keys = object?keys>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: We can't list keys on this object">
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#local out_seq = []>
|
||||||
|
<#list keys as key>
|
||||||
|
<#if ["class","declaredConstructors","superclass","declaringClass" ]?seq_contains(key) >
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#if
|
||||||
|
(
|
||||||
|
["loginUpdatePasswordUrl", "loginUpdateProfileUrl", "loginUsernameReminderUrl", "loginUpdateTotpUrl"]?seq_contains(key) &&
|
||||||
|
are_same_path(path, ["url"])
|
||||||
|
) || (
|
||||||
|
key == "updateProfileCtx" &&
|
||||||
|
are_same_path(path, [])
|
||||||
|
) || (
|
||||||
|
key == "loginAction" &&
|
||||||
|
are_same_path(path, ["url"]) &&
|
||||||
|
["saml-post-form.ftl", "error.ftl", "info.ftl"]?seq_contains(pageId) &&
|
||||||
|
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||||
|
) || (
|
||||||
|
["contextData", "idpConfig", "idp", "authenticationSession"]?seq_contains(key) &&
|
||||||
|
are_same_path(path, ["brokerContext"]) &&
|
||||||
|
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
|
||||||
|
) || (
|
||||||
|
key == "identityProviderBrokerCtx" &&
|
||||||
|
are_same_path(path, []) &&
|
||||||
|
["login-idp-link-confirm.ftl", "login-idp-link-email.ftl" ]?seq_contains(pageId)
|
||||||
|
) || (
|
||||||
|
["masterAdminClient", "delegateForUpdate", "defaultRole"]?seq_contains(key) &&
|
||||||
|
are_same_path(path, ["realm"])
|
||||||
|
)
|
||||||
|
>
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#if key == "attemptedUsername" && are_same_path(path, ["auth"])>
|
||||||
|
<#attempt>
|
||||||
|
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
<#recover>
|
||||||
|
</#attempt>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#attempt>
|
||||||
|
<#if !object[key]??>
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
<#recover>
|
||||||
|
<#local out_seq += ["/*Couldn't test if '" + key + "' is available on this object*/"]>
|
||||||
|
<#continue>
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#local propertyValue = "">
|
||||||
|
<#attempt>
|
||||||
|
<#local propertyValue = object[key]>
|
||||||
|
<#recover>
|
||||||
|
<#local out_seq += ["/*Couldn't dereference '" + key + "' on this object*/"]>
|
||||||
|
<#continue>
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#local rec_out = ftl_object_to_js_code_declaring_an_object(pageId, propertyValue, path + [ key ])>
|
||||||
|
<#if rec_out?starts_with("ABORT:")>
|
||||||
|
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
|
||||||
|
<#if errorMessage != " It's a method" >
|
||||||
|
<#local out_seq += ["/*" + key + ": " + errorMessage + "*/"]>
|
||||||
|
</#if>
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local out_seq += ['"' + key + '": ' + rec_out + ","]>
|
||||||
|
</#list>
|
||||||
|
|
||||||
|
<#return (["{"] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "}"])?join("\n")>
|
||||||
|
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local isMethod = "">
|
||||||
|
<#attempt>
|
||||||
|
<#local isMethod = object?is_method>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Can't test if it'sa method.">
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#if isMethod>
|
||||||
|
<#if are_same_path(path, ["auth", "showUsername"])>
|
||||||
|
<#attempt>
|
||||||
|
<#return auth.showUsername()?c>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Couldn't evaluate auth.showUsername()">
|
||||||
|
</#attempt>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#if are_same_path(path, ["auth", "showResetCredentials"])>
|
||||||
|
<#attempt>
|
||||||
|
<#return auth.showResetCredentials()?c>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Couldn't evaluate auth.showResetCredentials()">
|
||||||
|
</#attempt>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#if are_same_path(path, ["auth", "showTryAnotherWayLink"])>
|
||||||
|
<#attempt>
|
||||||
|
<#return auth.showTryAnotherWayLink()?c>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Couldn't evaluate auth.showTryAnotherWayLink()">
|
||||||
|
</#attempt>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#return "ABORT: It's a method">
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local isBoolean = "">
|
||||||
|
<#attempt>
|
||||||
|
<#local isBoolean = object?is_boolean>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Can't test if it's a boolean">
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#if isBoolean>
|
||||||
|
<#return object?c>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local isEnumerable = "">
|
||||||
|
<#attempt>
|
||||||
|
<#local isEnumerable = object?is_enumerable>
|
||||||
|
<#recover>
|
||||||
|
<#return "ABORT: Can't test if it's an enumerable">
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#if isEnumerable>
|
||||||
|
<#local out_seq = []>
|
||||||
|
<#local i = 0>
|
||||||
|
|
||||||
|
<#list object as array_item>
|
||||||
|
<#if !array_item??>
|
||||||
|
<#local out_seq += ["null,"]>
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#local rec_out = ftl_object_to_js_code_declaring_an_object(pageId, array_item, path + [ i ])>
|
||||||
|
<#local i = i + 1>
|
||||||
|
|
||||||
|
<#if rec_out?starts_with("ABORT:")>
|
||||||
|
<#local errorMessage = rec_out?remove_beginning("ABORT:")>
|
||||||
|
<#if errorMessage != " It's a method" >
|
||||||
|
<#local out_seq += ["/*" + i?string + ": " + errorMessage + "*/"]>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#continue>
|
||||||
|
</#if>
|
||||||
|
<#local out_seq += [rec_out + ","]>
|
||||||
|
</#list>
|
||||||
|
|
||||||
|
<#return (["["] + out_seq?map(str -> ""?right_pad(4 * (path?size + 1)) + str) + [ ""?right_pad(4 * path?size) + "]"])?join("\n")>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<#attempt>
|
||||||
|
<#return '"' + object?js_string + '"'>;
|
||||||
|
<#recover>
|
||||||
|
</#attempt>
|
||||||
|
|
||||||
|
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
|
||||||
|
</#function>
|
||||||
|
|
||||||
|
<#macro baseLayout pageId="template.ftl">
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>${msg("loginTitle",(realm.displayName!''))}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-image: url("${url.resourcesPath}/img/background.jpg");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
window.kcContext = ${ftl_object_to_js_code_declaring_an_object(pageId, .data_model, [])?no_esc}
|
||||||
|
</script>
|
||||||
|
<#nested "head">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<#nested "scripts">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</#macro>
|
BIN
src/static/login/resources/img/background.jpg
Normal file
BIN
src/static/login/resources/img/background.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 316 KiB |
2
src/static/login/theme.properties
Normal file
2
src/static/login/theme.properties
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
locales=en,de
|
||||||
|
parent=keycloak
|
441
src/types/context.ts
Normal file
441
src/types/context.ts
Normal file
|
@ -0,0 +1,441 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/** Take theses type definition with a grain of salt.
|
||||||
|
* Some values might be undefined on some pages.
|
||||||
|
* (ex: url.loginAction is undefined on error.ftl)
|
||||||
|
*/
|
||||||
|
export type KcContextBase =
|
||||||
|
| KcContextBase.Login
|
||||||
|
| KcContextBase.Register
|
||||||
|
| KcContextBase.RegisterUserProfile
|
||||||
|
| KcContextBase.Error
|
||||||
|
| KcContextBase.LoginResetPassword
|
||||||
|
| KcContextBase.LoginVerifyEmail
|
||||||
|
| KcContextBase.Terms
|
||||||
|
| KcContextBase.LoginOtp
|
||||||
|
| KcContextBase.LoginUsername
|
||||||
|
| KcContextBase.WebauthnAuthenticate
|
||||||
|
| KcContextBase.LoginPassword
|
||||||
|
| KcContextBase.LoginUpdatePassword
|
||||||
|
| KcContextBase.LoginUpdateProfile
|
||||||
|
| KcContextBase.LoginIdpLinkConfirm
|
||||||
|
| KcContextBase.LoginIdpLinkEmail
|
||||||
|
| KcContextBase.LoginPageExpired
|
||||||
|
| KcContextBase.LoginConfigTotp
|
||||||
|
| KcContextBase.LogoutConfirm
|
||||||
|
| KcContextBase.UpdateUserProfile
|
||||||
|
| KcContextBase.IdpReviewUserProfile;
|
||||||
|
|
||||||
|
export type WebauthnAuthenticator = {
|
||||||
|
credentialId: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export declare namespace KcContextBase {
|
||||||
|
export type Common = {
|
||||||
|
url: {
|
||||||
|
loginAction: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
resourcesCommonPath: string;
|
||||||
|
loginRestartFlowUrl: string;
|
||||||
|
loginUrl: string;
|
||||||
|
};
|
||||||
|
realm: {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
displayNameHtml?: string;
|
||||||
|
internationalizationEnabled: boolean;
|
||||||
|
registrationEmailAsUsername: boolean;
|
||||||
|
};
|
||||||
|
locale?: {
|
||||||
|
supported: {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
languageTag: string;
|
||||||
|
}[];
|
||||||
|
currentLanguageTag: string;
|
||||||
|
};
|
||||||
|
auth?: {
|
||||||
|
showUsername?: boolean;
|
||||||
|
showResetCredentials?: boolean;
|
||||||
|
showTryAnotherWayLink?: boolean;
|
||||||
|
attemptedUsername?: string;
|
||||||
|
};
|
||||||
|
scripts: string[];
|
||||||
|
message?: {
|
||||||
|
type: "success" | "warning" | "error" | "info";
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
client: {
|
||||||
|
clientId: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
isAppInitiatedAction: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Login = Common & {
|
||||||
|
url: {
|
||||||
|
loginResetCredentialsUrl: string;
|
||||||
|
registrationUrl: string;
|
||||||
|
};
|
||||||
|
realm: {
|
||||||
|
loginWithEmailAllowed: boolean;
|
||||||
|
rememberMe: boolean;
|
||||||
|
password: boolean;
|
||||||
|
resetPasswordAllowed: boolean;
|
||||||
|
registrationAllowed: boolean;
|
||||||
|
};
|
||||||
|
auth: {
|
||||||
|
selectedCredential?: string;
|
||||||
|
};
|
||||||
|
registrationDisabled: boolean;
|
||||||
|
login: {
|
||||||
|
username?: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
};
|
||||||
|
usernameEditDisabled: boolean;
|
||||||
|
social: {
|
||||||
|
displayInfo: boolean;
|
||||||
|
providers?: {
|
||||||
|
loginUrl: string;
|
||||||
|
alias: string;
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterCommon = Common & {
|
||||||
|
url: {
|
||||||
|
registrationAction: string;
|
||||||
|
};
|
||||||
|
passwordRequired: boolean;
|
||||||
|
recaptchaRequired: boolean;
|
||||||
|
recaptchaSiteKey?: string;
|
||||||
|
social: {
|
||||||
|
displayInfo: boolean;
|
||||||
|
providers?: {
|
||||||
|
loginUrl: string;
|
||||||
|
alias: string;
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Register = RegisterCommon & {
|
||||||
|
register: {
|
||||||
|
formData: {
|
||||||
|
firstName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterUserProfile = RegisterCommon & {
|
||||||
|
profile: {
|
||||||
|
context: "REGISTRATION_PROFILE";
|
||||||
|
attributes: Attribute[];
|
||||||
|
attributesByName: Record<string, Attribute>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Error = Common & {
|
||||||
|
client?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
message: NonNullable<Common["message"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResetPassword = Common & {
|
||||||
|
realm: {
|
||||||
|
loginWithEmailAllowed: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginVerifyEmail = Common & {
|
||||||
|
user?: {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Terms = Common & {};
|
||||||
|
|
||||||
|
export type LoginOtp = Common & {
|
||||||
|
otpLogin: {
|
||||||
|
userOtpCredentials: { id: string; userLabel: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginUsername = Common & {
|
||||||
|
url: {
|
||||||
|
loginResetCredentialsUrl: string;
|
||||||
|
registrationUrl: string;
|
||||||
|
};
|
||||||
|
realm: {
|
||||||
|
loginWithEmailAllowed: boolean;
|
||||||
|
rememberMe: boolean;
|
||||||
|
password: boolean;
|
||||||
|
resetPasswordAllowed: boolean;
|
||||||
|
registrationAllowed: boolean;
|
||||||
|
};
|
||||||
|
registrationDisabled: boolean;
|
||||||
|
login: {
|
||||||
|
username?: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
};
|
||||||
|
usernameHidden?: boolean;
|
||||||
|
social: {
|
||||||
|
displayInfo: boolean;
|
||||||
|
providers?: {
|
||||||
|
loginUrl: string;
|
||||||
|
alias: string;
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginPassword = Common & {
|
||||||
|
url: {
|
||||||
|
loginResetCredentialsUrl: string;
|
||||||
|
registrationUrl: string;
|
||||||
|
};
|
||||||
|
realm: {
|
||||||
|
resetPasswordAllowed: boolean;
|
||||||
|
};
|
||||||
|
auth?: {
|
||||||
|
showUsername?: boolean;
|
||||||
|
showResetCredentials?: boolean;
|
||||||
|
showTryAnotherWayLink?: boolean;
|
||||||
|
attemptedUsername?: string;
|
||||||
|
};
|
||||||
|
social: {
|
||||||
|
displayInfo: boolean;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebauthnAuthenticate = Common & {
|
||||||
|
authenticators: {
|
||||||
|
authenticators: WebauthnAuthenticator[];
|
||||||
|
};
|
||||||
|
challenge: string;
|
||||||
|
userVerification: UserVerificationRequirement | "not specified";
|
||||||
|
rpId: string;
|
||||||
|
createTimeout: string;
|
||||||
|
isUserIdentified: "true" | "false";
|
||||||
|
shouldDisplayAuthenticators: boolean;
|
||||||
|
social: {
|
||||||
|
displayInfo: boolean;
|
||||||
|
};
|
||||||
|
login: {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginUpdatePassword = Common & {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginUpdateProfile = Common & {
|
||||||
|
user: {
|
||||||
|
editUsernameAllowed: boolean;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginIdpLinkConfirm = Common & {
|
||||||
|
idpAlias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginIdpLinkEmail = Common & {
|
||||||
|
brokerContext: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
idpAlias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginPageExpired = Common & {
|
||||||
|
pageId: "login-page-expired.ftl";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginConfigTotp = Common & {
|
||||||
|
pageId: "login-config-totp.ftl";
|
||||||
|
mode?: "qr" | "manual" | undefined | null;
|
||||||
|
totp: {
|
||||||
|
totpSecretEncoded: string;
|
||||||
|
qrUrl: string;
|
||||||
|
policy: {
|
||||||
|
supportedApplications: string[];
|
||||||
|
algorithm: "HmacSHA1" | "HmacSHA256" | "HmacSHA512";
|
||||||
|
digits: number;
|
||||||
|
lookAheadWindow: number;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: "totp";
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "hotp";
|
||||||
|
initialCounter: number;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
totpSecretQrCode: string;
|
||||||
|
manualUrl: string;
|
||||||
|
totpSecret: string;
|
||||||
|
otpCredentials: { id: string; userLabel: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogoutConfirm = Common & {
|
||||||
|
pageId: "logout-confirm.ftl";
|
||||||
|
url: {
|
||||||
|
logoutConfirmAction: string;
|
||||||
|
};
|
||||||
|
client: {
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
logoutConfirm: {
|
||||||
|
code: string;
|
||||||
|
skipLink?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserProfile = Common & {
|
||||||
|
pageId: "update-user-profile.ftl";
|
||||||
|
profile: {
|
||||||
|
attributes: Attribute[];
|
||||||
|
attributesByName: Record<string, Attribute>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdpReviewUserProfile = Common & {
|
||||||
|
pageId: "idp-review-user-profile.ftl";
|
||||||
|
profile: {
|
||||||
|
context: "IDP_REVIEW";
|
||||||
|
attributes: Attribute[];
|
||||||
|
attributesByName: Record<string, Attribute>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Attribute = {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
required: boolean;
|
||||||
|
value?: string;
|
||||||
|
group?: string;
|
||||||
|
groupDisplayHeader?: string;
|
||||||
|
groupDisplayDescription?: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
validators: Validators;
|
||||||
|
annotations: Record<string, string>;
|
||||||
|
groupAnnotations: Record<string, string>;
|
||||||
|
autocomplete?:
|
||||||
|
| "on"
|
||||||
|
| "off"
|
||||||
|
| "name"
|
||||||
|
| "honorific-prefix"
|
||||||
|
| "given-name"
|
||||||
|
| "additional-name"
|
||||||
|
| "family-name"
|
||||||
|
| "honorific-suffix"
|
||||||
|
| "nickname"
|
||||||
|
| "email"
|
||||||
|
| "username"
|
||||||
|
| "new-password"
|
||||||
|
| "current-password"
|
||||||
|
| "one-time-code"
|
||||||
|
| "organization-title"
|
||||||
|
| "organization"
|
||||||
|
| "street-address"
|
||||||
|
| "address-line1"
|
||||||
|
| "address-line2"
|
||||||
|
| "address-line3"
|
||||||
|
| "address-level4"
|
||||||
|
| "address-level3"
|
||||||
|
| "address-level2"
|
||||||
|
| "address-level1"
|
||||||
|
| "country"
|
||||||
|
| "country-name"
|
||||||
|
| "postal-code"
|
||||||
|
| "cc-name"
|
||||||
|
| "cc-given-name"
|
||||||
|
| "cc-additional-name"
|
||||||
|
| "cc-family-name"
|
||||||
|
| "cc-number"
|
||||||
|
| "cc-exp"
|
||||||
|
| "cc-exp-month"
|
||||||
|
| "cc-exp-year"
|
||||||
|
| "cc-csc"
|
||||||
|
| "cc-type"
|
||||||
|
| "transaction-currency"
|
||||||
|
| "transaction-amount"
|
||||||
|
| "language"
|
||||||
|
| "bday"
|
||||||
|
| "bday-day"
|
||||||
|
| "bday-month"
|
||||||
|
| "bday-year"
|
||||||
|
| "sex"
|
||||||
|
| "tel"
|
||||||
|
| "tel-country-code"
|
||||||
|
| "tel-national"
|
||||||
|
| "tel-area-code"
|
||||||
|
| "tel-local"
|
||||||
|
| "tel-extension"
|
||||||
|
| "impp"
|
||||||
|
| "url"
|
||||||
|
| "photo";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Validators = Partial<{
|
||||||
|
length: Validators.DoIgnoreEmpty & Validators.Range;
|
||||||
|
double: Validators.DoIgnoreEmpty & Validators.Range;
|
||||||
|
integer: Validators.DoIgnoreEmpty & Validators.Range;
|
||||||
|
email: Validators.DoIgnoreEmpty;
|
||||||
|
"up-immutable-attribute": {};
|
||||||
|
"up-attribute-required-by-metadata-value": {};
|
||||||
|
"up-username-has-value": {};
|
||||||
|
"up-duplicate-username": {};
|
||||||
|
"up-username-mutation": {};
|
||||||
|
"up-email-exists-as-username": {};
|
||||||
|
"up-blank-attribute-value": Validators.ErrorMessage & {
|
||||||
|
"fail-on-null": boolean;
|
||||||
|
};
|
||||||
|
"up-duplicate-email": {};
|
||||||
|
"local-date": Validators.DoIgnoreEmpty;
|
||||||
|
pattern: Validators.DoIgnoreEmpty &
|
||||||
|
Validators.ErrorMessage & { pattern: string };
|
||||||
|
"person-name-prohibited-characters": Validators.DoIgnoreEmpty &
|
||||||
|
Validators.ErrorMessage;
|
||||||
|
uri: Validators.DoIgnoreEmpty;
|
||||||
|
"username-prohibited-characters": Validators.DoIgnoreEmpty &
|
||||||
|
Validators.ErrorMessage;
|
||||||
|
options: Validators.Options;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export declare namespace Validators {
|
||||||
|
export type DoIgnoreEmpty = {
|
||||||
|
"ignore.empty.value"?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorMessage = {
|
||||||
|
"error-message"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Range = {
|
||||||
|
min?: `${number}`;
|
||||||
|
max?: `${number}`;
|
||||||
|
};
|
||||||
|
export type Options = {
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
|
}
|
6
src/views/login/index.ftl
Normal file
6
src/views/login/index.ftl
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<#import "baselayout.ftl" as layout>
|
||||||
|
<@layout.baseLayout pageId="login.ftl" ; section>
|
||||||
|
<#if section = "scripts">
|
||||||
|
<script typo="module" src="${url.resourcesPath}/js/login.js"></script>
|
||||||
|
</#if>
|
||||||
|
</@layout.baseLayout>
|
7
src/views/login/index.ts
Normal file
7
src/views/login/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import index from "./index.vue";
|
||||||
|
|
||||||
|
if ((window as any).kcContext) {
|
||||||
|
const app = createApp(index);
|
||||||
|
app.mount("#app");
|
||||||
|
}
|
103
src/views/login/index.vue
Normal file
103
src/views/login/index.vue
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
<template>
|
||||||
|
<layout>
|
||||||
|
<h1 class="mb-8 text-3xl font-semibold text-center text-gray-700">
|
||||||
|
Login to your account
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="context.realm.registrationAllowed"
|
||||||
|
class="mt-4 text-sm font-light text-center text-gray-700">
|
||||||
|
Don't have an account yet?
|
||||||
|
<a
|
||||||
|
:href="context.url.registrationUrl"
|
||||||
|
class="font-medium text-blue-500 hover:underline"
|
||||||
|
>Sign up</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<div v-if="context.message?.type == 'error'">
|
||||||
|
<ErrorBox>{{ context.message?.summary }}</ErrorBox>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="flex flex-col justify-center mt-6"
|
||||||
|
:action="context.url.loginAction"
|
||||||
|
method="post">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm text-gray-800">{{
|
||||||
|
context.realm.loginWithEmailAllowed ? "Email or Username" : "Username"
|
||||||
|
}}</label>
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
:placeholder="
|
||||||
|
context.realm.loginWithEmailAllowed
|
||||||
|
? 'jane.doe@example.com'
|
||||||
|
: 'JDoe'
|
||||||
|
"
|
||||||
|
:value="context.login.username ? context.login.username : ''"
|
||||||
|
class="block w-full px-4 py-2 mt-2 text-gray-800 bg-white border rounded-md focus:border-blue-500 focus:ring-transparent focus:outline-none focus:ring focus:ring-opacity-40" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm text-gray-800"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
class="block w-full px-4 py-2 mt-2 text-gray-800 bg-white border rounded-md focus:border-blue-500 focus:ring-transparent focus:outline-none focus:ring focus:ring-opacity-40" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="context.realm.resetPasswordAllowed"
|
||||||
|
:href="context.url.loginResetCredentialsUrl"
|
||||||
|
class="text-xs text-gray-600 hover:underline"
|
||||||
|
>Forgot Password?</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="context.realm.rememberMe"
|
||||||
|
class="mt-5 flex flex-row items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="rememberMe"
|
||||||
|
class="w-4 h-4 text-blue-500 bg-gray-100 border-gray-400 rounded dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" />
|
||||||
|
<label for="rememberMe" class="block ml-3 text-sm text-gray-800"
|
||||||
|
>Remember me</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="credentialId"
|
||||||
|
:value="
|
||||||
|
context.auth.selectedCredential ? context.auth.selectedCredential : ''
|
||||||
|
" />
|
||||||
|
<button
|
||||||
|
class="w-full mt-6 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</layout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import Layout from "~/components/Layout.vue";
|
||||||
|
import ErrorBox from "~/components/ErrorBox.vue";
|
||||||
|
import type { KcContextBase } from "~/types/context";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Login",
|
||||||
|
components: {
|
||||||
|
Layout,
|
||||||
|
ErrorBox,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
context: (window as any).kcContext as KcContextBase.Login,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
</style>
|
6
src/views/webauthn-authenticate/index.ftl
Normal file
6
src/views/webauthn-authenticate/index.ftl
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<#import "baselayout.ftl" as layout>
|
||||||
|
<@layout.baseLayout pageId="webauthn-authenticate.ftl" ; section>
|
||||||
|
<#if section = "scripts">
|
||||||
|
<script typo="module" src="${url.resourcesPath}/js/webauthn-authenticate.js"></script>
|
||||||
|
</#if>
|
||||||
|
</@layout.baseLayout>
|
7
src/views/webauthn-authenticate/index.ts
Normal file
7
src/views/webauthn-authenticate/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import index from "./index.vue";
|
||||||
|
|
||||||
|
if ((window as any).kcContext) {
|
||||||
|
const app = createApp(index);
|
||||||
|
app.mount("#app");
|
||||||
|
}
|
202
src/views/webauthn-authenticate/index.vue
Normal file
202
src/views/webauthn-authenticate/index.vue
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
<template>
|
||||||
|
<layout>
|
||||||
|
<h1
|
||||||
|
v-if="context.isUserIdentified"
|
||||||
|
class="text-3xl font-semibold text-center text-gray-700">
|
||||||
|
Welcome {{ context.auth?.attemptedUsername }}!
|
||||||
|
</h1>
|
||||||
|
<h1 v-else class="text-3xl font-semibold text-center text-gray-700">
|
||||||
|
Welcome!
|
||||||
|
</h1>
|
||||||
|
<div v-if="webauthnSupported && !error">
|
||||||
|
<p class="mt-5 mb-5 text-center">
|
||||||
|
Please use one of your registered devices to continue:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="authenticator in context.authenticators.authenticators"
|
||||||
|
:key="authenticator.credentialId"
|
||||||
|
class="w-full h-20 mb-5 bg-white shadow-md rounded-md flex flex-row items-center">
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 ml-4 text-gray-700"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M6 .5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4H6v-4ZM7 1v1h1V1H7Zm2 0v1h1V1H9ZM5.5 5a.5.5 0 0 0-.5.5V15a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V5.5a.5.5 0 0 0-.5-.5h-6Z" />
|
||||||
|
</svg>
|
||||||
|
<div class="w-full ml-5 flex flex-col">
|
||||||
|
<p>{{ authenticator.label }}</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
Registered: {{ new Date(authenticator.createdAt).toLocaleString() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:onclick="prepareAuthenticate"
|
||||||
|
class="w-full px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
|
||||||
|
Authenticate
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
class="mt-5 text-sm text-center"
|
||||||
|
v-if="context.auth?.showTryAnotherWayLink">
|
||||||
|
Don't have your device at hand?
|
||||||
|
<a
|
||||||
|
:onclick="tryAnotherWay"
|
||||||
|
class="text-blue-500 hover:underline cursor-pointer"
|
||||||
|
>Try another way.</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!webauthnSupported">
|
||||||
|
<p class="mt-5 text-center">
|
||||||
|
It seems that your browser doesn't support WebAuthn. Please try logging
|
||||||
|
in with a different browser<span
|
||||||
|
v-if="!context.auth?.showTryAnotherWayLink"
|
||||||
|
>.</span
|
||||||
|
><span v-else
|
||||||
|
>, or try a different login method using the button below.</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="context.auth?.showTryAnotherWayLink"
|
||||||
|
:onclick="tryAnotherWay"
|
||||||
|
class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
|
||||||
|
Try another method
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="mt-5 flex flex-col">
|
||||||
|
<ErrorBox
|
||||||
|
>Something went wrong during authentication using your device.</ErrorBox
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:onclick="retryAuth"
|
||||||
|
class="w-full mt-5 px-4 py-2 tracking-wide text-white transition-colors duration-200 transform bg-blue-500 rounded-md hover:bg-blue-400 focus:outline-none focus:bg-blue-400">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</layout>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import Layout from "~/components/Layout.vue";
|
||||||
|
import ErrorBox from "~/components/ErrorBox.vue";
|
||||||
|
import type { KcContextBase } from "~/types/context";
|
||||||
|
import { base64url } from "rfc4648";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "Login",
|
||||||
|
components: {
|
||||||
|
Layout,
|
||||||
|
ErrorBox,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
context: (window as any).kcContext as KcContextBase.WebauthnAuthenticate,
|
||||||
|
webauthnSupported: true,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted: function () {
|
||||||
|
if (typeof PublicKeyCredential === "undefined") {
|
||||||
|
this.webauthnSupported = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
tryAnotherWay(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.formPost(this.context.url.loginAction, {
|
||||||
|
tryAnotherWay: "on",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
prepareAuthenticate() {
|
||||||
|
if (this.context.isUserIdentified) {
|
||||||
|
const allowedCredentials = new Array<PublicKeyCredentialDescriptor>();
|
||||||
|
this.context.authenticators.authenticators.forEach(authenticator => {
|
||||||
|
allowedCredentials.push({
|
||||||
|
id: base64url.parse(authenticator.credentialId, { loose: true }),
|
||||||
|
type: "public-key",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.authenticate(allowedCredentials);
|
||||||
|
} else {
|
||||||
|
this.authenticate([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async authenticate(
|
||||||
|
allowedAuthenticators: Array<PublicKeyCredentialDescriptor>
|
||||||
|
) {
|
||||||
|
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||||
|
rpId: this.context.rpId,
|
||||||
|
challenge: base64url.parse(this.context.challenge, { loose: true }),
|
||||||
|
};
|
||||||
|
if (this.context.createTimeout !== "0")
|
||||||
|
publicKey.timeout = Number(this.context.createTimeout) * 1000;
|
||||||
|
if (allowedAuthenticators.length)
|
||||||
|
publicKey.allowCredentials = allowedAuthenticators;
|
||||||
|
if (this.context.userVerification !== "not specified")
|
||||||
|
publicKey.userVerification = this.context.userVerification;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultRaw = await navigator.credentials.get({ publicKey });
|
||||||
|
if (!resultRaw || resultRaw.type !== "public-key") return;
|
||||||
|
const result = resultRaw as PublicKeyCredential;
|
||||||
|
if (!("authenticatorData" in result.response)) return;
|
||||||
|
const response = result.response as AuthenticatorAssertionResponse;
|
||||||
|
const clientDataJSON = response.clientDataJSON;
|
||||||
|
const authenticatorData = response.authenticatorData;
|
||||||
|
const signature = response.signature;
|
||||||
|
|
||||||
|
const postData = {
|
||||||
|
credentialId: result.id,
|
||||||
|
clientDataJSON: base64url.stringify(new Uint8Array(clientDataJSON), {
|
||||||
|
pad: false,
|
||||||
|
}),
|
||||||
|
authenticatorData: base64url.stringify(
|
||||||
|
new Uint8Array(authenticatorData),
|
||||||
|
{ pad: false }
|
||||||
|
),
|
||||||
|
signature: base64url.stringify(new Uint8Array(signature), {
|
||||||
|
pad: false,
|
||||||
|
}),
|
||||||
|
userHandle: base64url.stringify(
|
||||||
|
new Uint8Array(response.userHandle!),
|
||||||
|
{ pad: false }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.formPost(this.context.url.loginAction, postData);
|
||||||
|
} catch (err) {
|
||||||
|
this.error = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retryAuth() {
|
||||||
|
this.error = false;
|
||||||
|
},
|
||||||
|
formPost(url: string, data: object) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = url;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||||
|
const hiddenField = document.createElement("input");
|
||||||
|
hiddenField.type = "hidden";
|
||||||
|
hiddenField.name = key;
|
||||||
|
hiddenField.value = data[key];
|
||||||
|
|
||||||
|
form.appendChild(hiddenField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
</style>
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.vue"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
119
tsconfig.json
Normal file
119
tsconfig.json
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
"incremental": true /* Enable incremental compilation */,
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||||
|
"jsx": "preserve" /* Specify what JSX code is generated. */,
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "commonjs" /* Specify what module code is generated. */,
|
||||||
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||||
|
"baseUrl": "./src" /* Specify the base directory to resolve non-relative module names. */,
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./*"]
|
||||||
|
} /* Specify a set of entries that re-map imports to additional lookup locations. */,
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types",
|
||||||
|
"./src/@types"
|
||||||
|
] /* Specify multiple folders that act like `./node_modules/@types`. */,
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files */
|
||||||
|
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
"importHelpers": true /* Allow importing helper functions from tslib once per project, instead of including them per-file. */,
|
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
|
||||||
|
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
|
"noImplicitAny": false /* Enable error reporting for expressions and declarations with an implied `any` type.. */,
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"tests/**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
125
webpack.config.js
Normal file
125
webpack.config.js
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
const path = require("path");
|
||||||
|
const { VueLoaderPlugin } = require("vue-loader");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||||
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
|
|
||||||
|
const THEME_NAME = "modern-login";
|
||||||
|
const customPages = ["login", "webauthn-authenticate"];
|
||||||
|
|
||||||
|
module.exports = function (env, argv) {
|
||||||
|
const isDevelopment = argv.mode !== "production";
|
||||||
|
return {
|
||||||
|
entry: () => {
|
||||||
|
const entryList = {};
|
||||||
|
for (const entry of customPages) {
|
||||||
|
entryList[entry] = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"src",
|
||||||
|
"views",
|
||||||
|
entry,
|
||||||
|
"index.ts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entryList;
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, "dist", "theme", THEME_NAME, "login"),
|
||||||
|
filename: "resources/js/[name].js",
|
||||||
|
publicPath: "/",
|
||||||
|
},
|
||||||
|
devtool: "inline-cheap-module-source-map",
|
||||||
|
resolve: {
|
||||||
|
extensions: [".ts", ".tsx", ".js", ".vue", ".json", ".scss"],
|
||||||
|
alias: {
|
||||||
|
"~": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mode: isDevelopment ? "development" : "production",
|
||||||
|
watch: isDevelopment,
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.vue$/,
|
||||||
|
loader: "vue-loader",
|
||||||
|
options: {
|
||||||
|
sourceMap: isDevelopment,
|
||||||
|
extract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(ts|tsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: "babel-loader",
|
||||||
|
options: {
|
||||||
|
presets: ["@babel/preset-env", "@babel/preset-typescript"],
|
||||||
|
plugins: [
|
||||||
|
"@babel/plugin-transform-runtime",
|
||||||
|
"@babel/plugin-transform-typescript",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
"style-loader",
|
||||||
|
"css-loader",
|
||||||
|
{
|
||||||
|
loader: "postcss-loader",
|
||||||
|
options: {
|
||||||
|
postcssOptions: {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CleanWebpackPlugin(),
|
||||||
|
new VueLoaderPlugin(),
|
||||||
|
...customPages.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", "login"),
|
||||||
|
to: path.resolve(__dirname, "dist", "theme", THEME_NAME, "login"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: path.resolve(__dirname, "src", "static", "jar"),
|
||||||
|
to: path.resolve(__dirname, "dist"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
...(isDevelopment
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
optimization: {
|
||||||
|
removeAvailableModules: false,
|
||||||
|
removeEmptyChunks: false,
|
||||||
|
splitChunks: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in a new issue