mirror of
https://github.com/lukin/keywind.git
synced 2025-01-25 00:36:26 +00:00
Merge branch 'master' into pr/32
This commit is contained in:
commit
dd7d40cca0
38 changed files with 1568 additions and 160 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,5 +1,8 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# build
|
||||
out/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
|
|
21
README.md
21
README.md
|
@ -6,20 +6,32 @@ Keywind is a component-based Keycloak Login Theme built with [Tailwind CSS](http
|
|||
|
||||
### Styled Pages
|
||||
|
||||
- Error
|
||||
- Login
|
||||
- Login Config TOTP
|
||||
- Login IDP Link Confirm
|
||||
- Login OAuth Grant
|
||||
- Login OTP
|
||||
- Login Page Expired
|
||||
- Login Password
|
||||
- Login Recovery Authn Code Config
|
||||
- Login Recovery Authn Code Input
|
||||
- Login Reset Password
|
||||
- Login Update Password
|
||||
- Login Update Profile
|
||||
- Login Username
|
||||
- Logout Confirm
|
||||
- Register
|
||||
- Select Authenticator
|
||||
- Terms and Conditions
|
||||
- WebAuthn Authenticate
|
||||
- WebAuthn Error
|
||||
- WebAuthn Register
|
||||
|
||||
### Identity Provider Icons
|
||||
|
||||
- Bitbucket
|
||||
- Discord
|
||||
- Facebook
|
||||
- GitHub
|
||||
- GitLab
|
||||
|
@ -30,6 +42,7 @@ Keywind is a component-based Keycloak Login Theme built with [Tailwind CSS](http
|
|||
- OpenID
|
||||
- Red Hat OpenShift
|
||||
- PayPal
|
||||
- Slack
|
||||
- Stack Overflow
|
||||
- Twitter
|
||||
|
||||
|
@ -45,7 +58,7 @@ Keywind has been designed with component-based architecture from the start, and
|
|||
parent=keywind
|
||||
```
|
||||
|
||||
4. Brand and customize components with [FreeMaker](https://freemarker.apache.org/docs/dgui_quickstart_template.html)
|
||||
4. Brand and customize components with [FreeMarker](https://freemarker.apache.org/docs/dgui_quickstart_template.html)
|
||||
|
||||
## Customization
|
||||
|
||||
|
@ -91,3 +104,9 @@ When you're ready to deploy your own theme, run the build command to generate a
|
|||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
To deploy a theme as an archive, create a JAR archive with the theme resources.
|
||||
|
||||
```bash
|
||||
pnpm build:jar
|
||||
```
|
||||
|
|
19
package.json
19
package.json
|
@ -1,20 +1,27 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "keywind",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"build:jar": "vite-node scripts/build",
|
||||
"dev": "vite build --watch",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.10.5"
|
||||
"alpinejs": "^3.12.0",
|
||||
"rfc4648": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@types/alpinejs": "^3.7.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.20",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.1"
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/node": "^18.15.0",
|
||||
"archiver": "^5.3.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.1.4",
|
||||
"vite-node": "^0.29.2"
|
||||
}
|
||||
}
|
||||
|
|
662
pnpm-lock.yaml
662
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
25
scripts/build.ts
Normal file
25
scripts/build.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import archiver from 'archiver';
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
||||
|
||||
import { name } from '../package.json';
|
||||
|
||||
const dir = 'out';
|
||||
const file = `${name}.jar`;
|
||||
const path = `${dir}/${file}`;
|
||||
|
||||
!existsSync(dir) && mkdirSync(dir);
|
||||
|
||||
const output = createWriteStream(`${__dirname}/../${path}`);
|
||||
|
||||
const archive = archiver('zip');
|
||||
|
||||
archive.on('error', (error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
archive.directory('META-INF', 'META-INF');
|
||||
archive.directory('theme', 'theme');
|
||||
|
||||
archive.finalize();
|
59
src/data/recoveryCodes.ts
Normal file
59
src/data/recoveryCodes.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import Alpine from 'alpinejs';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
codeList: HTMLUListElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
recoveryCodes: {
|
||||
downloadFileDate: string;
|
||||
downloadFileDescription: string;
|
||||
downloadFileHeader: string;
|
||||
downloadFileName: string;
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('recoveryCodes', function (this: DataType) {
|
||||
const { codeList } = this.$refs;
|
||||
const { downloadFileDate, downloadFileDescription, downloadFileHeader, downloadFileName } =
|
||||
this.$store.recoveryCodes;
|
||||
|
||||
const date = new Date().toLocaleString(navigator.language);
|
||||
|
||||
const codeElements = codeList.getElementsByTagName('li');
|
||||
const codes = Array.from(codeElements)
|
||||
.map((codeElement) => codeElement.innerText)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
copy: () => navigator.clipboard.writeText(codes),
|
||||
download: () => {
|
||||
const element = document.createElement('a');
|
||||
const text = `${downloadFileHeader}\n\n${codes}\n\n${downloadFileDescription}\n\n${downloadFileDate} ${date}`;
|
||||
|
||||
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||
element.setAttribute('download', `${downloadFileName}.txt`);
|
||||
element.click();
|
||||
},
|
||||
print: () => {
|
||||
const codeListHTML = codeList.innerHTML;
|
||||
const styles = 'div { font-family: monospace; list-style-type: none }';
|
||||
const content = `<html><style>${styles}</style><body><title>${downloadFileName}</title><p>${downloadFileHeader}</p><div>${codeListHTML}</div><p>${downloadFileDescription}</p><p>${downloadFileDate} ${date}</p></body></html>`;
|
||||
|
||||
const printWindow = window.open();
|
||||
|
||||
if (printWindow) {
|
||||
printWindow.document.write(content);
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
147
src/data/webAuthnAuthenticate.ts
Normal file
147
src/data/webAuthnAuthenticate.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
import Alpine from 'alpinejs';
|
||||
import { base64url } from 'rfc4648';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
authenticatorDataInput: HTMLInputElement;
|
||||
authnSelectForm?: HTMLFormElement;
|
||||
clientDataJSONInput: HTMLInputElement;
|
||||
credentialIdInput: HTMLInputElement;
|
||||
errorInput: HTMLInputElement;
|
||||
signatureInput: HTMLInputElement;
|
||||
userHandleInput: HTMLInputElement;
|
||||
webAuthnForm: HTMLFormElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
webAuthnAuthenticate: {
|
||||
challenge: string;
|
||||
createTimeout: string;
|
||||
isUserIdentified: string;
|
||||
rpId: string;
|
||||
unsupportedBrowserText: string;
|
||||
userVerification: UserVerificationRequirement | 'not specified';
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('webAuthnAuthenticate', function (this: DataType) {
|
||||
const {
|
||||
authenticatorDataInput,
|
||||
authnSelectForm,
|
||||
clientDataJSONInput,
|
||||
credentialIdInput,
|
||||
errorInput,
|
||||
signatureInput,
|
||||
userHandleInput,
|
||||
webAuthnForm,
|
||||
} = this.$refs;
|
||||
const {
|
||||
challenge,
|
||||
createTimeout,
|
||||
isUserIdentified,
|
||||
rpId,
|
||||
unsupportedBrowserText,
|
||||
userVerification,
|
||||
} = this.$store.webAuthnAuthenticate;
|
||||
|
||||
const doAuthenticate = (allowCredentials: PublicKeyCredentialDescriptor[]) => {
|
||||
if (!window.PublicKeyCredential) {
|
||||
errorInput.value = unsupportedBrowserText;
|
||||
webAuthnForm.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
challenge: base64url.parse(challenge, { loose: true }),
|
||||
rpId: rpId,
|
||||
};
|
||||
|
||||
if (allowCredentials.length) {
|
||||
publicKey.allowCredentials = allowCredentials;
|
||||
}
|
||||
|
||||
if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
|
||||
|
||||
if (userVerification !== 'not specified') publicKey.userVerification = userVerification;
|
||||
|
||||
navigator.credentials
|
||||
.get({ publicKey })
|
||||
.then((result) => {
|
||||
if (
|
||||
result instanceof PublicKeyCredential &&
|
||||
result.response instanceof AuthenticatorAssertionResponse
|
||||
) {
|
||||
window.result = result;
|
||||
|
||||
authenticatorDataInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.authenticatorData),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
clientDataJSONInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.clientDataJSON),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
signatureInput.value = base64url.stringify(new Uint8Array(result.response.signature), {
|
||||
pad: false,
|
||||
});
|
||||
|
||||
credentialIdInput.value = result.id;
|
||||
|
||||
if (result.response.userHandle) {
|
||||
userHandleInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.userHandle),
|
||||
{ pad: false }
|
||||
);
|
||||
}
|
||||
|
||||
webAuthnForm.submit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
errorInput.value = error;
|
||||
webAuthnForm.submit();
|
||||
});
|
||||
};
|
||||
|
||||
const checkAllowCredentials = () => {
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = [];
|
||||
|
||||
if (authnSelectForm) {
|
||||
const authnSelectFormElements = Array.from(authnSelectForm.elements);
|
||||
|
||||
if (authnSelectFormElements.length) {
|
||||
authnSelectFormElements.forEach((element) => {
|
||||
if (element instanceof HTMLInputElement) {
|
||||
allowCredentials.push({
|
||||
id: base64url.parse(element.value, { loose: true }),
|
||||
type: 'public-key',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
doAuthenticate(allowCredentials);
|
||||
};
|
||||
|
||||
return {
|
||||
webAuthnAuthenticate: () => {
|
||||
if (!isUserIdentified) {
|
||||
doAuthenticate([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
checkAllowCredentials();
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
225
src/data/webAuthnRegister.ts
Normal file
225
src/data/webAuthnRegister.ts
Normal file
|
@ -0,0 +1,225 @@
|
|||
import Alpine from 'alpinejs';
|
||||
import { base64url } from 'rfc4648';
|
||||
|
||||
type DataType = {
|
||||
$refs: RefsType;
|
||||
$store: StoreType;
|
||||
};
|
||||
|
||||
type RefsType = {
|
||||
attestationObjectInput: HTMLInputElement;
|
||||
authenticatorLabelInput: HTMLInputElement;
|
||||
clientDataJSONInput: HTMLInputElement;
|
||||
errorInput: HTMLInputElement;
|
||||
publicKeyCredentialIdInput: HTMLInputElement;
|
||||
registerForm: HTMLFormElement;
|
||||
transportsInput: HTMLInputElement;
|
||||
};
|
||||
|
||||
type StoreType = {
|
||||
webAuthnRegister: {
|
||||
attestationConveyancePreference: AttestationConveyancePreference | 'not specified';
|
||||
authenticatorAttachment: AuthenticatorAttachment | 'not specified';
|
||||
challenge: string;
|
||||
createTimeout: string;
|
||||
excludeCredentialIds: string;
|
||||
requireResidentKey: string;
|
||||
rpEntityName: string;
|
||||
rpId: string;
|
||||
signatureAlgorithms: string;
|
||||
unsupportedBrowserText: string;
|
||||
userId: string;
|
||||
userVerificationRequirement: UserVerificationRequirement | 'not specified';
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('webAuthnRegister', function (this: DataType) {
|
||||
const {
|
||||
attestationObjectInput,
|
||||
authenticatorLabelInput,
|
||||
clientDataJSONInput,
|
||||
errorInput,
|
||||
publicKeyCredentialIdInput,
|
||||
registerForm,
|
||||
transportsInput,
|
||||
} = this.$refs;
|
||||
const {
|
||||
attestationConveyancePreference,
|
||||
authenticatorAttachment,
|
||||
challenge,
|
||||
createTimeout,
|
||||
excludeCredentialIds,
|
||||
requireResidentKey,
|
||||
rpEntityName,
|
||||
rpId,
|
||||
signatureAlgorithms,
|
||||
unsupportedBrowserText,
|
||||
userId,
|
||||
userVerificationRequirement,
|
||||
username,
|
||||
} = this.$store.webAuthnRegister;
|
||||
|
||||
const getPubKeyCredParams = (signatureAlgorithms: string) => {
|
||||
const pubKeyCredParams: PublicKeyCredentialParameters[] = [];
|
||||
|
||||
if (signatureAlgorithms === '') {
|
||||
pubKeyCredParams.push({ alg: -7, type: 'public-key' });
|
||||
|
||||
return pubKeyCredParams;
|
||||
}
|
||||
|
||||
const signatureAlgorithmsList = signatureAlgorithms.split(',');
|
||||
|
||||
signatureAlgorithmsList.forEach((value) => {
|
||||
pubKeyCredParams.push({
|
||||
alg: parseInt(value),
|
||||
type: 'public-key',
|
||||
});
|
||||
});
|
||||
|
||||
return pubKeyCredParams;
|
||||
};
|
||||
|
||||
const getExcludeCredentials = (excludeCredentialIds: string) => {
|
||||
const excludeCredentials: PublicKeyCredentialDescriptor[] = [];
|
||||
|
||||
if (excludeCredentialIds === '') return excludeCredentials;
|
||||
|
||||
const excludeCredentialIdsList = excludeCredentialIds.split(',');
|
||||
|
||||
excludeCredentialIdsList.forEach((value) => {
|
||||
excludeCredentials.push({
|
||||
id: base64url.parse(value, { loose: true }),
|
||||
type: 'public-key',
|
||||
});
|
||||
});
|
||||
|
||||
return excludeCredentials;
|
||||
};
|
||||
|
||||
const getTransportsAsString = (transportsList: string | string[]) => {
|
||||
if (transportsList === '' || transportsList.constructor !== Array) return '';
|
||||
|
||||
let transportsString = '';
|
||||
|
||||
transportsList.forEach((value) => {
|
||||
transportsString += value + ',';
|
||||
});
|
||||
|
||||
return transportsString.slice(0, -1);
|
||||
};
|
||||
|
||||
return {
|
||||
registerSecurityKey: () => {
|
||||
if (!window.PublicKeyCredential) {
|
||||
errorInput.value = unsupportedBrowserText;
|
||||
registerForm.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64url.parse(challenge, { loose: true }),
|
||||
pubKeyCredParams: getPubKeyCredParams(signatureAlgorithms),
|
||||
rp: {
|
||||
id: rpId,
|
||||
name: rpEntityName,
|
||||
},
|
||||
user: {
|
||||
displayName: username,
|
||||
id: base64url.parse(userId, { loose: true }),
|
||||
name: username,
|
||||
},
|
||||
};
|
||||
|
||||
if (attestationConveyancePreference !== 'not specified')
|
||||
publicKey.attestation = attestationConveyancePreference;
|
||||
|
||||
const authenticatorSelection: AuthenticatorSelectionCriteria = {};
|
||||
let isAuthenticatorSelectionSpecified = false;
|
||||
|
||||
if (authenticatorAttachment !== 'not specified') {
|
||||
authenticatorSelection.authenticatorAttachment = authenticatorAttachment;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (requireResidentKey !== 'not specified') {
|
||||
if (requireResidentKey === 'Yes') authenticatorSelection.requireResidentKey = true;
|
||||
else authenticatorSelection.requireResidentKey = false;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (userVerificationRequirement !== 'not specified') {
|
||||
authenticatorSelection.userVerification = userVerificationRequirement;
|
||||
isAuthenticatorSelectionSpecified = true;
|
||||
}
|
||||
|
||||
if (isAuthenticatorSelectionSpecified)
|
||||
publicKey.authenticatorSelection = authenticatorSelection;
|
||||
|
||||
const excludeCredentials = getExcludeCredentials(excludeCredentialIds);
|
||||
if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials;
|
||||
|
||||
if (parseInt(createTimeout) !== 0) publicKey.timeout = parseInt(createTimeout) * 1000;
|
||||
|
||||
navigator.credentials
|
||||
.create({ publicKey })
|
||||
.then((result) => {
|
||||
if (
|
||||
result instanceof PublicKeyCredential &&
|
||||
result.response instanceof AuthenticatorAttestationResponse
|
||||
) {
|
||||
const { getTransports } = result.response;
|
||||
|
||||
window.result = result;
|
||||
|
||||
const publicKeyCredentialId = result.rawId;
|
||||
|
||||
attestationObjectInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.attestationObject),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
clientDataJSONInput.value = base64url.stringify(
|
||||
new Uint8Array(result.response.clientDataJSON),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
publicKeyCredentialIdInput.value = base64url.stringify(
|
||||
new Uint8Array(publicKeyCredentialId),
|
||||
{ pad: false }
|
||||
);
|
||||
|
||||
if (typeof getTransports === 'function') {
|
||||
const transports = getTransports();
|
||||
|
||||
if (transports) {
|
||||
transportsInput.value = getTransportsAsString(transports);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'Your browser is not able to recognize supported transport media for the authenticator.'
|
||||
);
|
||||
}
|
||||
|
||||
const initLabel = 'WebAuthn Authenticator (Default Label)';
|
||||
let labelResult = window.prompt(
|
||||
"Please input your registered authenticator's label",
|
||||
initLabel
|
||||
);
|
||||
if (labelResult === null) labelResult = initLabel;
|
||||
|
||||
authenticatorLabelInput.value = labelResult;
|
||||
registerForm.submit();
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
error.value = error;
|
||||
registerForm.submit();
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
1
src/global.d.ts
vendored
1
src/global.d.ts
vendored
|
@ -3,5 +3,6 @@ import type { Alpine } from 'alpinejs';
|
|||
declare global {
|
||||
interface Window {
|
||||
Alpine: Alpine;
|
||||
result?: PublicKeyCredential;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ module.exports = {
|
|||
|
||||
provider: {
|
||||
bitbucket: '#0052CC',
|
||||
discord: '#5865F2',
|
||||
facebook: '#1877F2',
|
||||
github: '#181717',
|
||||
gitlab: '#FC6D26',
|
||||
|
@ -27,6 +28,7 @@ module.exports = {
|
|||
oidc: '#F78C40',
|
||||
openshift: '#EE0000',
|
||||
paypal: '#00457C',
|
||||
slack: '#4A154B',
|
||||
stackoverflow: '#F58025',
|
||||
twitter: '#1DA1F2',
|
||||
},
|
||||
|
|
7
theme/keywind/login/assets/providers/discord.ftl
Normal file
7
theme/keywind/login/assets/providers/discord.ftl
Normal file
|
@ -0,0 +1,7 @@
|
|||
<#-- https://discord.com/branding -->
|
||||
<#macro kw name="Discord">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>${name}</title>
|
||||
<path d="M20.3303 4.42852C18.7535 3.70661 17.0888 3.19446 15.3789 2.90516C15.1449 3.32345 14.9332 3.75381 14.7446 4.19445C12.9232 3.91998 11.071 3.91998 9.24961 4.19445C9.06095 3.75386 8.84924 3.3235 8.61535 2.90516C6.90433 3.19691 5.2386 3.71027 3.66019 4.43229C0.526644 9.06843 -0.322811 13.5894 0.101917 18.0462C1.937 19.4021 3.99098 20.4332 6.17458 21.0948C6.66626 20.4335 7.10134 19.732 7.47519 18.9976C6.76511 18.7324 6.07975 18.4052 5.42706 18.0198C5.59884 17.8952 5.76684 17.7669 5.92918 17.6423C7.82837 18.5354 9.90124 18.9985 12 18.9985C14.0987 18.9985 16.1715 18.5354 18.0707 17.6423C18.235 17.7763 18.403 17.9047 18.5729 18.0198C17.9189 18.4058 17.2323 18.7337 16.5209 18.9995C16.8943 19.7335 17.3294 20.4345 17.8216 21.0948C20.007 20.4359 22.0626 19.4052 23.898 18.0481C24.3963 12.8797 23.0467 8.4002 20.3303 4.42852ZM8.01318 15.3053C6.82961 15.3053 5.85179 14.2312 5.85179 12.9099C5.85179 11.5885 6.79563 10.505 8.0094 10.505C9.22318 10.505 10.1934 11.5885 10.1727 12.9099C10.1519 14.2312 9.21941 15.3053 8.01318 15.3053ZM15.9867 15.3053C14.8013 15.3053 13.8272 14.2312 13.8272 12.9099C13.8272 11.5885 14.7711 10.505 15.9867 10.505C17.2024 10.505 18.1651 11.5885 18.1444 12.9099C18.1236 14.2312 17.193 15.3053 15.9867 15.3053Z" fill="#5865F2" />
|
||||
</svg>
|
||||
</#macro>
|
|
@ -1,4 +1,5 @@
|
|||
<#import "./bitbucket.ftl" as bitbucketIcon>
|
||||
<#import "./discord.ftl" as discordIcon>
|
||||
<#import "./facebook.ftl" as facebookIcon>
|
||||
<#import "./github.ftl" as githubIcon>
|
||||
<#import "./gitlab.ftl" as gitlabIcon>
|
||||
|
@ -9,6 +10,7 @@
|
|||
<#import "./oidc.ftl" as oidcIcon>
|
||||
<#import "./openshift.ftl" as openshiftIcon>
|
||||
<#import "./paypal.ftl" as paypalIcon>
|
||||
<#import "./slack.ftl" as slackIcon>
|
||||
<#import "./stackoverflow.ftl" as stackoverflowIcon>
|
||||
<#import "./twitter.ftl" as twitterIcon>
|
||||
|
||||
|
@ -16,6 +18,10 @@
|
|||
<@bitbucketIcon.kw />
|
||||
</#macro>
|
||||
|
||||
<#macro discord>
|
||||
<@discordIcon.kw />
|
||||
</#macro>
|
||||
|
||||
<#macro facebook>
|
||||
<@facebookIcon.kw />
|
||||
</#macro>
|
||||
|
@ -60,6 +66,10 @@
|
|||
<@paypalIcon.kw />
|
||||
</#macro>
|
||||
|
||||
<#macro slack>
|
||||
<@slackIcon.kw />
|
||||
</#macro>
|
||||
|
||||
<#macro stackoverflow>
|
||||
<@stackoverflowIcon.kw />
|
||||
</#macro>
|
||||
|
|
14
theme/keywind/login/assets/providers/slack.ftl
Normal file
14
theme/keywind/login/assets/providers/slack.ftl
Normal file
|
@ -0,0 +1,14 @@
|
|||
<#-- https://slack.com/media-kit -->
|
||||
<#macro kw name="Slack">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>${name}</title>
|
||||
<path d="M5.04235 15.1661C5.04235 16.5537 3.9088 17.6873 2.52117 17.6873C1.13355 17.6873 0 16.5537 0 15.1661C0 13.7785 1.13355 12.645 2.52117 12.645H5.04235V15.1661Z" fill="#E01E5A" />
|
||||
<path d="M6.3127 15.1661C6.3127 13.7785 7.44626 12.645 8.83388 12.645C10.2215 12.645 11.355 13.7785 11.355 15.1661V21.4788C11.355 22.8664 10.2215 24 8.83388 24C7.44626 24 6.3127 22.8664 6.3127 21.4788V15.1661Z" fill="#E01E5A" />
|
||||
<path d="M8.83388 5.04235C7.44626 5.04235 6.3127 3.9088 6.3127 2.52117C6.3127 1.13355 7.44626 0 8.83388 0C10.2215 0 11.355 1.13355 11.355 2.52117V5.04235H8.83388Z" fill="#36C5F0" />
|
||||
<path d="M8.83388 6.3127C10.2215 6.3127 11.355 7.44626 11.355 8.83388C11.355 10.2215 10.2215 11.355 8.83388 11.355H2.52117C1.13355 11.355 0 10.2215 0 8.83388C0 7.44626 1.13355 6.3127 2.52117 6.3127H8.83388Z" fill="#36C5F0" />
|
||||
<path d="M18.9577 8.83388C18.9577 7.44626 20.0912 6.3127 21.4788 6.3127C22.8664 6.3127 24 7.44626 24 8.83388C24 10.2215 22.8664 11.355 21.4788 11.355H18.9577V8.83388Z" fill="#2EB67D" />
|
||||
<path d="M17.6873 8.83388C17.6873 10.2215 16.5537 11.355 15.1661 11.355C13.7785 11.355 12.645 10.2215 12.645 8.83388V2.52117C12.645 1.13355 13.7785 0 15.1661 0C16.5537 0 17.6873 1.13355 17.6873 2.52117V8.83388Z" fill="#2EB67D" />
|
||||
<path d="M15.1661 18.9577C16.5537 18.9577 17.6873 20.0912 17.6873 21.4788C17.6873 22.8664 16.5537 24 15.1661 24C13.7785 24 12.645 22.8664 12.645 21.4788V18.9577H15.1661Z" fill="#ECB22E" />
|
||||
<path d="M15.1661 17.6873C13.7785 17.6873 12.645 16.5537 12.645 15.1661C12.645 13.7785 13.7785 12.645 15.1661 12.645H21.4788C22.8664 12.645 24 13.7785 24 15.1661C24 16.5537 22.8664 17.6873 21.4788 17.6873H15.1661Z" fill="#ECB22E" />
|
||||
</svg>
|
||||
</#macro>
|
|
@ -10,6 +10,9 @@
|
|||
<#case "bitbucket">
|
||||
<#assign colorClass="hover:bg-provider-bitbucket/10">
|
||||
<#break>
|
||||
<#case "discord">
|
||||
<#assign colorClass="hover:bg-provider-discord/10">
|
||||
<#break>
|
||||
<#case "facebook">
|
||||
<#assign colorClass="hover:bg-provider-facebook/10">
|
||||
<#break>
|
||||
|
@ -43,6 +46,9 @@
|
|||
<#case "paypal">
|
||||
<#assign colorClass="hover:bg-provider-paypal/10">
|
||||
<#break>
|
||||
<#case "slack">
|
||||
<#assign colorClass="hover:bg-provider-slack/10">
|
||||
<#break>
|
||||
<#case "stackoverflow">
|
||||
<#assign colorClass="hover:bg-provider-stackoverflow/10">
|
||||
<#break>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<#macro kw linkHref="" linkTitle="" name="">
|
||||
<div class="flex items-center justify-center mb-4 space-x-2">
|
||||
<b>${name}</b>
|
||||
<span class="font-medium">${name}</span>
|
||||
<@link.kw
|
||||
color="primary"
|
||||
href=linkHref
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<#macro kw script="">
|
||||
<title>${msg("loginTitle", (realm.displayName!""))}</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<#if properties.meta?has_content>
|
||||
<#list properties.meta?split(" ") as meta>
|
||||
<meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
|
||||
<meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}">
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<#if properties.favicons?has_content>
|
||||
<#list properties.favicons?split(" ") as favicon>
|
||||
<link href="${url.resourcesPath}/${favicon?split('==')[0]}" rel="${meta?split('==')[1]}">
|
||||
<link href="${url.resourcesPath}/${favicon?split('==')[0]}" rel="${favicon?split('==')[1]}">
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<#if properties.styles?has_content>
|
||||
<#list properties.styles?split(" ") as style>
|
||||
<link href="${url.resourcesPath}/${style}" rel="stylesheet" />
|
||||
<link href="${url.resourcesPath}/${style}" rel="stylesheet">
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
|
|
18
theme/keywind/login/error.ftl
Normal file
18
theme/keywind/login/error.ftl
Normal file
|
@ -0,0 +1,18 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/alert.ftl" as alert>
|
||||
<#import "components/atoms/link.ftl" as link>
|
||||
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<#if section="header">
|
||||
${kcSanitize(msg("errorTitle"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<@alert.kw color="error">${kcSanitize(message.summary)?no_esc}</@alert.kw>
|
||||
<#if !skipLink??>
|
||||
<#if client?? && client.baseUrl?has_content>
|
||||
<@link.kw color="secondary" href=client.baseUrl size="small">
|
||||
${kcSanitize(msg("backToApplication"))?no_esc}
|
||||
</@link.kw>
|
||||
</#if>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -31,7 +31,7 @@
|
|||
<#if mode?? && mode="manual">
|
||||
<li>
|
||||
<p>${msg("loginTotpManualStep2")}</p>
|
||||
<p class="font-bold text-xl">${totp.totpSecretEncoded}</p>
|
||||
<p class="font-medium text-xl">${totp.totpSecretEncoded}</p>
|
||||
</li>
|
||||
<li>
|
||||
<@link.kw color="primary" href=totp.qrUrl>
|
||||
|
|
18
theme/keywind/login/login-page-expired.ftl
Normal file
18
theme/keywind/login/login-page-expired.ftl
Normal file
|
@ -0,0 +1,18 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section="header">
|
||||
${msg("pageExpiredTitle")}
|
||||
<#elseif section="form">
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" component="a" href=url.loginRestartFlowUrl>
|
||||
${msg("doTryAgain")}
|
||||
</@button.kw>
|
||||
<@button.kw color="secondary" component="a" href=url.loginAction>
|
||||
${msg("doContinue")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
39
theme/keywind/login/login-password.ftl
Normal file
39
theme/keywind/login/login-password.ftl
Normal file
|
@ -0,0 +1,39 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/input.ftl" as input>
|
||||
<#import "components/atoms/link.ftl" as link>
|
||||
|
||||
<@layout.registrationLayout displayMessage=!messagesPerField.existsError("password"); section>
|
||||
<#if section="header">
|
||||
${msg("doLogIn")}
|
||||
<#elseif section="form">
|
||||
<@form.kw
|
||||
action=url.loginAction
|
||||
method="post"
|
||||
onsubmit="login.disabled = true; return true;"
|
||||
>
|
||||
<@input.kw
|
||||
autofocus=true
|
||||
invalid=messagesPerField.existsError("password")
|
||||
label=msg("password")
|
||||
message=kcSanitize(messagesPerField.get("password"))?no_esc
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<#if realm.resetPasswordAllowed>
|
||||
<div class="flex items-center justify-between">
|
||||
<@link.kw color="primary" href=url.loginResetCredentialsUrl size="small">
|
||||
${msg("doForgotPassword")}
|
||||
</@link.kw>
|
||||
</div>
|
||||
</#if>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" name="login" type="submit">
|
||||
${msg("doLogIn")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
91
theme/keywind/login/login-recovery-authn-code-config.ftl
Normal file
91
theme/keywind/login/login-recovery-authn-code-config.ftl
Normal file
|
@ -0,0 +1,91 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/alert.ftl" as alert>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/checkbox.ftl" as checkbox>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
|
||||
<@layout.registrationLayout script="dist/recoveryCodes.js"; section>
|
||||
<#if section="header">
|
||||
${msg("recovery-code-config-header")}
|
||||
<#elseif section="form">
|
||||
<div class="space-y-6" x-data="recoveryCodes">
|
||||
<@alert.kw color="warning">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium">${msg("recovery-code-config-warning-title")}</h4>
|
||||
<p>${msg("recovery-code-config-warning-message")}</p>
|
||||
</div>
|
||||
</@alert.kw>
|
||||
<ul class="columns-2 font-mono text-center" x-ref="codeList">
|
||||
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
|
||||
<li>${code[0..3]}-${code[4..7]}-${code[8..]}</li>
|
||||
</#list>
|
||||
</ul>
|
||||
<div class="flex items-stretch space-x-4 mb-4">
|
||||
<@button.kw @click="print" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-print")}
|
||||
</@button.kw>
|
||||
<@button.kw @click="download" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-download")}
|
||||
</@button.kw>
|
||||
<@button.kw @click="copy" color="secondary" size="small" type="button">
|
||||
${msg("recovery-codes-copy")}
|
||||
</@button.kw>
|
||||
</div>
|
||||
<@form.kw action=url.loginAction method="post">
|
||||
<input
|
||||
name="generatedRecoveryAuthnCodes"
|
||||
type="hidden"
|
||||
value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}"
|
||||
/>
|
||||
<input
|
||||
name="generatedAt"
|
||||
type="hidden"
|
||||
value="${recoveryAuthnCodesConfigBean.generatedAt?c}"
|
||||
/>
|
||||
<input
|
||||
name="userLabel"
|
||||
type="hidden"
|
||||
value="${msg('recovery-codes-label-default')}"
|
||||
/>
|
||||
<@checkbox.kw
|
||||
label=msg("recovery-codes-confirmation-message")
|
||||
name="kcRecoveryCodesConfirmationCheck"
|
||||
required="required"
|
||||
x\-ref="confirmationCheck"
|
||||
/>
|
||||
<@buttonGroup.kw>
|
||||
<#if isAppInitiatedAction??>
|
||||
<@button.kw color="primary" type="submit">
|
||||
${msg("recovery-codes-action-complete")}
|
||||
</@button.kw>
|
||||
<@button.kw
|
||||
@click="$refs.confirmationCheck.required = false"
|
||||
color="secondary"
|
||||
name="cancel-aia"
|
||||
type="submit"
|
||||
value="true"
|
||||
>
|
||||
${msg("recovery-codes-action-cancel")}
|
||||
</@button.kw>
|
||||
<#else>
|
||||
<@button.kw color="primary" type="submit">
|
||||
${msg("recovery-codes-action-complete")}
|
||||
</@button.kw>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('recoveryCodes', {
|
||||
downloadFileDate: '${msg("recovery-codes-download-file-date")}',
|
||||
downloadFileDescription: '${msg("recovery-codes-download-file-description")}',
|
||||
downloadFileHeader: '${msg("recovery-codes-download-file-header")}',
|
||||
downloadFileName: 'kc-download-recovery-codes',
|
||||
})
|
||||
})
|
||||
</script>
|
26
theme/keywind/login/login-recovery-authn-code-input.ftl
Normal file
26
theme/keywind/login/login-recovery-authn-code-input.ftl
Normal file
|
@ -0,0 +1,26 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/input.ftl" as input>
|
||||
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section="header">
|
||||
${msg("auth-recovery-code-header")}
|
||||
<#elseif section="form">
|
||||
<@form.kw action=url.loginAction method="post">
|
||||
<@input.kw
|
||||
autocomplete="off"
|
||||
autofocus=true
|
||||
label=msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c)
|
||||
name="recoveryCodeInput"
|
||||
type="text"
|
||||
/>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" name="login" type="submit">
|
||||
${msg("doLogIn")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
71
theme/keywind/login/login-username.ftl
Normal file
71
theme/keywind/login/login-username.ftl
Normal file
|
@ -0,0 +1,71 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/checkbox.ftl" as checkbox>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/input.ftl" as input>
|
||||
<#import "components/atoms/link.ftl" as link>
|
||||
<#import "components/molecules/identity-provider.ftl" as identityProvider>
|
||||
<#import "features/labels/username.ftl" as usernameLabel>
|
||||
|
||||
<#assign usernameLabel><@usernameLabel.kw /></#assign>
|
||||
|
||||
<@layout.registrationLayout
|
||||
displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??
|
||||
displayMessage=!messagesPerField.existsError("username")
|
||||
;
|
||||
section
|
||||
>
|
||||
<#if section="header">
|
||||
${msg("loginAccountTitle")}
|
||||
<#elseif section="form">
|
||||
<#if realm.password>
|
||||
<@form.kw
|
||||
action=url.loginAction
|
||||
method="post"
|
||||
onsubmit="login.disabled = true; return true;"
|
||||
>
|
||||
<#if !usernameHidden??>
|
||||
<@input.kw
|
||||
autocomplete=realm.loginWithEmailAllowed?string("email", "username")
|
||||
autofocus=true
|
||||
disabled=usernameEditDisabled??
|
||||
invalid=messagesPerField.existsError("username")
|
||||
label=usernameLabel
|
||||
message=kcSanitize(messagesPerField.get("username"))?no_esc
|
||||
name="username"
|
||||
type="text"
|
||||
value=(login.username)!''
|
||||
/>
|
||||
</#if>
|
||||
<#if realm.rememberMe && !usernameHidden??>
|
||||
<div class="flex items-center justify-between">
|
||||
<@checkbox.kw
|
||||
checked=login.rememberMe??
|
||||
label=msg("rememberMe")
|
||||
name="rememberMe"
|
||||
/>
|
||||
</div>
|
||||
</#if>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" name="login" type="submit">
|
||||
${msg("doLogIn")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
<#elseif section="info">
|
||||
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
|
||||
<div class="text-center">
|
||||
${msg("noAccount")}
|
||||
<@link.kw color="primary" href=url.registrationUrl>
|
||||
${msg("doRegister")}
|
||||
</@link.kw>
|
||||
</div>
|
||||
</#if>
|
||||
<#elseif section="socialProviders">
|
||||
<#if realm.password && social.providers??>
|
||||
<@identityProvider.kw providers=social.providers />
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -70,9 +70,6 @@
|
|||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
<#if realm.password && social.providers??>
|
||||
<@identityProvider.kw providers=social.providers />
|
||||
</#if>
|
||||
<#elseif section="info">
|
||||
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
|
||||
<div class="text-center">
|
||||
|
@ -82,5 +79,9 @@
|
|||
</@link.kw>
|
||||
</div>
|
||||
</#if>
|
||||
<#elseif section="socialProviders">
|
||||
<#if realm.password && social.providers??>
|
||||
<@identityProvider.kw providers=social.providers />
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
|
1
theme/keywind/login/resources/dist/assets/index-a7b84447.js
vendored
Normal file
1
theme/keywind/login/resources/dist/assets/index-a7b84447.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
var s={};Object.defineProperty(s,"__esModule",{value:!0});function v(e,r,a){var l;if(a===void 0&&(a={}),!r.codes){r.codes={};for(var n=0;n<r.chars.length;++n)r.codes[r.chars[n]]=n}if(!a.loose&&e.length*r.bits&7)throw new SyntaxError("Invalid padding");for(var b=e.length;e[b-1]==="=";)if(--b,!a.loose&&!((e.length-b)*r.bits&7))throw new SyntaxError("Invalid padding");for(var c=new((l=a.out)!=null?l:Uint8Array)(b*r.bits/8|0),t=0,i=0,u=0,f=0;f<b;++f){var h=r.codes[e[f]];if(h===void 0)throw new SyntaxError("Invalid character "+e[f]);i=i<<r.bits|h,t+=r.bits,t>=8&&(t-=8,c[u++]=255&i>>t)}if(t>=r.bits||255&i<<8-t)throw new SyntaxError("Unexpected end of data");return c}function o(e,r,a){a===void 0&&(a={});for(var l=a,n=l.pad,b=n===void 0?!0:n,c=(1<<r.bits)-1,t="",i=0,u=0,f=0;f<e.length;++f)for(u=u<<8|255&e[f],i+=8;i>r.bits;)i-=r.bits,t+=r.chars[c&u>>i];if(i&&(t+=r.chars[c&u<<r.bits-i]),b)for(;t.length*r.bits&7;)t+="=";return t}var p={chars:"0123456789ABCDEF",bits:4},y={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",bits:5},d={chars:"0123456789ABCDEFGHIJKLMNOPQRSTUV",bits:5},w={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",bits:6},x={chars:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",bits:6},E={parse:function(r,a){return v(r.toUpperCase(),p,a)},stringify:function(r,a){return o(r,p,a)}},$={parse:function(r,a){return a===void 0&&(a={}),v(a.loose?r.toUpperCase().replace(/0/g,"O").replace(/1/g,"L").replace(/8/g,"B"):r,y,a)},stringify:function(r,a){return o(r,y,a)}},U={parse:function(r,a){return v(r,d,a)},stringify:function(r,a){return o(r,d,a)}},S={parse:function(r,a){return v(r,w,a)},stringify:function(r,a){return o(r,w,a)}},g={parse:function(r,a){return v(r,x,a)},stringify:function(r,a){return o(r,x,a)}},C={parse:v,stringify:o};s.base16=E;s.base32=$;s.base32hex=U;s.base64=S;s.base64url=g;s.codec=C;s.base16;s.base32;s.base32hex;s.base64;const I=s.base64url;s.codec;export{I as b};
|
5
theme/keywind/login/resources/dist/assets/module.esm-62c37d0d.js
vendored
Normal file
5
theme/keywind/login/resources/dist/assets/module.esm-62c37d0d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
theme/keywind/login/resources/dist/index.css
vendored
2
theme/keywind/login/resources/dist/index.css
vendored
File diff suppressed because one or more lines are too long
6
theme/keywind/login/resources/dist/index.js
vendored
6
theme/keywind/login/resources/dist/index.js
vendored
File diff suppressed because one or more lines are too long
8
theme/keywind/login/resources/dist/recoveryCodes.js
vendored
Normal file
8
theme/keywind/login/resources/dist/recoveryCodes.js
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import{m as p}from"./assets/module.esm-62c37d0d.js";document.addEventListener("alpine:init",()=>{p.data("recoveryCodes",function(){const{codeList:o}=this.$refs,{downloadFileDate:n,downloadFileDescription:i,downloadFileHeader:d,downloadFileName:s}=this.$store.recoveryCodes,a=new Date().toLocaleString(navigator.language),r=o.getElementsByTagName("li"),l=Array.from(r).map(t=>t.innerText).join(`
|
||||
`);return{copy:()=>navigator.clipboard.writeText(l),download:()=>{const t=document.createElement("a"),c=`${d}
|
||||
|
||||
${l}
|
||||
|
||||
${i}
|
||||
|
||||
${n} ${a}`;t.setAttribute("href","data:text/plain;charset=utf-8,"+encodeURIComponent(c)),t.setAttribute("download",`${s}.txt`),t.click()},print:()=>{const t=o.innerHTML,m=`<html><style>div { font-family: monospace; list-style-type: none }</style><body><title>${s}</title><p>${d}</p><div>${t}</div><p>${i}</p><p>${n} ${a}</p></body></html>`,e=window.open();e&&(e.document.write(m),e.print(),e.close())}}})});
|
1
theme/keywind/login/resources/dist/webAuthnAuthenticate.js
vendored
Normal file
1
theme/keywind/login/resources/dist/webAuthnAuthenticate.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
import{m as g}from"./assets/module.esm-62c37d0d.js";import{b as n}from"./assets/index-a7b84447.js";document.addEventListener("alpine:init",()=>{g.data("webAuthnAuthenticate",function(){const{authenticatorDataInput:l,authnSelectForm:r,clientDataJSONInput:p,credentialIdInput:f,errorInput:s,signatureInput:d,userHandleInput:h,webAuthnForm:a}=this.$refs,{challenge:m,createTimeout:o,isUserIdentified:A,rpId:w,unsupportedBrowserText:y,userVerification:u}=this.$store.webAuthnAuthenticate,c=i=>{if(!window.PublicKeyCredential){s.value=y,a.submit();return}const t={challenge:n.parse(m,{loose:!0}),rpId:w};i.length&&(t.allowCredentials=i),parseInt(o)!==0&&(t.timeout=parseInt(o)*1e3),u!=="not specified"&&(t.userVerification=u),navigator.credentials.get({publicKey:t}).then(e=>{e instanceof PublicKeyCredential&&e.response instanceof AuthenticatorAssertionResponse&&(window.result=e,l.value=n.stringify(new Uint8Array(e.response.authenticatorData),{pad:!1}),p.value=n.stringify(new Uint8Array(e.response.clientDataJSON),{pad:!1}),d.value=n.stringify(new Uint8Array(e.response.signature),{pad:!1}),f.value=e.id,e.response.userHandle&&(h.value=n.stringify(new Uint8Array(e.response.userHandle),{pad:!1})),a.submit())}).catch(e=>{s.value=e,a.submit()})},b=()=>{const i=[];if(r){const t=Array.from(r.elements);t.length&&t.forEach(e=>{e instanceof HTMLInputElement&&i.push({id:n.parse(e.value,{loose:!0}),type:"public-key"})})}c(i)};return{webAuthnAuthenticate:()=>{if(!A){c([]);return}b()}}})});
|
1
theme/keywind/login/resources/dist/webAuthnRegister.js
vendored
Normal file
1
theme/keywind/login/resources/dist/webAuthnRegister.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
import{m as T}from"./assets/module.esm-62c37d0d.js";import{b as a}from"./assets/index-a7b84447.js";document.addEventListener("alpine:init",()=>{T.data("webAuthnRegister",function(){const{attestationObjectInput:m,authenticatorLabelInput:g,clientDataJSONInput:A,errorInput:I,publicKeyCredentialIdInput:w,registerForm:s,transportsInput:C}=this.$refs,{attestationConveyancePreference:u,authenticatorAttachment:c,challenge:K,createTimeout:l,excludeCredentialIds:v,requireResidentKey:p,rpEntityName:S,rpId:P,signatureAlgorithms:R,unsupportedBrowserText:x,userId:E,userVerificationRequirement:d,username:f}=this.$store.webAuthnRegister,L=t=>{const e=[];return t===""?(e.push({alg:-7,type:"public-key"}),e):(t.split(",").forEach(r=>{e.push({alg:parseInt(r),type:"public-key"})}),e)},q=t=>{const e=[];return t===""||t.split(",").forEach(r=>{e.push({id:a.parse(r,{loose:!0}),type:"public-key"})}),e},N=t=>{if(t===""||t.constructor!==Array)return"";let e="";return t.forEach(i=>{e+=i+","}),e.slice(0,-1)};return{registerSecurityKey:()=>{if(!window.PublicKeyCredential){I.value=x,s.submit();return}const t={challenge:a.parse(K,{loose:!0}),pubKeyCredParams:L(R),rp:{id:P,name:S},user:{displayName:f,id:a.parse(E,{loose:!0}),name:f}};u!=="not specified"&&(t.attestation=u);const e={};let i=!1;c!=="not specified"&&(e.authenticatorAttachment=c,i=!0),p!=="not specified"&&(p==="Yes"?e.requireResidentKey=!0:e.requireResidentKey=!1,i=!0),d!=="not specified"&&(e.userVerification=d,i=!0),i&&(t.authenticatorSelection=e);const r=q(v);r.length>0&&(t.excludeCredentials=r),parseInt(l)!==0&&(t.timeout=parseInt(l)*1e3),navigator.credentials.create({publicKey:t}).then(n=>{if(n instanceof PublicKeyCredential&&n.response instanceof AuthenticatorAttestationResponse){const{getTransports:h}=n.response;window.result=n;const O=n.rawId;if(m.value=a.stringify(new Uint8Array(n.response.attestationObject),{pad:!1}),A.value=a.stringify(new Uint8Array(n.response.clientDataJSON),{pad:!1}),w.value=a.stringify(new Uint8Array(O),{pad:!1}),typeof h=="function"){const b=h();b&&(C.value=N(b))}else console.log("Your browser is not able to recognize supported transport media for the authenticator.");const y="WebAuthn Authenticator (Default Label)";let o=window.prompt("Please input your registered authenticator's label",y);o===null&&(o=y),g.value=o,s.submit()}}).catch(function(n){n.value=n,s.submit()})}}})});
|
28
theme/keywind/login/select-authenticator.ftl
Normal file
28
theme/keywind/login/select-authenticator.ftl
Normal file
|
@ -0,0 +1,28 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
<#import "components/atoms/link.ftl" as link>
|
||||
|
||||
<@layout.registrationLayout displayInfo=false; section>
|
||||
<#if section="header">
|
||||
${msg("loginChooseAuthenticator")}
|
||||
<#elseif section="form">
|
||||
<div x-data>
|
||||
<@form.kw action=url.loginAction method="post" x\-ref="selectCredentialForm">
|
||||
<input name="authenticationExecution" type="hidden" x-ref="authExecInput" />
|
||||
<#list auth.authenticationSelections as authenticationSelection>
|
||||
<div>
|
||||
<@link.kw
|
||||
@click="$refs.authExecInput.value = '${authenticationSelection.authExecId}'; $refs.selectCredentialForm.submit()"
|
||||
color="primary"
|
||||
component="button"
|
||||
type="button"
|
||||
>
|
||||
${msg("${authenticationSelection.displayName}")}
|
||||
</@link.kw>
|
||||
<div class="text-sm">${msg("${authenticationSelection.helpText}")}</div>
|
||||
</div>
|
||||
</#list>
|
||||
</@form.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -56,6 +56,7 @@
|
|||
</@button.kw>
|
||||
</form>
|
||||
</#if>
|
||||
<#nested "socialProviders">
|
||||
</#assign>
|
||||
|
||||
<#assign cardFooter>
|
||||
|
|
22
theme/keywind/login/terms.ftl
Normal file
22
theme/keywind/login/terms.ftl
Normal file
|
@ -0,0 +1,22 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
<#import "components/atoms/form.ftl" as form>
|
||||
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<#if section="header">
|
||||
${msg("termsTitle")}
|
||||
<#elseif section="form">
|
||||
${kcSanitize(msg("termsText"))?no_esc}
|
||||
<@form.kw action=url.loginAction method="post">
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw color="primary" name="accept" type="submit">
|
||||
${msg("doAccept")}
|
||||
</@button.kw>
|
||||
<@button.kw color="secondary" name="cancel" type="submit">
|
||||
${msg("doDecline")}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</@form.kw>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
71
theme/keywind/login/webauthn-authenticate.ftl
Normal file
71
theme/keywind/login/webauthn-authenticate.ftl
Normal file
|
@ -0,0 +1,71 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout script="dist/webAuthnAuthenticate.js"; section>
|
||||
<#if section="title">
|
||||
title
|
||||
<#elseif section="header">
|
||||
${kcSanitize(msg("webauthn-login-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data="webAuthnAuthenticate">
|
||||
<form action="${url.loginAction}" method="post" x-ref="webAuthnForm">
|
||||
<input name="authenticatorData" type="hidden" x-ref="authenticatorDataInput" />
|
||||
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
|
||||
<input name="credentialId" type="hidden" x-ref="credentialIdInput" />
|
||||
<input name="error" type="hidden" x-ref="errorInput" />
|
||||
<input name="signature" type="hidden" x-ref="signatureInput" />
|
||||
<input name="userHandle" type="hidden" x-ref="userHandleInput" />
|
||||
</form>
|
||||
<#if authenticators??>
|
||||
<form x-ref="authnSelectForm">
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<input value="${authenticator.credentialId}" type="hidden" />
|
||||
</#list>
|
||||
</form>
|
||||
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
|
||||
<#if authenticators.authenticators?size gt 1>
|
||||
<p>${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
|
||||
</#if>
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<div>
|
||||
<div class="font-medium">${kcSanitize(msg("${authenticator.label}"))?no_esc}</div>
|
||||
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
|
||||
<div>
|
||||
<#list authenticator.transports.displayNameProperties as nameProperty>
|
||||
<span>${kcSanitize(msg("${nameProperty!}"))?no_esc}</span>
|
||||
<#if nameProperty?has_next>
|
||||
<span>, </span>
|
||||
</#if>
|
||||
</#list>
|
||||
</div>
|
||||
</#if>
|
||||
<div class="text-sm">
|
||||
<span>${kcSanitize(msg("webauthn-createdAt-label"))?no_esc}</span>
|
||||
<span>${kcSanitize(authenticator.createdAt)?no_esc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</#list>
|
||||
</#if>
|
||||
</#if>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw @click="webAuthnAuthenticate" color="primary" type="button">
|
||||
${kcSanitize(msg("webauthn-doAuthenticate"))}
|
||||
</@button.kw>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('webAuthnAuthenticate', {
|
||||
challenge: '${challenge}',
|
||||
createTimeout: '${createTimeout}',
|
||||
isUserIdentified: '${isUserIdentified}',
|
||||
rpId: '${rpId}',
|
||||
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
|
||||
userVerification: '${userVerification}',
|
||||
})
|
||||
})
|
||||
</script>
|
34
theme/keywind/login/webauthn-error.ftl
Normal file
34
theme/keywind/login/webauthn-error.ftl
Normal file
|
@ -0,0 +1,34 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout displayMessage=true; section>
|
||||
<#if section="header">
|
||||
${kcSanitize(msg("webauthn-error-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data>
|
||||
<form action="${url.loginAction}" method="post" x-ref="errorCredentialForm">
|
||||
<input name="authenticationExecution" type="hidden" x-ref="executionValueInput" />
|
||||
<input name="isSetRetry" type="hidden" x-ref="isSetRetryInput" />
|
||||
</form>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw
|
||||
@click="$refs.executionValueInput.value = '${execution}'; $refs.isSetRetryInput.value = 'retry'; $refs.errorCredentialForm.submit()"
|
||||
color="primary"
|
||||
name="try-again"
|
||||
tabindex="4"
|
||||
type="button"
|
||||
>
|
||||
${kcSanitize(msg("doTryAgain"))?no_esc}
|
||||
</@button.kw>
|
||||
<#if isAppInitiatedAction??>
|
||||
<form action="${url.loginAction}" method="post">
|
||||
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
|
||||
${msg("doCancel")}
|
||||
</@button.kw>
|
||||
</form>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
54
theme/keywind/login/webauthn-register.ftl
Normal file
54
theme/keywind/login/webauthn-register.ftl
Normal file
|
@ -0,0 +1,54 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "components/atoms/button.ftl" as button>
|
||||
<#import "components/atoms/button-group.ftl" as buttonGroup>
|
||||
|
||||
<@layout.registrationLayout script="dist/webAuthnRegister.js"; section>
|
||||
<#if section="title">
|
||||
title
|
||||
<#elseif section="header">
|
||||
${kcSanitize(msg("webauthn-registration-title"))?no_esc}
|
||||
<#elseif section="form">
|
||||
<div x-data="webAuthnRegister">
|
||||
<form action="${url.loginAction}" method="post" x-ref="registerForm">
|
||||
<input name="attestationObject" type="hidden" x-ref="attestationObjectInput" />
|
||||
<input name="authenticatorLabel" type="hidden" x-ref="authenticatorLabelInput" />
|
||||
<input name="clientDataJSON" type="hidden" x-ref="clientDataJSONInput" />
|
||||
<input name="error" type="hidden" x-ref="errorInput" />
|
||||
<input name="publicKeyCredentialId" type="hidden" x-ref="publicKeyCredentialIdInput" />
|
||||
<input name="transports" type="hidden" x-ref="transportsInput" />
|
||||
</form>
|
||||
<@buttonGroup.kw>
|
||||
<@button.kw @click="registerSecurityKey" color="primary" type="submit">
|
||||
${msg("doRegister")}
|
||||
</@button.kw>
|
||||
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
|
||||
<form action="${url.loginAction}" method="post">
|
||||
<@button.kw color="secondary" name="cancel-aia" type="submit" value="true">
|
||||
${msg("doCancel")}
|
||||
</@button.kw>
|
||||
</form>
|
||||
</#if>
|
||||
</@buttonGroup.kw>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('webAuthnRegister', {
|
||||
attestationConveyancePreference: '${attestationConveyancePreference}',
|
||||
authenticatorAttachment: '${authenticatorAttachment}',
|
||||
challenge: '${challenge}',
|
||||
createTimeout: '${createTimeout}',
|
||||
excludeCredentialIds: '${excludeCredentialIds}',
|
||||
requireResidentKey: '${requireResidentKey}',
|
||||
rpEntityName: '${rpEntityName}',
|
||||
rpId: '${rpId}',
|
||||
signatureAlgorithms: '${signatureAlgorithms}',
|
||||
unsupportedBrowserText: '${msg("webauthn-unsupported-browser-text")?no_esc}',
|
||||
userId: '${userid}',
|
||||
userVerificationRequirement: '${userVerificationRequirement}',
|
||||
username: '${username}',
|
||||
})
|
||||
})
|
||||
</script>
|
|
@ -3,7 +3,12 @@ import { defineConfig } from 'vite';
|
|||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: 'src/index.ts',
|
||||
input: [
|
||||
'src/index.ts',
|
||||
'src/data/recoveryCodes.ts',
|
||||
'src/data/webAuthnAuthenticate.ts',
|
||||
'src/data/webAuthnRegister.ts',
|
||||
],
|
||||
output: {
|
||||
assetFileNames: '[name][extname]',
|
||||
dir: 'theme/keywind/login/resources/dist',
|
||||
|
|
Loading…
Reference in a new issue