Compare commits

...

5 Commits

13 changed files with 203 additions and 131 deletions

View File

@ -67,3 +67,9 @@ If you want to add new pages, there are some things to be aware of.
If the browser language is supported, it will be loaded automatically. Otherwise, English is loaded by default.
To add new languages, the corresponding file must be added under `static/login/resources/locales`. Then the language must be added to `SUPPORT_LOCALES` in `functions/i18n.ts`.
### Credits
Special thanks to the following projects, without which the development of keycloak-modern-login would not be possible:
- [Keycloakify](https://github.com/keycloakify/keycloakify), from which the [`ftl_object_to_js_code_declaring_an_object`](src/static/login/baselayout.ftl) function and [Keycloak context type definitions](src/types/context.ts) are derived.

View File

@ -45,7 +45,6 @@
"webpack-cli": "^4.9.1"
},
"dependencies": {
"moment": "^2.29.4",
"rfc4648": "^1.5.2",
"vue": "^3.2.26",
"vue-i18n": "9"

View File

@ -1,39 +1,39 @@
import { nextTick } from "vue"
import { createI18n } from "vue-i18n"
import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { KcContextBase } from "~/types/context";
export const SUPPORT_LOCALES = ["en", "de"]
export const SUPPORT_LOCALES = ["en", "de"];
export function setupI18n(options = {}) {
let browserLocale = navigator.language.split("-")[0];
let defaultLocale = "en";
if (SUPPORT_LOCALES.includes(browserLocale)) {
defaultLocale = browserLocale;
}
const browserLocale = navigator.language.split("-")[0];
let defaultLocale = "en";
if (SUPPORT_LOCALES.includes(browserLocale)) {
defaultLocale = browserLocale;
}
const i18n = createI18n(options)
setI18nLanguage(i18n, defaultLocale)
return i18n
const i18n = createI18n(options);
setI18nLanguage(i18n, defaultLocale);
return i18n;
}
export function setI18nLanguage(i18n, locale) {
loadLocaleMessages(i18n, locale);
if (i18n.mode === "legacy") {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
export function setI18nLanguage(i18n, locale) {
loadLocaleMessages(i18n, locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale
} else {
i18n.global.locale.value = locale
}
}
export async function loadLocaleMessages(i18n, locale) {
// load locale messages with dynamic import
let context = (window as any).kcContext as KcContextBase.Common;
const messages = await import(
/* webpackIgnore: true */`${context.url.resourcesPath}/locales/${locale}.js`
)
// set locale and locale message
i18n.global.setLocaleMessage(locale, messages.content)
return nextTick()
}
}
export async function loadLocaleMessages(i18n, locale) {
// load locale messages with dynamic import
const context = (window as any).kcContext as KcContextBase.Common;
const messages = await import(
/* webpackIgnore: true */ `${context.url.resourcesPath}/locales/${locale}.js`
);
// set locale and locale message
i18n.global.setLocaleMessage(locale, messages.content);
return nextTick();
}

View File

@ -1,3 +1,26 @@
<#--
MIT License
Copyright (c) 2020 GitHub user u/garronej
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<#function are_same_path path searchedPath>
<#if path?size != searchedPath?size>
<#return false>
@ -69,9 +92,12 @@
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"])
) || (
["password"]?seq_contains(key) &&
are_same_path(path, ["login"])
)
>
<#continue>

View File

@ -1,37 +1,40 @@
export const content = {
"login": {
"title": "Mit Ihrem Konto anmelden",
"noAccount": "Sie haben noch kein Konto?",
"signupLink": "Jetzt registrieren.",
"username": "Benutzername",
"usernameOrEmail": "Benutzername oder E-Mail-Adresse",
"password": "Passwort",
"forgotPassword": "Passwort vergessen?",
"rememberMe": "Angemeldet bleiben",
"login": "Anmelden",
"rety": "Erneut versuchen",
"welcome": "Willkommen!"
},
"2fa": {
"selectFactor": "Bitte wählen Sie einen zweiten Faktor aus:",
"hwSecKey": "Hardware Sicherheits-Schlüssel",
"hwSecKeyDesc": "Verwenden Sie ein WebAuthn kompatibles Gerät",
"otp": "Authenticator App",
"otpDesc": "Verwenden Sie einen Einmalcode aus ihrem Authenticator",
"recoveryCode": "Wiederherstellungsschlüssel",
"recoveryCodeDesc": "Verwenden Sie einen ihrer Wiederherstellungsschlüssel",
"tryAnotherWay": "Eine andere Option nutzen."
},
"webauthn": {
"title": "Bitte verwenden Sie eines ihrer Geräte, um fortzufahren:",
"registered": "Registriert: {date}",
"authenticate": "Fortfahren",
"noDevice": "Kein Gerät zur Hand?",
"noSupport": "Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung.",
"noSupportOtherMethod": "Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung, oder wählen Sie eine andere Anmeldeoption.",
"error": "Bei der Anmeldung mit ihrem Gerät ist etwas schief gelaufen. Bitte versuchen Sie es erneut, oder wählen Sie eine andere Anmeldeoption."
},
"redirect": {
"message": "Sie werden weitergeleitet..."
}
}
login: {
title: "Mit Ihrem Konto anmelden",
noAccount: "Sie haben noch kein Konto?",
signupLink: "Jetzt registrieren.",
username: "Benutzername",
usernameOrEmail: "Benutzername oder E-Mail-Adresse",
password: "Passwort",
forgotPassword: "Passwort vergessen?",
rememberMe: "Angemeldet bleiben",
login: "Anmelden",
rety: "Erneut versuchen",
welcome: "Willkommen!",
},
"2fa": {
selectFactor: "Bitte wählen Sie einen zweiten Faktor aus:",
hwSecKey: "Hardware Sicherheits-Schlüssel",
hwSecKeyDesc: "Verwenden Sie ein WebAuthn kompatibles Gerät",
otp: "Authenticator App",
otpDesc: "Verwenden Sie einen Einmalcode aus ihrem Authenticator",
recoveryCode: "Wiederherstellungsschlüssel",
recoveryCodeDesc: "Verwenden Sie einen ihrer Wiederherstellungsschlüssel",
tryAnotherWay: "Eine andere Option nutzen.",
},
webauthn: {
title: "Bitte verwenden Sie eines ihrer Geräte, um fortzufahren:",
registered: "Registriert: {date}",
authenticate: "Fortfahren",
noDevice: "Kein Gerät zur Hand?",
noSupport:
"Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung.",
noSupportOtherMethod:
"Es scheint, als würde ihr Browser WebAuthn nicht unterstützen. Bitte nutzen Sie einen anderen Browser für die Anmeldung, oder wählen Sie eine andere Anmeldeoption.",
error:
"Bei der Anmeldung mit ihrem Gerät ist etwas schief gelaufen. Bitte versuchen Sie es erneut, oder wählen Sie eine andere Anmeldeoption.",
},
redirect: {
message: "Sie werden weitergeleitet...",
},
};

