Files
wifi-gps-mapper-public/cracklab/server/web/templates/method_create.html
T
2026-10-06 21:08:12 +03:00

1701 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}
CrackLab — Create Method
{% endblock %}
{% block content %}
<style>
.method-form {
width: 100%;
max-width: 1500px;
margin: 0 auto;
}
#method-form {
display: grid;
grid-template-columns:
minmax(320px, 0.8fr)
minmax(620px, 1.6fr);
gap: 24px;
align-items: start;
}
.method-form-section {
margin-bottom: 0;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.method-form-section h2 {
margin: 0 0 20px;
font-size: 20px;
}
.method-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.method-field-full {
grid-column: 1 / -1;
}
.method-field label {
color: var(--muted);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.4px;
}
.method-field input,
.method-field select,
.method-field textarea {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--terminal);
color: var(--text);
font: inherit;
outline: none;
}
.method-field input:focus,
.method-field select:focus,
.method-field textarea:focus {
border-color: var(--accent);
}
.method-field textarea {
min-height: 100px;
resize: vertical;
}
.method-help {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.method-step {
margin-bottom: 18px;
padding: 20px;
background: var(--surface-light);
border: 1px solid var(--border);
border-radius: 9px;
}
.method-step:last-child {
margin-bottom: 0;
}
.method-step-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
margin-bottom: 18px;
}
.method-step-title {
font-size: 17px;
font-weight: 700;
}
.method-step-mode {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.method-step-fields {
display: grid;
grid-template-columns:
repeat(
auto-fit,
minmax(240px, 1fr)
);
gap: 18px;
}
.method-error {
margin-bottom: 24px;
padding: 14px 16px;
border: 1px solid var(--red);
border-radius: 8px;
background: rgba(239, 98, 98, 0.08);
color: var(--red);
}
.method-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 0;
}
.method-remove-step {
min-height: 32px;
padding: 0 11px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.method-remove-step:hover {
color: var(--red);
border-color: var(--red);
}
.method-mode-help {
grid-column: 1 / -1;
margin-top: -2px;
padding: 15px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--terminal);
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.method-mode-help-title {
margin-bottom: 6px;
color: var(--text);
font-weight: 700;
}
.method-mode-help code {
padding: 2px 5px;
border-radius: 4px;
background: var(--surface-light);
color: var(--text);
font-family: monospace;
}
.method-keyspace {
grid-column: 1 / -1;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--terminal);
color: var(--muted);
font-size: 13px;
}
.method-keyspace strong {
color: var(--text);
}
.method-steps-section {
grid-column: 2;
grid-row: 1 / span 2;
}
.method-mask-bulk {
grid-column: 1;
grid-row: 2 / span 4;
align-self: start;
padding: 20px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--surface-light);
}
.method-mask-bulk-title {
margin-bottom: 7px;
color: var(--text);
font-size: 17px;
font-weight: 700;
}
.method-mask-bulk textarea {
display: block;
width: 100%;
min-height: 250px;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--terminal);
color: var(--text);
font: inherit;
font-family: monospace;
outline: none;
resize: none;
white-space: pre;
box-sizing: border-box;
}
.method-mask-bulk textarea:focus {
border-color: var(--accent);
}
.method-mask-bulk-actions {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 10px;
}
.method-step-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.method-mask-bulk {
display: none;
}
.method-mask-bulk.is-visible {
display: block;
}
.method-mask-bulk-status {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.method-mask-bulk-status-error {
color: var(--red);
}
.method-mask-bulk-status-success {
color: var(--accent);
}
.method-mask-bulk-status ul {
margin: 6px 0 0;
padding-left: 20px;
}
.method-mask-bulk button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 1000px) {
#method-form {
grid-template-columns: 1fr;
}
.method-actions {
grid-column: auto;
}
}
@media (max-width: 700px) {
.method-actions {
flex-direction: column;
align-items: stretch;
}
.method-step-actions {
flex-direction: column;
align-items: stretch;
}
.method-mask-bulk-actions {
flex-direction: column;
align-items: stretch;
}
}
</style>
<div class="method-form">
<div class="methods-page-header">
<div>
<h1 class="page-title">
Создание метода
</h1>
<div class="page-description">
Новый рабочий метод будет сохранён в базе данных.
</div>
</div>
</div>
{% if error %}
<div class="method-error">
{{ error }}
</div>
{% endif %}
<form
method="post"
id="method-form"
>
<section class="method-form-section">
<h2>
Основная информация
</h2>
<div class="method-form-grid">
<div class="method-field">
<label for="name">
Название
</label>
<input
id="name"
name="name"
type="text"
required
autocomplete="off"
>
</div>
<div class="method-field">
<label for="hash_mode">
Тип хэша
</label>
<select
id="hash_mode"
name="hash_mode"
required
>
<option value="22000">
WPA-PBKDF2-PMKID+EAPOL (22000)
</option>
</select>
<div class="method-help">
Сейчас CrackLab работает с WPA/WPA2 hash mode 22000.
Дополнительные Wi-Fi hash modes можно будет добавить
позже.
</div>
</div>
<div class="method-field method-field-full">
<label for="description">
Описание
</label>
<textarea
id="description"
name="description"
placeholder="Например: имя + четыре цифры"
></textarea>
</div>
</div>
</section>
<section class="method-form-section method-steps-section">
<h2>
Шаги метода
</h2>
<div id="steps"></div>
<div class="method-step-actions">
<button
type="button"
class="methods-button-secondary"
id="add-step"
>
+ Добавить шаг
</button>
<button
type="button"
class="methods-button-secondary"
id="add-mask-bulk"
>
+ Добавить списком
</button>
</div>
<input
type="hidden"
id="step_count"
name="step_count"
value="1"
>
</section>
<div
class="method-mask-bulk"
id="mask-bulk"
>
<div class="method-mask-bulk-title">
Список масок
</div>
<textarea
data-mask-bulk
placeholder="?d?d?d?dwest
?u?u?u-west
?u?uwest?l?l"
spellcheck="false"
></textarea>
<div class="method-help">
Только для attack mode 3.
Одна маска на строку.
Пустые строки игнорируются.
Дубликаты удаляются после нормализации.
Минимальная длина кандидата — 8 позиций.
</div>
<div class="method-mask-bulk-actions">
<button
type="button"
class="methods-button-secondary"
data-mask-bulk-add
disabled
>
Добавить маски в шаги
</button>
<div
class="method-mask-bulk-status"
data-mask-bulk-status
></div>
</div>
</div>
<div class="method-actions">
<button
type="submit"
class="methods-button"
>
Сохранить метод
</button>
<a
class="methods-button-secondary"
href="{{ url_for('cracklab.methods_page') }}"
>
Отмена
</a>
</div>
</form>
</div>
<script>
const stepsContainer =
document.getElementById("steps");
const stepCountInput =
document.getElementById("step_count");
const addStepButton =
document.getElementById("add-step");
const addMaskBulkButton =
document.getElementById("add-mask-bulk");
const methodForm =
document.getElementById("method-form");
let stepCount = 0;
const modeNames = {
"0": "Dictionary",
"1": "Combination",
"3": "Mask",
"6": "Dictionary + Mask",
"7": "Mask + Dictionary",
};
const maskCharsetSizes = {
"?l": 26n,
"?u": 26n,
"?d": 10n,
"?s": 33n,
"?a": 95n,
"?b": 256n,
};
function escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function dictionaryField(
name,
label,
help,
) {
return `
<div class="method-field">
<label>
${escapeHtml(label)}
</label>
<input
name="${escapeHtml(name)}"
type="text"
placeholder="insidepro-wpa"
required
autocomplete="off"
>
<div class="method-help">
${escapeHtml(help)}
</div>
</div>
`;
}
function maskField(name) {
return `
<div class="method-field">
<label>
Маска
</label>
<input
name="${escapeHtml(name)}"
type="text"
placeholder="?d?d?d?d?d?d?d?d"
required
autocomplete="off"
>
<div class="method-help">
Charset:
<code>?l</code>,
<code>?u</code>,
<code>?d</code>,
<code>?s</code>,
<code>?a</code>,
<code>?b</code>.
Для буквального <code>?</code> используйте
<code>??</code>.
</div>
</div>
`;
}
function maskBulkField() {
return `
<div class="method-mask-bulk">
<div class="method-mask-bulk-title">
Список масок
</div>
<textarea
data-mask-bulk
placeholder="?d?d?d?dwest
?u?u?u-west
?u?uwest?l?l"
spellcheck="false"
></textarea>
<div class="method-help">
Только для attack mode 3.
Одна маска на строку.
Пустые строки игнорируются.
Дубликаты удаляются после нормализации.
Минимальная длина кандидата — 8 позиций.
</div>
<div class="method-mask-bulk-actions">
<button
type="button"
class="methods-button-secondary"
data-mask-bulk-add
disabled
>
Добавить маски в шаги
</button>
<div
class="method-mask-bulk-status"
data-mask-bulk-status
></div>
</div>
</div>
`;
}
function modeHelp(mode) {
const helps = {
"0": `
<div class="method-mode-help-title">
Как работает словарь
</div>
Hashcat последовательно проверяет каждую строку
словаря как отдельный кандидат пароля.
Например, словарь может содержать
<code>password</code>,
<code>qwerty</code>,
<code>welcome</code>.
`,
"1": `
<div class="method-mode-help-title">
Как работает комбинация
</div>
Каждое слово левого словаря объединяется
с каждым словом правого словаря.
Например:
<code>alex</code> + <code>1234</code>
→ <code>alex1234</code>.
<br>
<strong>Порядок важен:</strong>
левый словарь образует левую часть кандидата,
правый — правую.
`,
"3": `
<div class="method-mode-help-title">
Как работает маска
</div>
Маска задаёт шаблон кандидата.
Hashcat перебирает все варианты символов,
разрешённых в каждой позиции.
<br><br>
<code>?l</code> — строчная буква a-z<br>
<code>?u</code> — заглавная буква A-Z<br>
<code>?d</code> — цифра 0-9<br>
<code>?s</code> — специальный символ<br>
<code>?a</code> — буква, цифра или специальный символ<br>
<code>?b</code> — полный байтовый charset<br>
<code>??</code> — буквальный символ <code>?</code>
<br><br>
В режиме 3 кандидат должен содержать
минимум 8 позиций.
Например:
<code>?d?d?d?dwest</code>
→ 8 позиций.
<br><br>
Текст без charset является буквальной
частью кандидата.
Например:
<code>u?u?west?l?l</code>
→ 8 позиций.
`,
"6": `
<div class="method-mode-help-title">
Как работает Dictionary + Mask
</div>
К каждому слову словаря добавляются все варианты,
соответствующие маске.
<br>
Например, слово <code>alex</code> и маска
<code>?d?d</code> дают кандидатов от
<code>alex00</code> до <code>alex99</code>.
<br><br>
<strong>Порядок:</strong>
словарь слева, маска справа.
`,
"7": `
<div class="method-mode-help-title">
Как работает Mask + Dictionary
</div>
К каждой комбинации, созданной маской,
добавляется слово из словаря справа.
<br>
Например, маска <code>?d?d</code> и слово
<code>alex</code> дают
<code>00alex</code>,
<code>01alex</code>,
<code>02alex</code>
и так далее.
<br><br>
<strong>Порядок:</strong>
маска слева, словарь справа.
`,
};
return `
<div class="method-mode-help">
${helps[mode] || ""}
</div>
`;
}
function analyzeMask(mask) {
if (!mask) {
return {
length: 0,
keyspace: 1n,
error: "Маска не должна быть пустой.",
};
}
let length = 0;
let keyspace = 1n;
for (
let index = 0;
index < mask.length;
) {
if (mask[index] !== "?") {
length += 1;
index += 1;
continue;
}
if (index + 1 >= mask.length) {
return {
length,
keyspace,
error:
"Одиночный символ '?' в позиции " +
`${index + 1}. Для буквального ? используйте ??.`,
};
}
const token =
mask.slice(
index,
index + 2,
);
if (token === "??") {
length += 1;
index += 2;
continue;
}
if (
!Object.prototype.hasOwnProperty.call(
maskCharsetSizes,
token,
)
) {
return {
length,
keyspace,
error:
`Неизвестный charset ${token} ` +
`в позиции ${index + 1}.`,
};
}
length += 1;
keyspace *=
maskCharsetSizes[token];
index += 2;
}
return {
length,
keyspace,
error: null,
};
}
function calculateMaskKeyspace(mask) {
const analysis =
analyzeMask(mask);
if (analysis.error) {
return null;
}
return analysis.keyspace;
}
function validateBulkMasks(value) {
const masks = [];
const seen = new Set();
const errors = [];
value.split(/\r?\n/).forEach(
(rawLine, lineIndex) => {
const mask =
rawLine.trim();
if (!mask) {
return;
}
const analysis =
analyzeMask(mask);
if (analysis.error) {
errors.push(
`Строка ${lineIndex + 1}: ` +
analysis.error,
);
return;
}
if (analysis.length < 8) {
errors.push(
`Строка ${lineIndex + 1}: ` +
"маска даёт только " +
`${analysis.length} позиций, ` +
"минимум — 8.",
);
return;
}
if (seen.has(mask)) {
return;
}
seen.add(mask);
masks.push(mask);
},
);
return {
masks,
errors,
};
}
function formatNumber(value) {
return new Intl.NumberFormat(
"ru-RU",
).format(value);
}
function updateMaskKeyspace(
step,
mask,
) {
const keyspace =
step.querySelector(
"[data-keyspace]",
);
if (!keyspace) {
return;
}
const maskInput =
step.querySelector(
'input[name$="_mask"]',
);
const analysis =
analyzeMask(mask);
const modeSelect =
step.querySelector(
"[data-attack-mode]",
);
const minimumLengthRequired =
modeSelect &&
modeSelect.value === "3";
let error =
analysis.error;
if (
!error &&
minimumLengthRequired &&
analysis.length < 8
) {
error =
"Маска должна давать минимум " +
`8 позиций, сейчас ${analysis.length}.`;
}
if (maskInput) {
maskInput.setCustomValidity(
error || "",
);
}
if (error) {
keyspace.innerHTML =
"<strong>Ошибка маски:</strong> " +
escapeHtml(error);
return;
}
keyspace.innerHTML =
"<strong>Длина кандидата:</strong> " +
formatNumber(
analysis.length,
) +
"<br>" +
"<strong>Количество кандидатов:</strong> " +
formatNumber(
analysis.keyspace,
);
}
function renderStepFields(
stepNumber,
mode,
) {
const prefix =
`step_${stepNumber}`;
let html = "";
if (mode === "0") {
html += dictionaryField(
`${prefix}_dictionary`,
"Словарь",
"Укажите логическое имя wordlist, которое настроено на клиенте.",
);
}
if (mode === "1") {
html += dictionaryField(
`${prefix}_dictionary_1`,
"Словарь — Left",
"Этот словарь образует левую часть кандидата.",
);
html += dictionaryField(
`${prefix}_dictionary_2`,
"Словарь — Right",
"Этот словарь образует правую часть кандидата.",
);
}
if (mode === "3") {
html += maskField(
`${prefix}_mask`,
);
html += `
<div
class="method-keyspace"
data-keyspace
>
<strong>Длина кандидата:</strong>
укажите маску
</div>
`;
}
if (mode === "6") {
html += dictionaryField(
`${prefix}_dictionary`,
"Словарь — Left",
"Словарь образует левую часть кандидата.",
);
html += maskField(
`${prefix}_mask`,
);
html += `
<div
class="method-keyspace"
data-keyspace
>
<strong>Количество кандидатов:</strong>
укажите маску
</div>
`;
}
if (mode === "7") {
html += maskField(
`${prefix}_mask`,
);
html += `
<div
class="method-keyspace"
data-keyspace
>
<strong>Количество кандидатов:</strong>
укажите маску
</div>
`;
html += dictionaryField(
`${prefix}_dictionary`,
"Словарь — Right",
"Словарь образует правую часть кандидата.",
);
}
return html;
}
function bindMaskInput(step) {
const mask =
step.querySelector(
'input[name$="_mask"]',
);
if (!mask) {
return;
}
mask.addEventListener(
"input",
() => {
updateMaskKeyspace(
step,
mask.value,
);
},
);
updateMaskKeyspace(
step,
mask.value,
);
}
function bindMaskBulk() {
const bulk =
document.getElementById("mask-bulk");
const textarea =
bulk.querySelector(
"[data-mask-bulk]",
);
const button =
bulk.querySelector(
"[data-mask-bulk-add]",
);
const status =
bulk.querySelector(
"[data-mask-bulk-status]",
);
const refresh = () => {
const result =
validateBulkMasks(
textarea.value,
);
button.disabled =
result.errors.length > 0 ||
result.masks.length === 0;
if (result.errors.length > 0) {
status.className =
"method-mask-bulk-status " +
"method-mask-bulk-status-error";
status.innerHTML =
"<ul>" +
result.errors
.map(
(error) =>
`<li>${escapeHtml(error)}</li>`,
)
.join("") +
"</ul>";
return;
}
if (result.masks.length > 0) {
status.className =
"method-mask-bulk-status " +
"method-mask-bulk-status-success";
status.textContent =
`Готово: ${result.masks.length} ` +
"уникальных масок.";
return;
}
status.className =
"method-mask-bulk-status";
status.textContent =
"Введите по одной маске на строку.";
};
textarea.addEventListener(
"input",
refresh,
);
button.addEventListener(
"click",
() => {
const result =
validateBulkMasks(
textarea.value,
);
if (
result.errors.length > 0 ||
result.masks.length === 0
) {
refresh();
return;
}
addBulkMasks(
result.masks,
);
textarea.value = "";
button.disabled = true;
status.className =
"method-mask-bulk-status " +
"method-mask-bulk-status-success";
status.textContent =
`Добавлено: ${result.masks.length} ` +
"масок.";
},
);
refresh();
}
function updateMaskBulkVisibility() {
const bulk =
document.getElementById("mask-bulk");
const hasMaskMode =
Array.from(
stepsContainer.querySelectorAll(
"[data-attack-mode]",
),
).some(
(modeSelect) =>
modeSelect.value === "3",
);
addMaskBulkButton.disabled =
!hasMaskMode;
if (!hasMaskMode) {
bulk.classList.remove(
"is-visible",
);
}
}
function addBulkMasks(
masks,
) {
masks.forEach(
(mask) => {
const newStep =
addStep();
const modeSelect =
newStep.querySelector(
"[data-attack-mode]",
);
modeSelect.value = "3";
modeSelect.dispatchEvent(
new Event("change"),
);
const newMask =
newStep.querySelector(
'input[name$="_mask"]',
);
newMask.value =
mask;
newMask.dispatchEvent(
new Event(
"input",
{ bubbles: true },
),
);
},
);
stepCountInput.value =
stepCount;
}
function addStep() {
stepCount += 1;
const currentStepNumber =
stepCount;
const step =
document.createElement("div");
step.className =
"method-step";
step.dataset.step =
currentStepNumber;
step.innerHTML = `
<div class="method-step-header">
<div>
<div class="method-step-title">
Step ${currentStepNumber}
</div>
<div
class="method-step-mode"
data-mode-label
>
Attack mode
</div>
</div>
<button
type="button"
class="method-remove-step"
data-remove-step
>
Удалить
</button>
</div>
<div class="method-step-fields">
<div class="method-field">
<label>
Режим атаки
</label>
<select
name="step_${currentStepNumber}_attack_mode"
data-attack-mode
required
>
<option value="">
Выберите режим
</option>
<option value="0">
0 — Dictionary
</option>
<option value="1">
1 — Combination
</option>
<option value="3">
3 — Mask
</option>
<option value="6">
6 — Dictionary + Mask
</option>
<option value="7">
7 — Mask + Dictionary
</option>
</select>
</div>
<div class="method-field">
<label>
Профиль нагрузки
</label>
<select
name="step_${currentStepNumber}_workload_profile"
data-workload-profile
>
<option value="1">
1 — Low
</option>
<option
value="2"
selected
>
2 — Default
</option>
<option value="3">
3 — High
</option>
<option value="4">
4 — Nightmare
</option>
</select>
<div class="method-help">
Определяет, насколько агрессивно Hashcat
использует ресурсы компьютера.
</div>
</div>
<div
class="method-field-full"
data-step-fields
></div>
</div>
`;
stepsContainer.appendChild(
step,
);
const modeSelect =
step.querySelector(
"[data-attack-mode]",
);
const fields =
step.querySelector(
"[data-step-fields]",
);
const modeLabel =
step.querySelector(
"[data-mode-label]",
);
function updateStep() {
const mode =
modeSelect.value;
const number =
Number(
step.dataset.step,
);
fields.innerHTML =
renderStepFields(
number,
mode,
);
modeLabel.textContent =
mode
? `${modeNames[mode]} (mode ${mode})`
: "Attack mode";
if (mode) {
fields.insertAdjacentHTML(
"beforeend",
modeHelp(mode),
);
}
bindMaskInput(step);
updateMaskBulkVisibility();
}
modeSelect.addEventListener(
"change",
updateStep,
);
step.querySelector(
"[data-remove-step]",
).addEventListener(
"click",
() => {
if (
stepsContainer.children.length <= 1
) {
return;
}
step.remove();
rebuildStepNumbers();
},
);
updateStep();
stepCountInput.value =
stepCount;
return step;
}
function rebuildStepNumbers() {
const stepElements =
Array.from(
stepsContainer.children,
);
stepCount =
stepElements.length;
stepElements.forEach(
(step, index) => {
const number =
index + 1;
step.dataset.step =
number;
const title =
step.querySelector(
".method-step-title",
);
if (title) {
title.textContent =
`Step ${number}`;
}
const modeSelect =
step.querySelector(
"[data-attack-mode]",
);
if (modeSelect) {
modeSelect.name =
`step_${number}_attack_mode`;
}
const workload =
step.querySelector(
"[data-workload-profile]",
);
if (workload) {
workload.name =
`step_${number}_workload_profile`;
}
const namedFields =
step.querySelectorAll(
"[data-step-fields] [name]",
);
namedFields.forEach(
(field) => {
field.name =
field.name.replace(
/^step_\d+_/,
`step_${number}_`,
);
},
);
},
);
stepCountInput.value =
stepCount;
}
methodForm.addEventListener(
"submit",
(event) => {
const maskInputs =
stepsContainer.querySelectorAll(
'input[name$="_mask"]',
);
let firstInvalidMask = null;
maskInputs.forEach(
(maskInput) => {
const step =
maskInput.closest(
".method-step",
);
const analysis =
analyzeMask(
maskInput.value.trim(),
);
const modeSelect =
step.querySelector(
"[data-attack-mode]",
);
const mode =
modeSelect
? modeSelect.value
: "";
let error =
analysis.error;
if (
!error &&
mode === "3" &&
analysis.length < 8
) {
error =
"Маска должна давать минимум " +
`8 позиций, сейчас ${analysis.length}.`;
}
maskInput.setCustomValidity(
error || "",
);
if (
error &&
!firstInvalidMask
) {
firstInvalidMask =
maskInput;
}
},
);
if (firstInvalidMask) {
event.preventDefault();
firstInvalidMask.reportValidity();
}
},
);
addMaskBulkButton.addEventListener(
"click",
() => {
if (addMaskBulkButton.disabled) {
return;
}
const bulk =
document.getElementById(
"mask-bulk",
);
bulk.classList.add(
"is-visible",
);
bulk.querySelector(
"[data-mask-bulk]",
).focus();
},
);
addStepButton.addEventListener(
"click",
() => {
addStep();
},
);
addStep();
</script>
{% endblock %}