ExklusivBeta
Two-Key <mv-two-key>
Schutz für kritische Aktionen (Produktion löschen, Geld überweisen, deployen), der nur durch zwei bewusste, synchronisierte „Schlüssel“ scharf geschaltet wird: zwei Schlüsselschalter, deren Zylinder sich mit einer Feder um eine Vierteldrehung dreht, und ein geschützter Aktions-Button unter einem Milchglasdeckel. Modus sync: Beide Schlüssel müssen innerhalb eines kurzen Zeitfensters gedreht werden: Der erste rastet mit einem Countdown-Ring ein, zwei Finger oder ein Zwei-Tasten-Akkord auf der Tastatur (F + J) drehen beide gleichzeitig, und wer das Fenster verpasst, setzt mit „Out of sync“ alles zurück. Modus two-person: Dein Schlüssel fordert einen zweiten Freigeber an (mv-request → approve(name) / reject()) und zeigt „Waiting for a second approver…“. Sind beide gedreht, hebt sich der Deckel für einige Sekunden von der Aktion (ablaufende Zündschnur); Escape oder der Timeout entschärfen. Kann außerdem verlangen, den Namen der Ressource einzutippen, bevor die Schlüssel freigegeben werden.
| Kategorie | Formulare |
|---|---|
| Typ | Web Component (<mv-two-key>) |
| Status | Beta |
| Kit | Sichere destruktive Aktionen und Geheimnisse |
| Keywords | exclusive, culture, confirm, confirmation, destructive, dangerous-action, two-person-rule, four-eyes, approval, dual-control, arming, key-switch, rotary, chord, multi-touch, guard, safety |
When to use
- An irreversible operation such as deleting a production database needs more than a single confirm click
- A payment or payout above a threshold must be approved by a second person before it can be sent
- A deploy, rollback or failover should require a deliberate two-handed gesture that can't happen by accident
- An admin console needs a typed resource name plus an explicit arming step before a destructive action
Avoid when
- The action is reversible or low-stakes: a plain confirmation is enough and less tiring → use Dialog instead
- One deliberate gesture is enough and there's no second person or key involved → use Hold Button instead
- The two-person rule must be enforced for real: the approval has to be checked on the server, the component only orchestrates the UI
Installation
node scripts/add.mjs two-key --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["two-key"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/two-key/two-key.js, components/two-key/two-key.css.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-two-key confirm-text="orders-prod-db">
<button slot="action">Delete database</button>
</mv-two-key>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<div id="tk-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#tk-demo { display:grid; grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); gap:1.25rem; align-items:start }
#tk-demo .tk-card { display:grid; gap:1rem; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#tk-demo .tk-head { display:flex; align-items:flex-start; gap:.75rem }
#tk-demo .tk-icon { display:grid; place-items:center; width:2.25rem; height:2.25rem; flex:none; border-radius:var(--mv-radius-lg); background:color-mix(in oklch, var(--mv-danger) 12%, transparent); color:var(--mv-danger) }
#tk-demo .tk-icon[data-tone="accent"] { background:var(--mv-accent-subtle); color:var(--mv-accent-fg) }
#tk-demo .tk-icon svg { width:1.125rem; height:1.125rem }
#tk-demo .tk-title { display:grid; gap:.2rem; min-width:0 }
#tk-demo .tk-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em; line-height:1.3 }
#tk-demo .tk-title p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#tk-demo .tk-facts { display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; margin:0; padding:.75rem .875rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font-size:.8125rem }
#tk-demo .tk-facts dt { color:var(--mv-fg-muted) }
#tk-demo .tk-facts dd { margin:0; text-align:end; font-variant-numeric:tabular-nums; font-weight:500 }
#tk-demo .tk-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding-top:.75rem; border-top:1px solid var(--mv-border) }
#tk-demo .tk-log { margin:0; min-width:0; color:var(--mv-fg-subtle); font:400 .75rem/1.5 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1 1 12rem }
#tk-demo .tk-log b { color:var(--mv-fg-muted); font-weight:600 }
#tk-demo .mv-choice { font-size:.8125rem }
</style>
<!-- 1 · Sync mode + typed confirmation -->
<section class="tk-card" aria-labelledby="tk-db-title">
<div class="tk-head">
<span class="tk-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="M19.5 5.5v4"/><path d="M4.5 11.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="m15.5 14.5 5 5"/><path d="m20.5 14.5-5 5"/></svg></span>
<div class="tk-title">
<h3 id="tk-db-title">Delete production database</h3>
<p>Permanently deletes <b>orders-prod-db</b>, its 14 replicas and 30 days of snapshots.</p>
</div>
</div>
<mv-two-key id="tk-db" mode="sync" window="3000" armed="5000" chord="F J" confirm-text="orders-prod-db" done-label="Deletion scheduled. You have 24 hours to restore it.">
<button slot="action" class="mv-button" data-variant="destructive" type="button">Delete orders-prod-db</button>
</mv-two-key>
<div class="tk-foot">
<p class="tk-log" id="tk-db-log">Type the name, then turn both keys (or hold F + J)</p>
<button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="tk-db-fill">Fill name</button>
</div>
</section>
<!-- 2 · Two-person approval -->
<section class="tk-card" aria-labelledby="tk-wire-title">
<div class="tk-head">
<span class="tk-icon" data-tone="accent" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10h18"/><path d="M5 10v8"/><path d="M9.7 10v8"/><path d="M14.3 10v8"/><path d="M19 10v8"/><path d="M3 20.5h18"/><path d="M12 3.5 21 8H3z"/></svg></span>
<div class="tk-title">
<h3 id="tk-wire-title">Transfer $48,200 to vendor</h3>
<p>Payments over $25,000 need a second approver from Finance.</p>
</div>
</div>
<dl class="tk-facts">
<dt>Payee</dt><dd>Halden & Rowe Logistics</dd>
<dt>Invoice</dt><dd>INV-20931 · due Sep 30, 2026</dd>
<dt>From</dt><dd>Operating account ••4417</dd>
</dl>
<mv-two-key id="tk-wire" mode="two-person" armed="8000" label-a="Your key" label-b="Finance approver" done-label="Transfer sent. Confirmation #TX-58213.">
<button slot="action" class="mv-button" type="button">Send $48,200.00</button>
</mv-two-key>
<div class="tk-foot">
<p class="tk-log" id="tk-wire-log">Priya Raman (Finance) answers in about 2s</p>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tk-decline"><span class="mv-choice-text"><span class="mv-choice-title">Approver declines</span></span></label>
</div>
</section>
<script type="module">
await customElements.whenDefined("mv-two-key");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const logger = (id) => {
const el = document.getElementById(id);
return (text) => {
const b = document.createElement("b");
b.textContent = text;
el.replaceChildren(b, ` · ${time()}`);
};
};
const wire = (el, log) => {
el.addEventListener("mv-turn", (e) => log(`mv-turn { key: "${e.detail.key}", turned: ${e.detail.turned} }`));
el.addEventListener("mv-arm", (e) => log(`mv-arm { expires: ${e.detail.expires} }`));
el.addEventListener("mv-launch", (e) => log(`mv-launch { approver: ${e.detail.approver ? `"${e.detail.approver}"` : "null"} }`));
el.addEventListener("mv-abort", (e) => log(`mv-abort { reason: "${e.detail.reason}" }`));
};
const db = document.getElementById("tk-db");
wire(db, logger("tk-db-log"));
document.getElementById("tk-db-fill").addEventListener("click", () => {
const input = db.querySelector(".mv-two-key-input");
input.value = "orders-prod-db";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const wireEl = document.getElementById("tk-wire");
const wireLog = logger("tk-wire-log");
wire(wireEl, wireLog);
let pending = 0;
wireEl.addEventListener("mv-request", (e) => {
wireLog("mv-request → notifying Priya Raman");
clearTimeout(pending);
const decline = document.getElementById("tk-decline").checked;
pending = setTimeout(() => {
if (decline) e.detail.reject("the invoice amount doesn't match the PO");
else e.detail.resolve("Priya Raman");
}, 2000);
});
</script>
</div>Kulturelle Referenz
WarGames: Kriegsspiele, John Badham (1983, Film). Ein Raketenstart erfordert, dass zwei Offiziere gleichzeitig zwei Schlüssel drehen, die zu weit auseinander liegen, als dass eine Person beide erreichen könnte. In der UI wird eine kritische Aktion erst nach zwei bewussten, synchronisierten Schlüsseldrehungen freigegeben, oder nach einem Schlüssel, den ein zweiter Freigeber dreht.
API
Attributes
| Name | Typ | Default | Description |
|---|---|---|---|
mode | sync | two-person | sync | sync: the user turns both keys within window ms. two-person: the user turns key A, key B belongs to a second approver (mv-request, approve(), reject()). |
window | number (ms) | 3000 | sync: maximum time between the two turns (min 300). The first key latches with a countdown ring; when it runs out both keys reset with “Out of sync”. |
armed | number (ms) | 5000 | How long the action stays unlocked once both keys are turned (min 1000). Lapsing disarms (mv-abort reason "timeout"). |
chord | string | F J | sync: the two keyboard keys (shown on the key caps) that turn key A and key B when held together while focus is inside the component. Letting go before the other key is down un-turns it. Ignored while typing in a field. |
confirm-text | string | Adds a “Type orders-prod-db to unlock the keys” field: the keys stay locked (aria-disabled) until the exact text is entered (case-sensitive, trimmed). The field is read-only while a sequence is in progress. | |
action-label | string | Confirm | Label of the built-in action button, used when no slot="action" is provided. |
label-a / label-b | string | Key 1 / Key 2 (two-person: Your key / Second approver) | Visible labels (and accessible names) of the two keys. |
done-label | string | Done | Message shown (and announced) after the action fired. |
disabled | boolean | Locks both keys and the action. Setting it mid-sequence disarms (mv-abort reason "disabled"). | |
data-state | idle | locked | latched | waiting | armed | fired | out-of-sync | rejected | Set by the component (fired, out-of-sync and rejected return to idle after ~1.8s). |
Properties
| Name | Typ | Description |
|---|---|---|
state | string | Read-only: current state (see data-state). |
turned | { a: boolean, b: boolean } | Read-only: which keys are turned. |
approver | string | null | Read-only (two-person): name given to approve(). |
Methods
| Name | Description |
|---|---|
turn("a" | "b") | Turns a key as if the user did (key B is refused in two-person mode). Returns false if not allowed (locked, already turned, armed…). |
approve(name?) | two-person, while waiting: key B turns, the name is shown under it and the action arms. |
reject(reason?) | two-person, while waiting: key A resets and “Declined: reason” is shown (mv-abort reason "rejected"). |
disarm(reason?) | Cancels a latched key, a pending request or an armed action (mv-abort with that reason, default "manual"). |
reset() | Silently returns to the initial state. |
Events
| Name | Description |
|---|---|
mv-turn | A key turned or un-turned. detail: { key: "a" | "b", turned, source?, approver? } (source: pointer | keyboard | chord | click | api). |
mv-request | two-person: key A was turned and a second approval is needed. detail: { resolve(name), reject(reason) }, equivalent to approve() / reject(), ignored once the request is cancelled or superseded. |
mv-arm | Both keys are turned: the action is unlocked. detail: { approver, expires (ms) }. |
mv-launch | The unlocked action was pressed. detail: { approver, mode }. Clicks on the action never get through while it is not armed (blocked in the capture phase). |
mv-abort | A sequence stopped. detail: { reason: "out-of-sync" | "timeout" | "escape" | "cancelled" | "released" | "rejected" | "disabled" | custom, detail? }. |
Content structure
| Name | Description |
|---|---|
action | The guarded button (e.g. <button slot="action" class="mv-button" data-variant="destructive">). It is moved under the lid, disabled until armed and stretched to full width. Without it, a built-in button labelled by action-label is rendered. Other children are not kept. |
CSS classes
| Name | Description |
|---|---|
mv-two-key-confirm / -input / -need | Typed confirmation: label, field, expected text. |
mv-two-key-deck | Panel holding the two keys and the link between them (role=group). |
mv-two-key-key | One key (data-key a | b, data-turned, data-latched, data-remote, data-waiting). |
mv-two-key-switch / -dial / -barrel / -slot / -ring / -marks | The rotary switch: button, dial, rotating barrel with its key slot, countdown ring, off/on marks. |
mv-two-key-caption / -label / -kbd / -who | Key caption: label, keyboard cap or approver name. |
mv-two-key-link | Connector whose halves fill as each key turns; its node unlocks when armed. |
mv-two-key-guard / -lid / -fuse | The guarded action, its hinged frosted lid and the armed-time fuse. |
mv-two-key-status / -count | Status line (role=status inside, data-tone neutral | progress | armed | danger | success) and the ticking countdown chip (aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-two-key-color | var(--mv-accent) | Turned keys, rings, link and fuse. |
--mv-two-key-danger | var(--mv-danger) | Out of sync / declined feedback. |
--mv-two-key-size | 5.5rem | Diameter of each key dial. |
--mv-two-key-duration | var(--mv-duration-slower) | Duration of the quarter-turn spring. |
Accessibility
Each key is a real <button role="switch"> with aria-checked, named by its visible label (“Key 1”), aria-keyshortcuts for its chord key, and aria-disabled (still focusable) while locked, armed or owned by the remote approver (the remote key is removed from the tab order). Keyboard: Tab to a key, Space or Enter turns it (again to turn it back while latched); holding the chord keys (F + J by default) turns both; Escape cancels a latched key, a pending request or an armed action. When the action arms, focus moves to it if focus was inside the component, and returns to key 1 after firing or disarming. Pointer: keys turn on pointerdown, so two fingers on two keys turn both at the same time (touch-action: none, no context menu); clicks synthesized by assistive technology (no pointer, no key) are honored too. The status line is a polite role=status (“Key 1 turned. Turn Key 2 within 3 seconds.”, “Waiting for a second approver…”, “Approved by Priya Raman. Armed: … is unlocked for 8 seconds. Press Escape to cancel.”, “Out of sync…”) and describes the action button; the ticking seconds are an aria-hidden chip so nothing chatters. The action is disabled until armed and its clicks are blocked in the capture phase. Color is never the only cue: barrel angle, marks, text and icons change together. Reduced motion (system or data-motion="reduce"): no rotation animation, lid, fills and messages change instantly, the waiting ring does not spin. The component only orchestrates the UI: enforce dual control on the server.