Files
reze-test/public/app.js
T
reze 58f585b691 reze: Add Node.js password generator with memorable and random modes
Public password generator with no auth and no npm dependencies.

- Server-side generation using crypto.randomInt; two levels: diceware-style
  memorable passphrases (42abacus-91abdomen) and fully random characters.
- Configurable word list (EFF Large / EFF Short), digits per word, separators,
  capitalization, length, character class toggles, custom symbols and
  ambiguous-character exclusion.
- Light/dark theme matching druid520/site, entropy estimate and strength bar,
  clipboard copy, options persisted in localStorage.
- Dockerfile for Dokploy plus a Woodpecker deploy hook on trunk.
2026-10-21 09:01:42 +00:00

380 lines
11 KiB
JavaScript

(function () {
'use strict';
var STORAGE_KEY = 'opts';
var DEFAULTS = {
mode: 'memorable',
words: 4,
digits: 2,
digitSeparator: '',
separator: '-',
capitalization: 'none',
list: 'eff-large',
length: 20,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: false,
customSymbols: '',
excludeAmbiguous: false,
requireEach: true,
count: 1
};
var FIELDS = [
{ key: 'words', id: 'words', type: 'int', min: 2, max: 10 },
{ key: 'digits', id: 'digits', type: 'int', min: 0, max: 4 },
{ key: 'digitSeparator', id: 'digitSeparator', type: 'str' },
{ key: 'separator', id: 'separator', type: 'str' },
{ key: 'capitalization', id: 'capitalization', type: 'str' },
{ key: 'list', id: 'list', type: 'str' },
{ key: 'length', id: 'length', type: 'int', min: 8, max: 64 },
{ key: 'count', id: 'count', type: 'int', min: 1, max: 10 },
{ key: 'customSymbols', id: 'customSymbols', type: 'str' },
{ key: 'useLower', id: 'useLower', type: 'bool' },
{ key: 'useUpper', id: 'useUpper', type: 'bool' },
{ key: 'useDigits', id: 'useDigits', type: 'bool' },
{ key: 'useSymbols', id: 'useSymbols', type: 'bool' },
{ key: 'excludeAmbiguous', id: 'excludeAmbiguous', type: 'bool' },
{ key: 'requireEach', id: 'requireEach', type: 'bool' }
];
var STRENGTH_LABELS = ['very weak', 'weak', 'fair', 'strong', 'very strong'];
var STRENGTH_THRESHOLDS = [28, 40, 60, 80];
function byId(id) {
return document.getElementById(id);
}
function clampInt(value, fallback, min, max) {
var n = parseInt(value, 10);
if (isNaN(n)) return fallback;
if (n < min) return min;
if (n > max) return max;
return n;
}
function loadState() {
var state = {};
var key;
for (key in DEFAULTS) {
if (Object.prototype.hasOwnProperty.call(DEFAULTS, key)) state[key] = DEFAULTS[key];
}
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var saved = JSON.parse(raw);
if (saved && typeof saved === 'object') {
for (key in saved) {
if (Object.prototype.hasOwnProperty.call(state, key)) state[key] = saved[key];
}
}
}
} catch (e) {}
return state;
}
function saveState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {}
}
function readState() {
var state = {};
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
state[field.key] = el.checked;
} else if (field.type === 'int') {
state[field.key] = clampInt(el.value, DEFAULTS[field.key], field.min, field.max);
} else {
state[field.key] = el.value;
}
}
var checked = document.querySelector('input[name="mode"]:checked');
state.mode = checked ? checked.value : DEFAULTS.mode;
return state;
}
function applyState(state) {
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
el.checked = Boolean(state[field.key]);
} else if (field.type === 'int') {
el.value = clampInt(state[field.key], DEFAULTS[field.key], field.min, field.max);
} else {
el.value = state[field.key] === undefined || state[field.key] === null ? '' : state[field.key];
}
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].checked = radios[i].value === state.mode;
}
}
function updatePanels(state) {
var memorable = byId('panel-memorable');
var random = byId('panel-random');
if (memorable) memorable.hidden = state.mode !== 'memorable';
if (random) random.hidden = state.mode !== 'random';
}
function updateLengthOut() {
var length = byId('length');
var out = byId('length-out');
if (length && out) out.textContent = length.value;
}
function strengthLevel(bits) {
var i;
for (i = 0; i < STRENGTH_THRESHOLDS.length; i += 1) {
if (bits < STRENGTH_THRESHOLDS[i]) return i;
}
return STRENGTH_LABELS.length - 1;
}
function buildStrength(bits) {
var level = strengthLevel(bits);
var wrap = document.createElement('span');
wrap.className = 'strength';
wrap.setAttribute('role', 'img');
wrap.setAttribute('aria-label', 'strength: ' + STRENGTH_LABELS[level]);
var i;
for (i = 0; i < STRENGTH_LABELS.length; i += 1) {
var seg = document.createElement('i');
if (i <= level) seg.className = 'on';
wrap.appendChild(seg);
}
return wrap;
}
function showError(message) {
var box = byId('error');
if (!box) return;
if (message) {
box.textContent = message;
box.hidden = false;
} else {
box.textContent = '';
box.hidden = true;
}
}
function copyWithFallback(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', 'readonly');
area.style.position = 'absolute';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
document.body.removeChild(area);
if (ok) {
resolve();
} else {
reject(new Error('copy failed'));
}
});
}
function copyPassword(value, button) {
var original = button.getAttribute('data-label') || 'Copy';
copyWithFallback(value).then(function () {
button.textContent = 'Copied';
}, function () {
button.textContent = 'Failed';
}).then(function () {
window.setTimeout(function () {
button.textContent = original;
}, 1200);
});
}
function renderPasswords(data) {
var container = byId('results');
if (!container) return;
container.textContent = '';
var list = (data && data.passwords) || [];
for (let i = 0; i < list.length; i += 1) {
const item = list[i];
const row = document.createElement('div');
row.className = 'pw';
const value = document.createElement('pre');
value.className = 'pw-value';
value.textContent = item.value;
const meta = document.createElement('div');
meta.className = 'pw-meta';
const entropy = document.createElement('span');
entropy.className = 'pw-entropy';
entropy.textContent = item.entropy + ' bits';
const level = strengthLevel(item.entropy);
const label = document.createElement('span');
label.className = 'pw-strength-label';
label.textContent = STRENGTH_LABELS[level];
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'button copy';
copy.textContent = 'Copy';
copy.setAttribute('data-label', 'Copy');
copy.setAttribute('aria-label', 'Copy password');
copy.addEventListener('click', function () {
copyPassword(item.value, copy);
});
meta.appendChild(entropy);
meta.appendChild(buildStrength(item.entropy));
meta.appendChild(label);
meta.appendChild(copy);
row.appendChild(value);
row.appendChild(meta);
container.appendChild(row);
}
}
function payloadFor(state) {
if (state.mode === 'random') {
return {
mode: 'random',
length: state.length,
useLower: state.useLower,
useUpper: state.useUpper,
useDigits: state.useDigits,
useSymbols: state.useSymbols,
customSymbols: state.customSymbols,
excludeAmbiguous: state.excludeAmbiguous,
requireEach: state.requireEach,
count: state.count
};
}
return {
mode: 'memorable',
words: state.words,
digits: state.digits,
digitSeparator: state.digitSeparator,
separator: state.separator,
capitalization: state.capitalization,
list: state.list,
count: state.count
};
}
function generate() {
var state = readState();
saveState(state);
showError('');
fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadFor(state))
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error(data && data.error ? data.error : 'request failed with status ' + response.status);
}
return data;
});
}).then(function (data) {
renderPasswords(data);
}, function (err) {
showError(err && err.message ? err.message : 'request failed');
});
}
function loadMeta() {
fetch('/api/meta', { headers: { Accept: 'application/json' } }).then(function (response) {
return response.json();
}).then(function (data) {
var lists = (data && data.lists) || [];
var i;
for (i = 0; i < lists.length; i += 1) {
var option = document.querySelector('#list option[value="' + lists[i].id + '"]');
if (option) option.textContent = lists[i].name + ' (' + lists[i].size + ' words)';
}
}, function () {});
}
function init() {
var state = loadState();
applyState(state);
updatePanels(state);
updateLengthOut();
var i;
var field;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
(function (f) {
var el = byId(f.id);
if (!el) return;
var handler = function () {
var current = readState();
saveState(current);
updatePanels(current);
updateLengthOut();
};
el.addEventListener('change', handler);
el.addEventListener('input', handler);
})(field);
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].addEventListener('change', function () {
var current = readState();
saveState(current);
updatePanels(current);
});
}
var generateBtn = byId('generate');
if (generateBtn) generateBtn.addEventListener('click', generate);
var regenerateBtn = byId('regenerate');
if (regenerateBtn) regenerateBtn.addEventListener('click', generate);
loadMeta();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();