View File

@ -1,37 +1,40 @@
export const content = {
"login": {
"title": "Login to your account",
"noAccount": "Don't have an account yet?",
"signupLink": "Sign up.",
"username": "Username",
"usernameOrEmail": "Email or Username",
"password": "Password",
"forgotPassword": "Forgot Password?",
"rememberMe": "Remember me",
"login": "Login",
"rety": "Retry",
"welcome": "Welcome!"
},
"2fa": {
"selectFactor": "Please select a second factor you would like to use:",
"hwSecKey": "Hardware Security Key",
"hwSecKeyDesc": "Authenticate using a WebAuthn capable device",
"otp": "Authenticator App",
"otpDesc": "Authenticate using a one time code from your authenticator app",
"recoveryCode": "Recovery Code",
"recoveryCodeDesc": "Authenticate using one of your recovery codes",
"tryAnotherWay": "Try another method."
},
"webauthn": {
"title": "Please use one of your registered devices to continue:",
"registered": "Registered: {date}",
"authenticate": "Continue",
"noDevice": "Don't have your device at hand?",
"noSupport": "It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser.",
"noSupportOtherMethod": "It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser, or try a different login method using the button below.",
"error": "Something went wrong during authentication using your device. Please try again, or use a different login method."
},
"redirect": {
"message": "Redirecting..."
}
}
login: {
title: "Login to your account",
noAccount: "Don't have an account yet?",
signupLink: "Sign up.",
username: "Username",
usernameOrEmail: "Email or Username",
password: "Password",
forgotPassword: "Forgot Password?",
rememberMe: "Remember me",
login: "Login",
rety: "Retry",
welcome: "Welcome!",
},
"2fa": {
selectFactor: "Please select a second factor you would like to use:",
hwSecKey: "Hardware Security Key",
hwSecKeyDesc: "Authenticate using a WebAuthn capable device",
otp: "Authenticator App",
otpDesc: "Authenticate using a one time code from your authenticator app",
recoveryCode: "Recovery Code",
recoveryCodeDesc: "Authenticate using one of your recovery codes",
tryAnotherWay: "Try another method.",
},
webauthn: {
title: "Please use one of your registered devices to continue:",
registered: "Registered: {date}",
authenticate: "Continue",
noDevice: "Don't have your device at hand?",
noSupport:
"It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser.",
noSupportOtherMethod:
"It seems that your browser doesn't support WebAuthn. Please try logging in with a different browser, or try a different login method using the button below.",
error:
"Something went wrong during authentication using your device. Please try again, or use a different login method.",
},
redirect: {
message: "Redirecting...",
},
};

