mirror of https://github.com/keeweb/keeweb.git
feat: add passphrases, uuid4
This commit is contained in:
parent
c1fda05c77
commit
fab0b1ef79
|
@ -1,6 +1,7 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { phonetic } from 'util/generators/phonetic';
|
||||
import { shuffle } from 'util/fn';
|
||||
import {} from 'crypto';
|
||||
|
||||
const CharRanges = {
|
||||
upper: 'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
|
@ -8,9 +9,9 @@ const CharRanges = {
|
|||
digits: '123456789',
|
||||
special: '!@#$%^&*_+-=,./?;:`"~\'\\',
|
||||
brackets: '(){}[]<>',
|
||||
high:
|
||||
'¡¢£¤¥¦§©ª«¬®¯°±²³´µ¶¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ',
|
||||
ambiguous: 'O0oIl'
|
||||
high: '¡¢£¤¥¦§©ª«¬®¯°±²³´µ¶¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ',
|
||||
ambiguous: 'O0oIl',
|
||||
spaces: ' '
|
||||
};
|
||||
|
||||
const DefaultCharRangesByPattern = {
|
||||
|
@ -20,30 +21,80 @@ const DefaultCharRangesByPattern = {
|
|||
'*': CharRanges.special,
|
||||
'[': CharRanges.brackets,
|
||||
'Ä': CharRanges.high,
|
||||
'0': CharRanges.ambiguous
|
||||
'0': CharRanges.ambiguous,
|
||||
'S': CharRanges.spaces
|
||||
};
|
||||
|
||||
const PasswordGenerator = {
|
||||
generate(opts) {
|
||||
const bUseSpaces = opts.spaces;
|
||||
const bUpperInc = opts.include && opts.include.length && opts.upper; // allows for using uppercase checkboxes for presets
|
||||
const bLowerInc = opts.include && opts.include.length && opts.lower; // allows for using lowercase checkboxes for presets
|
||||
|
||||
if (!opts || typeof opts.length !== 'number' || opts.length < 0) {
|
||||
return '';
|
||||
}
|
||||
if (opts.name === 'Pronounceable') {
|
||||
|
||||
if (opts.name.toLowerCase() === 'uuid') {
|
||||
return this.generateUUIDv4(opts);
|
||||
}
|
||||
|
||||
if (opts.name.toLowerCase() === 'passphrase') {
|
||||
return this.generatePassphrase(opts);
|
||||
}
|
||||
|
||||
if (opts.name.toLowerCase() === 'pronounceable') {
|
||||
return this.generatePronounceable(opts);
|
||||
}
|
||||
const ranges = Object.keys(CharRanges)
|
||||
.filter((r) => opts[r])
|
||||
.map((r) => CharRanges[r]);
|
||||
|
||||
// used if action specifies custom opts.include
|
||||
// delete all CharRanges
|
||||
// used for presets such as hashes since we need to keep the list of opts.include characters
|
||||
// but also allows for selecting upper and lowercase checkboxes
|
||||
if (bUpperInc || bLowerInc) {
|
||||
if (bUpperInc && opts.include) {
|
||||
opts.include = opts.include.toUpperCase();
|
||||
} else if (bLowerInc && opts.include) {
|
||||
opts.include = opts.include.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// returns string of characters to choose from
|
||||
let ranges = Object.keys(CharRanges)
|
||||
.filter((r) => {
|
||||
return opts[r];
|
||||
})
|
||||
.map((r) => {
|
||||
return CharRanges[r];
|
||||
});
|
||||
|
||||
// delete ranges if opts.include specified, use the include characters instead
|
||||
if (bUpperInc || bLowerInc) {
|
||||
ranges = [];
|
||||
}
|
||||
|
||||
Object.keys(ranges).forEach((r) => {
|
||||
const itemKey = r;
|
||||
const item = ranges[itemKey];
|
||||
|
||||
if (item === String.fromCharCode(32)) {
|
||||
ranges.pop(r);
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.include && opts.include.length) {
|
||||
ranges.push(opts.include);
|
||||
}
|
||||
|
||||
if (!ranges.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rangesByPatternChar = {
|
||||
...DefaultCharRangesByPattern,
|
||||
'I': opts.include || ''
|
||||
};
|
||||
|
||||
const pattern = opts.pattern || 'X';
|
||||
|
||||
let countDefaultChars = 0;
|
||||
|
@ -56,6 +107,7 @@ const PasswordGenerator = {
|
|||
|
||||
const rangeIxRandomBytes = kdbxweb.CryptoEngine.random(countDefaultChars);
|
||||
const rangeCharRandomBytes = kdbxweb.CryptoEngine.random(countDefaultChars);
|
||||
const randomBytes = kdbxweb.CryptoEngine.random(opts.length);
|
||||
const defaultRangeGeneratedChars = [];
|
||||
for (let i = 0; i < countDefaultChars; i++) {
|
||||
const rangeIx = i < ranges.length ? i : rangeIxRandomBytes[i] % ranges.length;
|
||||
|
@ -65,19 +117,44 @@ const PasswordGenerator = {
|
|||
}
|
||||
shuffle(defaultRangeGeneratedChars);
|
||||
|
||||
const randomBytes = kdbxweb.CryptoEngine.random(opts.length);
|
||||
const chars = [];
|
||||
for (let i = 0; i < opts.length; i++) {
|
||||
const pwdLenReq = opts.length;
|
||||
let pwdLenNow = 1;
|
||||
let pwdCharLast = null;
|
||||
let pwdMarginSp = Math.round(pwdLenReq * Math.random());
|
||||
if (pwdMarginSp === pwdLenReq) {
|
||||
pwdMarginSp -= 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < pwdLenReq; i++) {
|
||||
const rand = Math.round(Math.random() * 1000) + randomBytes[i];
|
||||
const patternChar = pattern[i % pattern.length];
|
||||
if (patternChar === 'X') {
|
||||
chars.push(defaultRangeGeneratedChars.pop());
|
||||
const bAlreadyHasSpace =
|
||||
chars.find((element) => element === String.fromCharCode(32)) !== undefined;
|
||||
let char = defaultRangeGeneratedChars.pop();
|
||||
if (
|
||||
bUseSpaces === true &&
|
||||
pwdLenNow > 1 &&
|
||||
pwdLenNow !== pwdLenReq &&
|
||||
pwdCharLast !== String.fromCharCode(32)
|
||||
) {
|
||||
if (Math.random() < 0.1 || (pwdLenNow >= pwdMarginSp && !bAlreadyHasSpace)) {
|
||||
char = String.fromCharCode(32);
|
||||
}
|
||||
chars.push(char);
|
||||
} else {
|
||||
chars.push(char);
|
||||
}
|
||||
pwdLenNow += 1;
|
||||
pwdCharLast = char;
|
||||
} else {
|
||||
const range = rangesByPatternChar[patternChar];
|
||||
const char = range ? range[rand % range.length] : patternChar;
|
||||
chars.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
},
|
||||
|
||||
|
@ -100,7 +177,80 @@ const PasswordGenerator = {
|
|||
}
|
||||
result += ch;
|
||||
}
|
||||
return result.substr(0, opts.length);
|
||||
|
||||
// return result.substr(0, opts.length);
|
||||
return result.slice(0, opts.length);
|
||||
},
|
||||
|
||||
// UUID v4 based on pure randomness, whereas v5 is derived from a string.
|
||||
// ASCII string with 8 hexadecimal digits followed by a hyphen
|
||||
// then three groups of 4 hexadecimal digits each followed by a hyphen
|
||||
// then 12 hexadecimal digits.
|
||||
// possibilities: 2^128
|
||||
|
||||
generateUUIDv4(opts) {
|
||||
let p = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||
(+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16)
|
||||
);
|
||||
|
||||
if (opts.lower && !opts.upper) {
|
||||
p = p.toLowerCase(); // lowercase
|
||||
} else if (opts.upper && !opts.lower) {
|
||||
p = p.toUpperCase(); // uppercase
|
||||
} else if (opts.upper && opts.lower) {
|
||||
p = p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(); // Mixed
|
||||
}
|
||||
|
||||
return p;
|
||||
},
|
||||
|
||||
generatePassphrase(opts) {
|
||||
const pass = phonetic.generate({
|
||||
length: opts.length
|
||||
});
|
||||
const passphraseList = require('../../wordlist/default.json');
|
||||
shuffle(passphraseList);
|
||||
|
||||
let i;
|
||||
const spacer = '';
|
||||
const chars = [];
|
||||
for (i = 0; i < pass.length; i++) {
|
||||
const id = Math.floor(Math.random() * passphraseList.length);
|
||||
let p = passphraseList[id];
|
||||
passphraseList.splice(id, 1);
|
||||
|
||||
if (opts.lower && !opts.upper) {
|
||||
p = p.toLowerCase(); // word1 word2
|
||||
} else if (opts.upper && !opts.lower) {
|
||||
p = p.toUpperCase(); // WORD1 WORD2
|
||||
} else if (opts.upper && opts.lower) {
|
||||
p = p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(); // Word1 Word2
|
||||
}
|
||||
|
||||
if (opts.digits) {
|
||||
p = p + Math.floor(Math.random() * 10);
|
||||
}
|
||||
|
||||
if (i < pass.length - 1) {
|
||||
if (opts.special) {
|
||||
p = p + ',';
|
||||
} else if (opts.high) {
|
||||
if (opts.spaces) {
|
||||
p = p + ' -';
|
||||
} else {
|
||||
p = p + '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (i < pass.length - 1 && opts.spaces) {
|
||||
p = p + ' ';
|
||||
}
|
||||
|
||||
chars.push(p);
|
||||
}
|
||||
|
||||
return chars.join(spacer);
|
||||
},
|
||||
|
||||
deriveOpts(password) {
|
||||
|
|
|
@ -60,17 +60,24 @@ class GeneratorPresetsView extends View {
|
|||
const rangeOverride = {
|
||||
high: '¡¢£¤¥¦§©ª«¬®¯°±¹²´µ¶»¼÷¿ÀÖîü...'
|
||||
};
|
||||
return ['Upper', 'Lower', 'Digits', 'Special', 'Brackets', 'High', 'Ambiguous'].map(
|
||||
(name) => {
|
||||
const nameLower = name.toLowerCase();
|
||||
return {
|
||||
name: nameLower,
|
||||
title: Locale['genPs' + name],
|
||||
enabled: sel[nameLower],
|
||||
sample: rangeOverride[nameLower] || CharRanges[nameLower]
|
||||
};
|
||||
}
|
||||
);
|
||||
return [
|
||||
'Upper',
|
||||
'Lower',
|
||||
'Digits',
|
||||
'Special',
|
||||
'Brackets',
|
||||
'High',
|
||||
'Ambiguous',
|
||||
'Spaces'
|
||||
].map((name) => {
|
||||
const nameLower = name.toLowerCase();
|
||||
return {
|
||||
name: nameLower,
|
||||
title: Locale['genPs' + name],
|
||||
enabled: sel[nameLower],
|
||||
sample: rangeOverride[nameLower] || CharRanges[nameLower]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getPreset(name) {
|
||||
|
@ -109,6 +116,7 @@ class GeneratorPresetsView extends View {
|
|||
special: selected.special,
|
||||
brackets: selected.brackets,
|
||||
ambiguous: selected.ambiguous,
|
||||
spaces: selected.spaces,
|
||||
include: selected.include
|
||||
};
|
||||
GeneratorPresets.add(preset);
|
||||
|
|
|
@ -11,8 +11,8 @@ import template from 'templates/generator.hbs';
|
|||
|
||||
class GeneratorView extends View {
|
||||
parent = 'body';
|
||||
|
||||
template = template;
|
||||
spacesLenMin = 5;
|
||||
|
||||
events = {
|
||||
'click': 'click',
|
||||
|
@ -27,32 +27,8 @@ class GeneratorView extends View {
|
|||
};
|
||||
|
||||
valuesMap = [
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
22,
|
||||
24,
|
||||
26,
|
||||
28,
|
||||
30,
|
||||
32,
|
||||
48,
|
||||
64
|
||||
3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 24, 26, 28, 30, 32, 48,
|
||||
64, 128
|
||||
];
|
||||
|
||||
presets = null;
|
||||
|
@ -80,8 +56,10 @@ class GeneratorView extends View {
|
|||
showToggleButton: this.model.copy,
|
||||
opt: this.gen,
|
||||
hide: this.hide,
|
||||
spaces: false,
|
||||
presets: this.presets,
|
||||
preset: this.preset,
|
||||
presetLC: this.preset.toLowerCase(),
|
||||
showTemplateEditor: !this.model.noTemplateEditor
|
||||
});
|
||||
this.resultEl = this.$el.find('.gen__result');
|
||||
|
@ -103,6 +81,7 @@ class GeneratorView extends View {
|
|||
const defaultPreset = this.presets.filter((p) => p.default)[0] || this.presets[0];
|
||||
this.preset = defaultPreset.name;
|
||||
}
|
||||
this.presetLC = this.preset.toLowerCase();
|
||||
this.presets.forEach((pr) => {
|
||||
pr.pseudoLength = this.lengthToPseudoValue(pr.length);
|
||||
});
|
||||
|
@ -120,8 +99,21 @@ class GeneratorView extends View {
|
|||
showPassword() {
|
||||
if (this.hide && !this.model.copy) {
|
||||
this.resultEl.text(PasswordPresenter.present(this.password.length));
|
||||
// this.resultEl.prop('value', PasswordPresenter.present(this.password.length));
|
||||
} else {
|
||||
this.resultEl.text(this.password);
|
||||
// this.resultEl.prop('value', this.password);
|
||||
}
|
||||
}
|
||||
|
||||
setDisabledElements() {
|
||||
// disabled elements / checkboxes greyed out
|
||||
if (this.gen.disabledElements && this.gen.disabledElements.length) {
|
||||
for (let i = 0; i < this.gen.disabledElements.length; i++) {
|
||||
const elementName = this.gen.disabledElements[i];
|
||||
this.gen[elementName] = false;
|
||||
this.$el.find('.checkbox-' + elementName).attr('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,6 +127,20 @@ class GeneratorView extends View {
|
|||
this.gen.length = val;
|
||||
this.$el.find('.gen__length-range-val').text(val);
|
||||
this.optionChanged('length');
|
||||
// dont allow spaces unless password is a certain length
|
||||
if (this.presetLC !== 'passphrase') {
|
||||
const cboxSpaces = document.getElementsByClassName('checkbox-spaces');
|
||||
if (this.gen.length > this.spacesLenMin) {
|
||||
this.$el.find('.checkbox-spaces').removeAttr('disabled');
|
||||
if (cboxSpaces.item(0).checked) {
|
||||
this.gen.spaces = true; // force spaces to be enabled
|
||||
}
|
||||
} else {
|
||||
this.$el.find('.checkbox-spaces').attr('disabled', 'disabled');
|
||||
cboxSpaces.item(0).checked = false;
|
||||
this.gen.spaces = false; // force spaces to be disabled
|
||||
}
|
||||
}
|
||||
this.generate();
|
||||
}
|
||||
}
|
||||
|
@ -144,26 +150,78 @@ class GeneratorView extends View {
|
|||
if (id) {
|
||||
this.gen[id] = e.target.checked;
|
||||
}
|
||||
|
||||
if (this.presetLC === 'passphrase') {
|
||||
const cbSpecial = document.getElementsByClassName('checkbox-special');
|
||||
const cbHigh = document.getElementsByClassName('checkbox-high');
|
||||
if (id === 'special') {
|
||||
// upper checked -> uncheck and re-enable lower
|
||||
if (cbSpecial.item(0).checked) {
|
||||
this.$el.find('.checkbox-high').attr('disabled', 'disabled');
|
||||
cbHigh.item(0).checked = false;
|
||||
this.gen.high = false;
|
||||
} else {
|
||||
this.$el.find('.checkbox-high').removeAttr('disabled');
|
||||
}
|
||||
} else if (id === 'high') {
|
||||
// upper checked -> uncheck and re-enable lower
|
||||
if (cbHigh.item(0).checked) {
|
||||
this.$el.find('.checkbox-special').attr('disabled', 'disabled');
|
||||
cbSpecial.item(0).checked = false;
|
||||
this.gen.special = false;
|
||||
} else {
|
||||
this.$el.find('.checkbox-special').removeAttr('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.optionChanged(id);
|
||||
this.generate();
|
||||
}
|
||||
|
||||
optionChanged(option) {
|
||||
// certain presets should allow users to check custom options.
|
||||
// if a preset is not in the list, checking any checkbox will set preset to 'custom'
|
||||
if (
|
||||
this.preset === 'Custom' ||
|
||||
(this.preset === 'Pronounceable' && ['length', 'lower', 'upper'].indexOf(option) >= 0)
|
||||
this.presetLC === 'custom' ||
|
||||
(this.presetLC === 'passphrase' &&
|
||||
['length', 'lower', 'upper', 'digits', 'high', 'special', 'spaces'].indexOf(
|
||||
option
|
||||
) >= 0) ||
|
||||
((this.presetLC === 'hash128' ||
|
||||
this.presetLC === 'hash256' ||
|
||||
this.presetLC === 'hash512') &&
|
||||
['lower', 'upper'].indexOf(option) >= 0) ||
|
||||
(this.presetLC === 'uuid' && ['lower', 'upper', 'digits'].indexOf(option) >= 0) ||
|
||||
(this.presetLC === 'pronounceable' && ['length', 'lower', 'upper'].indexOf(option) >= 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.preset = this.gen.name = 'Custom';
|
||||
this.$el.find('.gen__sel-tpl').val('');
|
||||
}
|
||||
|
||||
generate() {
|
||||
this.password = PasswordGenerator.generate(this.gen);
|
||||
this.setDisabledElements();
|
||||
this.showPassword();
|
||||
const isLong = this.password.length > 32;
|
||||
this.resultEl.toggleClass('gen__result--long-pass', isLong);
|
||||
|
||||
// disable spaces checkif if password is below minimum
|
||||
if (this.gen.length < this.spacesLenMin && this.presetLC !== 'passphrase') {
|
||||
this.$el.find('.checkbox-spaces').attr('disabled', 'disabled');
|
||||
const cboxSpaces = document.getElementsByClassName('checkbox-spaces');
|
||||
cboxSpaces.item(0).checked = false;
|
||||
this.gen.spaces = false; // force spaces to be disabled
|
||||
}
|
||||
|
||||
this.resultEl.removeClass('__wrap');
|
||||
this.resultEl.removeClass('__nowrap');
|
||||
|
||||
this.setInputHeight();
|
||||
|
||||
// password vs passphrase word wrapping
|
||||
this.resultEl.toggleClass(this.presetLC === 'passphrase' ? '__nowrap' : '__wrap');
|
||||
}
|
||||
|
||||
hideChange(e) {
|
||||
|
@ -193,11 +251,27 @@ class GeneratorView extends View {
|
|||
return;
|
||||
}
|
||||
this.preset = name;
|
||||
this.presetLC = this.preset.toLowerCase();
|
||||
const preset = this.presets.find((t) => t.name === name);
|
||||
this.gen = { ...preset };
|
||||
this.render();
|
||||
}
|
||||
|
||||
setInputHeight() {
|
||||
const MinHeight = 10;
|
||||
this.resultEl.height(MinHeight);
|
||||
let newHeight = this.resultEl[0].scrollHeight;
|
||||
if (newHeight <= MinHeight) {
|
||||
newHeight = MinHeight;
|
||||
}
|
||||
|
||||
if (newHeight > 130) {
|
||||
newHeight = 130;
|
||||
}
|
||||
|
||||
this.resultEl.height(newHeight);
|
||||
}
|
||||
|
||||
newPass() {
|
||||
this.generate();
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -54,6 +54,7 @@
|
|||
<code>[</code> – {{res 'genPsBrackets'}}<br/>
|
||||
<code>Ä</code> – {{res 'genPsHigh'}}<br/>
|
||||
<code>0</code> – {{res 'genPsAmbiguous'}}<br/>
|
||||
<code>S</code> – {{res 'genPsSpaces'}}<br/>
|
||||
<code>I</code> – {{res 'genPsIncluded'}}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -20,22 +20,24 @@
|
|||
<option value="...">...</option>
|
||||
{{/if}}
|
||||
</select>
|
||||
<input type="range" class="gen__length-range" value="{{opt.pseudoLength}}" min="0" max="25" />
|
||||
<input type="range" class="gen__length-range" value="{{opt.pseudoLength}}" min="0" max="26" />
|
||||
<div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-upper"
|
||||
<div class="gen__check"><input class="checkbox-upper" type="checkbox" id="gen__check-upper"
|
||||
data-id="upper" {{#if opt.upper}}checked{{/if}}><label for="gen__check-upper">ABC</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-lower"
|
||||
<div class="gen__check"><input class="checkbox-lower" type="checkbox" id="gen__check-lower"
|
||||
data-id="lower" {{#if opt.lower}}checked{{/if}}><label for="gen__check-lower">abc</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-digits"
|
||||
<div class="gen__check"><input class="checkbox-digits" type="checkbox" id="gen__check-digits"
|
||||
data-id="digits" {{#if opt.digits}}checked{{/if}}><label for="gen__check-digits">123</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-special"
|
||||
<div class="gen__check"><input class="checkbox-special" type="checkbox" id="gen__check-special"
|
||||
data-id="special" {{#if opt.special}}checked{{/if}}><label for="gen__check-special">!@#</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-brackets"
|
||||
<div class="gen__check"><input class="checkbox-brackets" type="checkbox" id="gen__check-brackets"
|
||||
data-id="brackets" {{#if opt.brackets}}checked{{/if}}><label for="gen__check-brackets">({<</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-high"
|
||||
<div class="gen__check"><input class="checkbox-high" type="checkbox" id="gen__check-high"
|
||||
data-id="high" {{#if opt.high}}checked{{/if}}><label for="gen__check-high">äæ±</label></div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-ambiguous"
|
||||
<div class="gen__check"><input class="checkbox-ambiguous" type="checkbox" id="gen__check-ambiguous"
|
||||
data-id="ambiguous" {{#if opt.ambiguous}}checked{{/if}}><label for="gen__check-ambiguous">0Oo</label></div>
|
||||
<div class="gen__check spaces"><input class="checkbox-spaces" type="checkbox" id="gen__check-spaces"
|
||||
data-id="spaces" {{#if opt.spaces}}checked{{/if}}><label for="gen__check-spaces">Spaces</label></div>
|
||||
</div>
|
||||
<div class="gen__result"></div>
|
||||
<div class="gen__btn-wrap"><button class="gen__btn-ok">{{btnTitle}}</button></div>
|
||||
|
|
Loading…
Reference in New Issue