View File

@ -1,4 +1,29 @@
/* eslint-disable */
/**
* MIT License
*
* Copyright (c) 2020 GitHub user u/garronej
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/** 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)

View File

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) {
const app = createApp(index);
app.use(setupI18n())
app.use(setupI18n());
app.mount("#app");
}

View File

@ -22,11 +22,14 @@
method="post">
<div>
<label for="username" class="block text-sm text-gray-800">{{
context.realm.loginWithEmailAllowed ? $t("login.usernameOrEmail") : $t("login.username")
context.realm.loginWithEmailAllowed
? $t("login.usernameOrEmail")
: $t("login.username")
}}</label>
<input
name="username"
type="text"
ref="focus"
:placeholder="
context.realm.loginWithEmailAllowed
? 'jane.doe@example.com'
@ -37,9 +40,9 @@
</div>
<div class="mt-4">
<div>
<label for="password" class="block text-sm text-gray-800"
>{{ $t("login.password") }}</label
>
<label for="password" class="block text-sm text-gray-800">{{
$t("login.password")
}}</label>
<input
type="password"
name="password"
@ -60,9 +63,9 @@
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"
>{{ $t("login.rememberMe") }}</label
>
<label for="rememberMe" class="block ml-3 text-sm text-gray-800">{{
$t("login.rememberMe")
}}</label>
</div>
<input
type="hidden"
@ -78,7 +81,7 @@
</layout>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, ref, onMounted, nextTick } from "vue";
import Layout from "~/components/Layout.vue";
import ErrorBox from "~/components/ErrorBox.vue";
import type { KcContextBase } from "~/types/context";
@ -94,6 +97,19 @@ export default defineComponent({
context: (window as any).kcContext as KcContextBase.Login,
};
},
setup() {
const focus = ref(null);
onMounted(() => {
nextTick(() => {
focus.value.focus();
});
});
return {
focus,
};
},
});
</script>
<style>

View File

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) {
const app = createApp(index);
app.use(setupI18n())
app.use(setupI18n());
app.mount("#app");
}

View File

@ -12,7 +12,9 @@
stroke-dasharray="164 56" />
</svg>
</div>
<h1 class="mt-4 text-xl text-center text-gray-700">{{ $t("redirect.message") }}</h1>
<h1 class="mt-4 text-xl text-center text-gray-700">
{{ $t("redirect.message") }}
</h1>
</layout>
</template>
<script lang="ts">

View File

@ -4,6 +4,6 @@ import { setupI18n } from "~/functions/i18n";
if ((window as any).kcContext) {
const app = createApp(index);
app.use(setupI18n())
app.use(setupI18n());
app.mount("#app");
}

View File

@ -24,7 +24,7 @@
<div class="w-full ml-5 flex flex-col">
<p>{{ authenticator.label }}</p>
<p class="text-sm">
{{ $t("webauthn.registered", {date: moment(authenticator.createdAt)}) }}
{{ $t("webauthn.registered", { date: authenticator.createdAt }) }}
</p>
</div>
</div>
@ -59,9 +59,7 @@
</button>
</div>
<div v-if="error" class="mt-5 flex flex-col">
<ErrorBox
>{{ $t("webauthn.error") }}</ErrorBox
>
<ErrorBox>{{ $t("webauthn.error") }}</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">
@ -77,7 +75,6 @@ import ErrorBox from "~/components/ErrorBox.vue";
import type { KcContextBase } from "~/types/context";
import { base64url } from "rfc4648";
import { formPost } from "~/functions/utils";
import moment from "moment";
export default defineComponent({
name: "WebAuthnAuthenticate",
@ -98,9 +95,6 @@ export default defineComponent({
}
},
methods: {
moment(date: string) : string {
return moment(date).toDate().toLocaleString();
},
tryAnotherWay(e: Event) {
e.preventDefault();
formPost(this.context.url.loginAction, {
@ -109,7 +103,7 @@ export default defineComponent({
},
prepareAuthenticate() {
if (this.context.isUserIdentified) {
const allowedCredentials = new Array<PublicKeyCredentialDescriptor>();
const allowedCredentials: PublicKeyCredentialDescriptor[] = [];
this.context.authenticators.authenticators.forEach(authenticator => {
allowedCredentials.push({
id: base64url.parse(authenticator.credentialId, { loose: true }),
@ -121,9 +115,7 @@ export default defineComponent({
this.authenticate([]);
}
},
async authenticate(
allowedAuthenticators: Array<PublicKeyCredentialDescriptor>
) {
async authenticate(allowedAuthenticators: PublicKeyCredentialDescriptor[]) {
const publicKey: PublicKeyCredentialRequestOptions = {
rpId: this.context.rpId,
challenge: base64url.parse(this.context.challenge, { loose: true }),